diff --git a/blitz/app.py b/blitz/app.py index bd51bc2..6068b3a 100644 --- a/blitz/app.py +++ b/blitz/app.py @@ -6,13 +6,14 @@ from blitz.models.blitz.field import _BlitzNullValue, AllowedBlitzFieldTypes, BlitzField, BlitzType from blitz.models.blitz.file import BlitzFile from blitz.models.blitz.resource import BlitzResource, BlitzResourceConfig -from blitz.parser import _find_blitz_file_path, parse_file +from blitz.parser import find_blitz_file_path from blitz.db.migrations import generate_migration, run_migrations import warnings from sqlalchemy import exc as sa_exc from semver import Version from loguru import logger + class ReleaseLevel(enum.Enum): PATCH = "PATCH" MINOR = "MINOR" @@ -55,7 +56,7 @@ def _load_versions(self) -> None: continue try: - _find_blitz_file_path(self.path / str(version)) + find_blitz_file_path(self.path / str(version)) except Exception: raise ValueError( f"Blitz app {self.name} has a version dir '{version}' without a blitz file inside." @@ -70,7 +71,7 @@ def get_version(self, version: Version) -> "BlitzApp": return BlitzApp( name=self.name, path=self.path, - file=parse_file(_find_blitz_file_path(self.path / str(version))), + file=BlitzFile.from_file(find_blitz_file_path(self.path / str(version))), in_memory=self._in_memory, version=version, ) @@ -149,7 +150,7 @@ def release(self, level: ReleaseLevel, force: bool = False) -> Version: raise Exception # We run the migrations to the latest version latest_blitz_app = BlitzApp( - "", latest_version_path, parse_file(latest_version_path / self.file.path.name), in_memory=True + "", latest_version_path, BlitzFile.from_file(latest_version_path / self.file.path.name), in_memory=True ) with warnings.catch_warnings(): diff --git a/blitz/cli/commands/create.py b/blitz/cli/commands/create.py index 853effc..92194b0 100644 --- a/blitz/cli/commands/create.py +++ b/blitz/cli/commands/create.py @@ -65,9 +65,7 @@ class BlitzProjectCreator: DEMO_BLITZ_APP_DESCRIPTION = "This is a demo blitz app" DEMO_BLITZ_APP_NAME = "Demo Blitz App" - def __init__( - self, name: str, description: str, file_format: str, demo: bool = False - ) -> None: + def __init__(self, name: str, description: str, file_format: str, demo: bool = False) -> None: self.name = name self.description = description self.file_format = file_format @@ -95,9 +93,7 @@ def create_file_or_exit(self) -> None: raise typer.Exit(code=1) def print_success_message(self) -> None: - print( - f"\n[medium_purple1 bold]{self.name}[/medium_purple1 bold] created successfully !" - ) + print(f"\n[medium_purple1 bold]{self.name}[/medium_purple1 bold] created successfully !") print("To start your app, you can use:") print(f" [bold medium_purple1]blitz start {self.path}[/bold medium_purple1]") @@ -127,9 +123,7 @@ def _write_blitz_file(self) -> Path: raise Exception() match self.file_format: case "json": - blitz_file_data = self.blitz_file.model_dump_json( - indent=4, by_alias=True, exclude_unset=True - ) + blitz_file_data = self.blitz_file.model_dump_json(indent=4, by_alias=True, exclude_unset=True) case "yaml": blitz_file_data = yaml.dump( self.blitz_file.model_dump(by_alias=True, exclude_unset=True), @@ -151,9 +145,7 @@ def _print_file_error(error: Exception) -> None: @staticmethod def _print_directory_error(error: Exception) -> None: - print( - f"[red bold]Error[/red bold] while creating the blitz app in the file system: {error}" - ) + print(f"[red bold]Error[/red bold] while creating the blitz app in the file system: {error}") def create_blitz_app( diff --git a/blitz/cli/commands/release.py b/blitz/cli/commands/release.py index 7f507d4..5d2343f 100644 --- a/blitz/cli/commands/release.py +++ b/blitz/cli/commands/release.py @@ -38,6 +38,6 @@ def release_blitz( except MigrationNoChangesDetectedError: raise NoChangesDetectedError() - print(f"Blitz app {blitz_app_name} released at version {new_version}") + print(f"Blitz app {blitz_app.name} released at version {new_version}") print("You can now start your versioned blitz app by running:") - print(f" [bold medium_purple1]blitz start {blitz_app_name} --version {new_version}[/bold medium_purple1]") + print(f" [bold medium_purple1]blitz start {blitz_app.name} --version {new_version}[/bold medium_purple1]") diff --git a/blitz/cli/errors/__init__.py b/blitz/cli/errors/__init__.py index 39e4f35..038a2b7 100644 --- a/blitz/cli/errors/__init__.py +++ b/blitz/cli/errors/__init__.py @@ -39,4 +39,4 @@ def __init__(self) -> None: class NoChangesDetectedError(BlitzCLIError): def __init__(self) -> None: print("No changes detected since the latest version. Use --force to release anyway.") - super().__init__(code=self.CODE) \ No newline at end of file + super().__init__(code=self.CODE) diff --git a/blitz/core.py b/blitz/core.py index cfc6192..8a2b3b3 100644 --- a/blitz/core.py +++ b/blitz/core.py @@ -1,7 +1,8 @@ from pathlib import Path from blitz.app import BlitzApp -from blitz.parser import _find_blitz_app_path, _find_blitz_file_path, parse_file +from blitz.models.blitz.file import BlitzFile +from blitz.parser import find_blitz_app_path, find_blitz_file_path from blitz.settings import DBTypes, get_settings @@ -25,9 +26,9 @@ def _discover_apps(self) -> None: for dotfile in Path(".").glob(f"**/*{self.BLITZ_DOT_FILE}"): blitz_app_name = dotfile.parent.name - blitz_app_path = _find_blitz_app_path(blitz_app_name) - blitz_file_path = _find_blitz_file_path(blitz_app_path) - blitz_file = parse_file(blitz_file_path) + blitz_app_path = find_blitz_app_path(blitz_app_name) + blitz_file_path = find_blitz_file_path(blitz_app_path) + blitz_file = BlitzFile.from_file(blitz_file_path) self.apps.append( BlitzApp( diff --git a/blitz/models/base.py b/blitz/models/base.py index 534b1e5..c03e021 100644 --- a/blitz/models/base.py +++ b/blitz/models/base.py @@ -161,6 +161,7 @@ def create_resource_model( fields: dict[Any, Any] = {} for field_name, field in resource_config.fields.items(): extra = {} + if not isinstance(field.default, _BlitzNullValue): extra["default"] = field.default @@ -178,6 +179,10 @@ def create_resource_model( if not isinstance(field.unique, _BlitzNullValue): extra["unique"] = field.unique + if field.settings and field.settings.description is not None: + extra["description"] = field.settings.description + extra["title"] = field.settings.description + if field.type == AllowedBlitzFieldTypes.foreign_key: pass elif field.type == AllowedBlitzFieldTypes.relationship: diff --git a/blitz/models/blitz/field.py b/blitz/models/blitz/field.py index e7a72ca..f64d76a 100644 --- a/blitz/models/blitz/field.py +++ b/blitz/models/blitz/field.py @@ -2,7 +2,7 @@ from blitz.models.utils import ContainsEnum from typing import Any, ClassVar -from pydantic import BaseModel, computed_field, field_validator, model_serializer +from pydantic import BaseModel, computed_field, field_validator, model_serializer, Field import uuid from datetime import datetime import logging @@ -52,9 +52,7 @@ class BlitzType(BaseModel): def __init_subclass__(cls, **kwargs: Any) -> None: for allowed_type in AllowedBlitzFieldTypes: if allowed_type not in cls.TYPE_MAPPING: - logger.warning( - f"Type {allowed_type} is not mapped with a factory in {cls.__name__}.TYPE_MAPPING." - ) + logger.warning(f"Type {allowed_type} is not mapped with a factory in {cls.__name__}.TYPE_MAPPING.") @computed_field # type: ignore @property @@ -81,6 +79,12 @@ class BlitzField(BaseModel): class Config: arbitrary_types_allowed = True + class Settings(BaseModel): + FIELD_PREFIX: ClassVar[str] = "_" + description: str | None = None + + settings: Settings = Field(Settings(), exclude=True) + # Modifiers are used to define the properties of a field in the shortcut version of the blitz field _unique_modifier: ClassVar[str] = "!" _nullable_modifier: ClassVar[str] = "?" @@ -117,7 +121,7 @@ def _string_to_customtype(cls, v: str | BlitzType) -> BlitzType: # Need a fix in pydantic maybe use a custom method to serialize the model and not the @model_serializer # @model_serializer - # def _serialize_model(self) -> dict[str, Any] | str: + # def _serialize_model(self) -> dict[str, Any]: # if isinstance(self._raw_field_value, dict): # return self.model_dump() # elif isinstance(self._raw_field_value, str): @@ -126,22 +130,15 @@ def _string_to_customtype(cls, v: str | BlitzType) -> BlitzType: # raise ValueError(f"Type `{type(self._raw_field_value)}` not allowed") @classmethod - def from_shortcut_version( - cls, raw_field_name: str, raw_field_value: str - ) -> "BlitzField": + def from_shortcut_version(cls, raw_field_name: str, raw_field_value: str) -> "BlitzField": field_name = raw_field_name.strip(cls._field_name_shortcut_modifiers) field_name_modifiers = raw_field_name[len(field_name) :] field_value = raw_field_value.strip(cls._field_value_shortcut_modifiers) field_value_modifiers = raw_field_value[len(field_value) :] - if ( - cls._required_modifier in field_value_modifiers - and cls._nullable_modifier in field_value_modifiers - ): - raise ValueError( - f"Field `{field_name}` cannot be both required and nullable." - ) + if cls._required_modifier in field_value_modifiers and cls._nullable_modifier in field_value_modifiers: + raise ValueError(f"Field `{field_name}` cannot be both required and nullable.") if field_value in AllowedBlitzFieldTypes: field_type = AllowedBlitzFieldTypes(field_value) @@ -152,8 +149,7 @@ def from_shortcut_version( params: dict[str, Any] = {} params["nullable"] = ( - cls._nullable_modifier in field_value_modifiers - or field_type == AllowedBlitzFieldTypes.foreign_key + cls._nullable_modifier in field_value_modifiers or field_type == AllowedBlitzFieldTypes.foreign_key ) params["unique"] = cls._unique_modifier in field_name_modifiers if cls._nullable_modifier in field_value_modifiers: @@ -164,9 +160,7 @@ def from_shortcut_version( if field_type == AllowedBlitzFieldTypes.relationship: params["relationship"] = field_value - params["relationship_list"] = ( - cls._relationship_list_modifier in field_value_modifiers - ) + params["relationship_list"] = cls._relationship_list_modifier in field_value_modifiers return cls( _raw_field_name=raw_field_name, diff --git a/blitz/models/blitz/file.py b/blitz/models/blitz/file.py index 2e06859..d84dd6c 100644 --- a/blitz/models/blitz/file.py +++ b/blitz/models/blitz/file.py @@ -1,14 +1,25 @@ -from typing import Any -from pydantic import BaseModel, Field +from typing import Any, ClassVar, NoReturn +from pydantic import BaseModel, Field, field_serializer from blitz.models.blitz.config import BlitzAppConfig from blitz.models.blitz.resource import BlitzResourceConfig from pathlib import Path from enum import StrEnum +import json +import yaml -class FileType(StrEnum): - JSON = "json" - YAML = "yaml" +def _get_data_from_json(file: Path) -> dict[str, dict[str, Any]]: + with open(file, "r") as f: + return dict(json.load(f)) + + +def _get_data_from_yaml(file: Path) -> dict[str, dict[str, Any]]: + with open(file, "r") as f: + return dict(yaml.safe_load(f)) + + +def _no_parser_for_suffix(file: Path) -> NoReturn: + raise ValueError(f"No parser for {file}") class BlitzFile(BaseModel): @@ -16,12 +27,68 @@ class BlitzFile(BaseModel): The Blitz file is the configuration file for a Blitz app. It contains the BlitzAppConfig and a list of BlitzResourceConfig. """ - path: Path | None = Field(default=None, exclude=True) - file_type: FileType | None = Field(default=None, exclude=True) + class FileType(StrEnum): + JSON = "json" + YAML = "yaml" + + CONFIG_FIELD_NAME: ClassVar[str] = "config" + RESOURCES_FIELD_NAME: ClassVar[str] = "resources" + config: BlitzAppConfig - resources_configs: list[BlitzResourceConfig] = Field([], serialization_alias="resources") + resources_configs: list[BlitzResourceConfig] = Field(default=[], serialization_alias=RESOURCES_FIELD_NAME) raw_file: dict[str, Any] = Field(exclude=True) + path: Path | None = Field(default=None, exclude=True) + file_type: FileType | None = Field(default=None, exclude=True) # def write(self) -> None: # with open(self.path, "w") as blitz_file: # blitz_file.write(self.model_dump_json) + + @field_serializer("resources_configs") + def _serialize_resources_configs(self, resources_configs: list[BlitzResourceConfig], _info: Any) -> dict[str, Any]: + serialized_resources_configs = {} + for resource_config in resources_configs: + serialized_resources_configs[resource_config.name] = resource_config.model_dump() + + return serialized_resources_configs + + @classmethod + def from_file(cls, file_path: Path) -> "BlitzFile": + blitz_file = { + cls.FileType.JSON.value: _get_data_from_json, + cls.FileType.YAML.value: _get_data_from_yaml, + }.get(file_path.suffix[1:], _no_parser_for_suffix)(file_path) + + return cls.from_dict( + blitz_file, + path=file_path.absolute(), + file_type=cls.FileType(file_path.suffix.removeprefix(".")), + ) + + @classmethod + def from_dict( + cls, + blitz_file: dict[str, dict[str, Any]], + path: Path | None = None, + file_type: FileType | None = None, + ) -> "BlitzFile": + resources_configs: list[BlitzResourceConfig] = [] + resource_name: str + resource_config: dict[str, Any] + for resource_name, resource_config in blitz_file.get(cls.RESOURCES_FIELD_NAME, {}).items(): + settings_fields = {} + fields = {} + for field_name, field_value in resource_config.items(): + if field_name.startswith(BlitzResourceConfig.Settings.FIELD_PREFIX): + settings_fields[field_name[len(BlitzResourceConfig.Settings.FIELD_PREFIX) :]] = field_value + else: + fields[field_name] = field_value + resources_configs.append(BlitzResourceConfig(name=resource_name, fields=fields, settings=settings_fields)) + + return cls( + config=blitz_file.get(cls.CONFIG_FIELD_NAME), + resources_configs=resources_configs, + raw_file=blitz_file, + path=path, + file_type=file_type, + ) diff --git a/blitz/models/blitz/resource.py b/blitz/models/blitz/resource.py index f75e9d6..418d8c6 100644 --- a/blitz/models/blitz/resource.py +++ b/blitz/models/blitz/resource.py @@ -1,5 +1,5 @@ -from typing import Any -from pydantic import BaseModel, field_validator +from typing import Any, ClassVar +from pydantic import BaseModel, field_validator, model_serializer from blitz.models.blitz.field import BlitzField from blitz.models.base import BaseResourceModel @@ -11,9 +11,14 @@ class BlitzResourceConfig(BaseModel): If the fields is a string, we are reading it like a shortcut version of a BlitzField object. """ + class Settings(BaseModel): + FIELD_PREFIX: ClassVar[str] = "_" + allowed_methods: str = "CRUD" + description: str | None = None + name: str - allowed_methods: str = "CRUD" fields: dict[str, BlitzField] + settings: Settings = Settings() @field_validator("fields", mode="before") def _string_to_fields(cls, v: dict[str, Any | dict[str, Any]]) -> dict[str, BlitzField]: @@ -26,30 +31,49 @@ def _string_to_fields(cls, v: dict[str, Any | dict[str, Any]]) -> dict[str, Blit # Else if the field value is a dict, it must be a BlitzField object elif isinstance(raw_field_value, dict): + settings_fields_config = {} + field_config = {} + for field_config_name, field_config_value in raw_field_value.items(): + if field_config_name.startswith(BlitzField.Settings.FIELD_PREFIX): + settings_fields_config[ + field_config_name[len(BlitzField.Settings.FIELD_PREFIX) :] + ] = field_config_value + else: + field_config[field_config_name] = field_config_value fields[field_name] = BlitzField( _raw_field_name=raw_field_name, _raw_field_value=raw_field_value, - **raw_field_value, + settings=settings_fields_config, + **field_config, ) else: raise ValueError(f"Type `{type(raw_field_value)}` not allowed for field `{raw_field_name}`") return fields + @model_serializer + def _serialize_model(self) -> dict[str, Any]: + serialized_model = {} + for field_name, field in self.fields.items(): + serialized_model[field_name] = field.model_dump(exclude_unset=True) + for setting_name, setting_value in self.settings.model_dump(exclude_unset=True).items(): + serialized_model[f"{self.Settings.FIELD_PREFIX}{setting_name}"] = setting_value + return serialized_model + @property def can_create(self) -> bool: - return "C" in self.allowed_methods + return "C" in self.settings.allowed_methods @property def can_read(self) -> bool: - return "R" in self.allowed_methods + return "R" in self.settings.allowed_methods @property def can_update(self) -> bool: - return "U" in self.allowed_methods + return "U" in self.settings.allowed_methods @property def can_delete(self) -> bool: - return "D" in self.allowed_methods + return "D" in self.settings.allowed_methods class BlitzResource(BaseModel): @@ -60,3 +84,57 @@ class BlitzResource(BaseModel): config: BlitzResourceConfig model: type[BaseResourceModel] + + +BlitzResourceConfig( + name="Food", + fields={ + "name!": "str!", + "expiration_date": "datetime!", + }, + settings={}, +) + +BlitzResourceConfig( + name="Ingredient", + fields={ + "food_id": "Food.id", + "food": "Food", + "recipe_id": "Recipe.id!", + "recipe": "Recipe", + }, + settings={}, +) + +BlitzResourceConfig( + name="Recipe", + fields={ + "name!": "str!", + "ingredients": "Ingredient[]", + "cook_id": "Cook.id!", + "cook": "Cook", + }, + settings={}, +) + +BlitzResourceConfig( + name="Cook", + fields={ + "name!": "str!", + "age": "int!", + "recipes": "Recipe[]", + "rat": "Rat", + }, + settings={}, +) + +BlitzResourceConfig( + name="Rat", + fields={ + "name!": "str!", + "age": "int!", + "cook_id!": "Cook.id!", + "cook": "Cook", + }, + settings={}, +) diff --git a/blitz/parser.py b/blitz/parser.py index 4c54dd0..a635304 100644 --- a/blitz/parser.py +++ b/blitz/parser.py @@ -1,65 +1,24 @@ -import json from pathlib import Path -import yaml -from typing import Any, NoReturn - from blitz.models.blitz import BlitzFile -from blitz.models.blitz.file import FileType - - -def _get_data_from_json(file: Path) -> dict[str, Any]: - with open(file, "r") as f: - return dict(json.load(f)) - - -def _get_data_from_yaml(file: Path) -> dict[str, Any]: - with open(file, "r") as f: - return dict(yaml.safe_load(f)) - -def _no_parser_for_suffix(file: Path) -> NoReturn: - raise ValueError(f"No parser for {file}") - -def _find_blitz_app_path(blitz_app_name: str) -> Path: +def find_blitz_app_path(blitz_app_name: str) -> Path: blitz_app_path = (Path(".") / Path(blitz_app_name)).absolute() if not blitz_app_path.is_dir(): raise FileNotFoundError(f"Could not find a Blitz app in {blitz_app_path}.") return blitz_app_path -def _find_blitz_file_path(blitz_app_path: Path) -> Path: - yaml_blitz_file = blitz_app_path / "blitz.yaml" - json_blitz_file = blitz_app_path / "blitz.json" +def find_blitz_file_path(blitz_app_path: Path) -> Path: + blitz_file_path: Path | None = None + for type in BlitzFile.FileType: + blitz_file = blitz_app_path / f"blitz.{type.value}" + if blitz_file.exists(): + if blitz_file_path is not None: + raise ValueError(f"Found multiple Blitz files in {blitz_app_path}.") + blitz_file_path = blitz_file - if yaml_blitz_file.exists() and json_blitz_file.exists(): - raise ValueError(f"Found both a YAML and a JSON Blitz file in {blitz_app_path}.") - if yaml_blitz_file.exists(): - return yaml_blitz_file - elif json_blitz_file.exists(): - return json_blitz_file - else: + if blitz_file_path is None: raise FileNotFoundError(f"Could not find a Blitz file in {blitz_app_path}.") - - -def parse_file(file_path: Path) -> BlitzFile: - blitz_file_fields = { - ".json": _get_data_from_json, - ".yaml": _get_data_from_yaml, - }.get(file_path.suffix, _no_parser_for_suffix)(file_path) - return BlitzFile( - path=file_path.absolute(), - file_type=FileType(file_path.suffix.removeprefix(".")), - config=blitz_file_fields["config"], - resources_configs=blitz_file_fields["resources"], - raw_file=blitz_file_fields, - ) - - -def create_blitz_file_from_dict(blitz_file_content: dict[str, dict[str, Any]]) -> BlitzFile: - return BlitzFile( - config=blitz_file_content.get("config"), - resources_configs=blitz_file_content.get("resources"), - raw_file=blitz_file_content, - ) + return blitz_file_path diff --git a/blitz/ui/blitz_ui.py b/blitz/ui/blitz_ui.py index 20a0f40..3e72215 100644 --- a/blitz/ui/blitz_ui.py +++ b/blitz/ui/blitz_ui.py @@ -70,7 +70,7 @@ def get_ressources(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: rows.append( { "name": ressource.config.name, - "allowed_methods": ressource.config.allowed_methods, + "allowed_methods": ressource.config.settings.allowed_methods, } ) diff --git a/blitz/ui/components/gpt_chat_components.py b/blitz/ui/components/gpt_chat_components.py index 9453ef4..34a2349 100644 --- a/blitz/ui/components/gpt_chat_components.py +++ b/blitz/ui/components/gpt_chat_components.py @@ -5,7 +5,7 @@ from nicegui.elements.dialog import Dialog from nicegui.elements.expansion import Expansion from pydantic import ValidationError -from blitz.parser import create_blitz_file_from_dict +from blitz.models.blitz.file import BlitzFile from openai.types.chat import ChatCompletionMessageParam @@ -40,7 +40,7 @@ def _get_color(is_valid: bool) -> str: def validate_blitz_file(self, json: dict[str, Any]) -> bool: try: - create_blitz_file_from_dict(json) + BlitzFile.from_dict(json) except ValidationError: return False else: diff --git a/blitz/ui/components/json_editor.py b/blitz/ui/components/json_editor.py index 2c952bd..cc3fe58 100644 --- a/blitz/ui/components/json_editor.py +++ b/blitz/ui/components/json_editor.py @@ -3,8 +3,7 @@ from nicegui import ui, app from pydantic import ValidationError import yaml -from blitz.models.blitz.file import FileType -from blitz.parser import create_blitz_file_from_dict +from blitz.models.blitz.file import BlitzFile from blitz.ui.blitz_ui import BlitzUI, get_blitz_ui from blitz.ui.components.header import DARK_PINK, MAIN_PINK @@ -65,7 +64,7 @@ def reset_content(self) -> None: def validate(self) -> None: try: - create_blitz_file_from_dict(self.content) + BlitzFile.from_dict(self.content) except ValidationError: ui.notify("Invalid Blitz File", type="negative") else: @@ -73,7 +72,7 @@ def validate(self) -> None: def save(self) -> None: try: - create_blitz_file_from_dict(self.content) + BlitzFile.from_dict(self.content) except ValidationError: ui.notify("Invalid Blitz File", type="negative") return @@ -85,9 +84,9 @@ def save(self) -> None: # TODO: handle error raise Exception with open(self.blitz_ui.current_app.file.path, "w") as f: - if self.blitz_ui.current_app.file.file_type == FileType.JSON: + if self.blitz_ui.current_app.file.file_type == BlitzFile.FileType.JSON: f.write(json.dumps(self.content, indent=4)) - elif self.blitz_ui.current_app.file.file_type == FileType.YAML: + elif self.blitz_ui.current_app.file.file_type == BlitzFile.FileType.YAML: f.write(yaml.dump(self.content, indent=4)) except Exception: ui.notify("Error While Saving File", type="negative")