diff --git a/altb/config.py b/altb/config.py index 5be49e4..f6fb048 100644 --- a/altb/config.py +++ b/altb/config.py @@ -12,11 +12,13 @@ import yaml import typer import pydantic -from pydantic import BaseModel, BaseSettings, PrivateAttr, StrictBool, StrictStr, Field, BaseConfig +from pydantic import BaseModel, BaseSettings, PrivateAttr, StrictStr, Field, BaseConfig from rich.console import ConsoleOptions, RenderResult, Console, ConsoleRenderable from altb.constants import CONFIG_FILE, TYPE_TO_COLOR, PACKAGE_NAME, VERSIONS_DIRECTORY from altb.common import error_console +from altb.migrator import migrator +from altb.migrator.versions import ConfigVersion class RichText(ConsoleRenderable): @@ -58,6 +60,7 @@ def __rich_console__( class TagKind(str, Enum): link = "link" command = "command" + build = "build" class BaseTag(BaseModel, abc.ABC): @@ -77,7 +80,6 @@ def __eq__(self, other): class LinkTagSpec(BaseModel): path: pathlib.Path - is_copy: Optional[StrictBool] = False class Config(BaseConfig): extra = pydantic.Extra.forbid @@ -114,6 +116,28 @@ class CommandTag(BaseTag): spec: CommandTagSpec +class HashOptions(BaseModel): + sources_directories: List[str] + match_glob: List[str] + ignore_glob: List[str] + + +class BuildTagSpec(BaseModel): + build_command: str + working_directory: Optional[pathlib.Path] + env: Optional[dict[StrictStr, StrictStr]] + output_binary_path: str + hash_options: Optional[HashOptions] = None + + class Config(BaseConfig): + extra = pydantic.Extra.forbid + + +class BuildTag(BaseTag): + kind: Literal[TagKind.build] = TagKind.build + spec: BuildTagSpec + + TagConfig = Annotated[Union[CommandTag, LinkTag], Field(discriminator="kind")] TagDict = Dict[str, TagConfig] @@ -220,12 +244,14 @@ def assert_valid(self): class AppConfig(BaseModel): + version: str = ConfigVersion.latest binaries: Dict[str, BinaryStruct] = {} class Config(BaseConfig): json_encoders = { pathlib.Path: str, set: list, + ConfigVersion: str, } extra = pydantic.Extra.forbid @@ -275,7 +301,7 @@ def track_path(self, app_name: str, app_path: pathlib.Path, tag: str = None, self.remove(app_name, existing_tag) - spec = LinkTagSpec(path=app_path, is_copy=should_copy) + spec = LinkTagSpec(path=app_path) self.binaries[app_name][tag] = LinkTag(description=description, spec=spec) def rename_tag(self, app_name: str, tag: str, new_tag: str): @@ -346,13 +372,30 @@ class Config(BaseSettings.Config): _config: AppConfig = PrivateAttr(None) + def config_version(self): + if not self.config_path.exists(): + return DEFAULT_CONFIG.copy(), False + + with self.config_path.open() as f: + content = yaml.safe_load(f) + return content.get('version', ConfigVersion.latest) + + def migrate(self): + with self.config_path.open() as f: + content = yaml.safe_load(f) + + new_content = migrator.migrate(content) + with self.config_path.open(mode='w') as f: + yaml.safe_dump(new_content, f) + def save(self): directory = self.config_path.parent if not self.config_path.exists(): os.makedirs(directory, exist_ok=True) + to_dump = yaml.safe_load(self._config.json(exclude_none=True)) with self.config_path.open(mode='w') as f: - yaml.safe_dump(yaml.safe_load(self._config.json(exclude_none=True)), f) + yaml.safe_dump(to_dump, f) def load_or_create(self) -> Tuple[AppConfig, bool]: if not self.config_path.exists(): diff --git a/altb/main.py b/altb/main.py index b337a48..65ff4aa 100644 --- a/altb/main.py +++ b/altb/main.py @@ -6,7 +6,6 @@ import typer import yaml -import pkg_resources from rich.live import Live from rich.tree import Tree from rich.text import Text @@ -360,6 +359,12 @@ def schema( background_color="default", word_wrap=True)) +@app.command() +def migrate(ctx: typer.Context): + settings = ctx.ensure_object(Settings) + settings.migrate() + + @app.callback(invoke_without_command=True) def _main( version: Optional[bool] = typer.Option(None, '-v', '--version', help='Show version', is_eager=True), diff --git a/altb/migrator/__init__.py b/altb/migrator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/altb/migrator/definitions.py b/altb/migrator/definitions.py new file mode 100644 index 0000000..42e2c3e --- /dev/null +++ b/altb/migrator/definitions.py @@ -0,0 +1,3 @@ +import pathlib + +DIRECTORY = pathlib.Path(__file__).absolute().parent diff --git a/altb/migrator/migrations/__init__.py b/altb/migrator/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/altb/migrator/migrations/base_migration.py b/altb/migrator/migrations/base_migration.py new file mode 100644 index 0000000..b0fcf02 --- /dev/null +++ b/altb/migrator/migrations/base_migration.py @@ -0,0 +1,19 @@ +import abc +import pathlib +from abc import ABC + + +MIGRATIONS_DIRECTORY = pathlib.Path(__file__).absolute().parent +SCHEMAS_DIRECTORY = MIGRATIONS_DIRECTORY / 'schemas' + + +class BaseMigration(ABC): + EXPECTED_SCHEMA: pathlib.Path + + @abc.abstractmethod + def migrate(self, input_config: dict) -> dict: + return input_config + + @abc.abstractmethod + def rollback(self, input_config: dict) -> dict: + return input_config diff --git a/altb/migrator/migrations/migration_001_initial.py b/altb/migrator/migrations/migration_001_initial.py new file mode 100644 index 0000000..4dac16d --- /dev/null +++ b/altb/migrator/migrations/migration_001_initial.py @@ -0,0 +1,11 @@ +from .base_migration import BaseMigration, SCHEMAS_DIRECTORY + + +class MigrationInitial(BaseMigration): + EXPECTED_SCHEMA = SCHEMAS_DIRECTORY / "001_AppConfig.json" + + def migrate(self, input_config: dict) -> dict: + return input_config + + def rollback(self, input_config: dict) -> dict: + return input_config diff --git a/altb/migrator/migrations/migration_002_remove_is_copy.py b/altb/migrator/migrations/migration_002_remove_is_copy.py new file mode 100644 index 0000000..5188cf4 --- /dev/null +++ b/altb/migrator/migrations/migration_002_remove_is_copy.py @@ -0,0 +1,16 @@ +from .base_migration import BaseMigration, SCHEMAS_DIRECTORY + + +class MigrationRemoveIsCopy(BaseMigration): + EXPECTED_SCHEMA = SCHEMAS_DIRECTORY / "002_AppConfig.json" + + def migrate(self, input_config: dict) -> dict: + for binary in input_config['binaries'].values(): + for tag in binary['tags'].values(): + if tag['kind'] == 'link': + del tag['spec']['is_copy'] + + return input_config + + def rollback(self, input_config: dict) -> dict: + return input_config diff --git a/altb/migrator/migrations/schemas/001_AppConfig.json b/altb/migrator/migrations/schemas/001_AppConfig.json new file mode 100644 index 0000000..efe5a25 --- /dev/null +++ b/altb/migrator/migrations/schemas/001_AppConfig.json @@ -0,0 +1 @@ +{"title": "AppConfig", "type": "object", "properties": {"version": {"title": "Version", "default": "0.1.0", "type": "string"}, "binaries": {"title": "Binaries", "default": {}, "type": "object", "additionalProperties": {"$ref": "#/definitions/BinaryStruct"}}}, "additionalProperties": false, "definitions": {"CommandTagSpec": {"title": "CommandTagSpec", "type": "object", "properties": {"command": {"title": "Command", "type": "string"}, "working_directory": {"title": "Working Directory", "type": "string", "format": "path"}, "env": {"title": "Env", "type": "object", "additionalProperties": {"type": "string"}}}, "required": ["command"], "additionalProperties": false}, "CommandTag": {"title": "CommandTag", "type": "object", "properties": {"kind": {"title": "Kind", "default": "command", "enum": ["command"], "type": "string"}, "description": {"title": "Description", "type": "string"}, "spec": {"$ref": "#/definitions/CommandTagSpec"}}, "required": ["spec"], "additionalProperties": false}, "LinkTagSpec": {"title": "LinkTagSpec", "type": "object", "properties": {"path": {"title": "Path", "type": "string", "format": "path"}, "is_copy": {"title": "Is Copy", "default": false, "type": "boolean"}}, "required": ["path"], "additionalProperties": false}, "LinkTag": {"title": "LinkTag", "type": "object", "properties": {"kind": {"title": "Kind", "default": "link", "enum": ["link"], "type": "string"}, "description": {"title": "Description", "type": "string"}, "spec": {"$ref": "#/definitions/LinkTagSpec"}}, "required": ["spec"], "additionalProperties": false}, "BinaryStruct": {"title": "BinaryStruct", "type": "object", "properties": {"name": {"title": "Name", "type": "string"}, "tags": {"title": "Tags", "default": {}, "type": "object", "additionalProperties": {"discriminator": {"propertyName": "kind", "mapping": {"command": "#/definitions/CommandTag", "link": "#/definitions/LinkTag"}}, "anyOf": [{"$ref": "#/definitions/CommandTag"}, {"$ref": "#/definitions/LinkTag"}]}}, "selected": {"title": "Selected", "type": "string"}}, "required": ["name"], "additionalProperties": false}}} \ No newline at end of file diff --git a/altb/migrator/migrations/schemas/002_AppConfig.json b/altb/migrator/migrations/schemas/002_AppConfig.json new file mode 100644 index 0000000..7111e4e --- /dev/null +++ b/altb/migrator/migrations/schemas/002_AppConfig.json @@ -0,0 +1 @@ +{"title": "AppConfig", "type": "object", "properties": {"version": {"title": "Version", "default": "0.1.0", "type": "string"}, "binaries": {"title": "Binaries", "default": {}, "type": "object", "additionalProperties": {"$ref": "#/definitions/BinaryStruct"}}}, "additionalProperties": false, "definitions": {"CommandTagSpec": {"title": "CommandTagSpec", "type": "object", "properties": {"command": {"title": "Command", "type": "string"}, "working_directory": {"title": "Working Directory", "type": "string", "format": "path"}, "env": {"title": "Env", "type": "object", "additionalProperties": {"type": "string"}}}, "required": ["command"], "additionalProperties": false}, "CommandTag": {"title": "CommandTag", "type": "object", "properties": {"kind": {"title": "Kind", "default": "command", "enum": ["command"], "type": "string"}, "description": {"title": "Description", "type": "string"}, "spec": {"$ref": "#/definitions/CommandTagSpec"}}, "required": ["spec"], "additionalProperties": false}, "LinkTagSpec": {"title": "LinkTagSpec", "type": "object", "properties": {"path": {"title": "Path", "type": "string", "format": "path"}}, "required": ["path"], "additionalProperties": false}, "LinkTag": {"title": "LinkTag", "type": "object", "properties": {"kind": {"title": "Kind", "default": "link", "enum": ["link"], "type": "string"}, "description": {"title": "Description", "type": "string"}, "spec": {"$ref": "#/definitions/LinkTagSpec"}}, "required": ["spec"], "additionalProperties": false}, "BinaryStruct": {"title": "BinaryStruct", "type": "object", "properties": {"name": {"title": "Name", "type": "string"}, "tags": {"title": "Tags", "default": {}, "type": "object", "additionalProperties": {"discriminator": {"propertyName": "kind", "mapping": {"command": "#/definitions/CommandTag", "link": "#/definitions/LinkTag"}}, "anyOf": [{"$ref": "#/definitions/CommandTag"}, {"$ref": "#/definitions/LinkTag"}]}}, "selected": {"title": "Selected", "type": "string"}}, "required": ["name"], "additionalProperties": false}}} \ No newline at end of file diff --git a/altb/migrator/migrator.py b/altb/migrator/migrator.py new file mode 100644 index 0000000..edbde20 --- /dev/null +++ b/altb/migrator/migrator.py @@ -0,0 +1,63 @@ +import json +from copy import deepcopy +from typing import List, Type +from dataclasses import dataclass + +from jsonschema import validate + +from .versions import ConfigVersion +from .migrations.base_migration import BaseMigration +from .migrations.migration_001_initial import MigrationInitial +from .migrations.migration_002_remove_is_copy import MigrationRemoveIsCopy + + +@dataclass +class MigrationConfig: + version: ConfigVersion + migration_class: Type[BaseMigration] + + +migrations: List[MigrationConfig] = [ + MigrationConfig(ConfigVersion.v0_0_0, MigrationInitial), + MigrationConfig(ConfigVersion.v0_1_0, MigrationRemoveIsCopy), +] + + +def is_migrated(current_version: ConfigVersion) -> bool: + return migrations[-1].version == current_version + + +def migration_index(version: ConfigVersion): + for i, config in enumerate(migrations): + if config.version == version: + return i + + return -1 + + +def migrate(config: dict) -> dict: + version: ConfigVersion + if 'version' not in config: + version = ConfigVersion.v0_0_0 + else: + version = ConfigVersion(config['version']) + + index = migration_index(version) + + print(f"detected config version of - {version}") + current_config = deepcopy(config) + migrations_needed = migrations[index+1:] + print(f"{len(migrations_needed)} migrations needed") + for migration in migrations_needed: + print(f"\tmigrating config to - {migration.version}") + instance = migration.migration_class() + current_config = instance.migrate(current_config) + current_config['version'] = str(migration.version.value) + + with open(instance.EXPECTED_SCHEMA) as f: + schema = json.load(f) + + validate(instance=current_config, schema=schema) + + print("done!") + return current_config diff --git a/altb/migrator/versions.py b/altb/migrator/versions.py new file mode 100644 index 0000000..207bdfa --- /dev/null +++ b/altb/migrator/versions.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class ConfigVersion(str, Enum): + v0_0_0 = "0.0.0" + v0_1_0 = "0.1.0" + latest = v0_1_0 diff --git a/poetry.lock b/poetry.lock index 626253a..7861022 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,6 +14,20 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + [[package]] name = "backcall" version = "0.2.0" @@ -99,6 +113,21 @@ docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] perf = ["ipython"] testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] +[[package]] +name = "importlib-resources" +version = "5.9.0" +description = "Read resources from Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] + [[package]] name = "ipdb" version = "0.13.9" @@ -159,6 +188,31 @@ parso = ">=0.8.0,<0.9.0" qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<7.0.0)"] +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +category = "dev" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jinja2-strcase" +version = "0.0.2" +description = "A python package for converting string case in jinja2 templates" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +jinja2 = "*" + [[package]] name = "jinxed" version = "1.2.0" @@ -170,6 +224,33 @@ python-versions = "*" [package.dependencies] ansicon = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "jsonschema" +version = "4.7.2" +description = "An implementation of JSON Schema validation for Python" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +attrs = ">=17.4.0" +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +importlib-resources = {version = ">=1.4.0", markers = "python_version < \"3.9\""} +pyrsistent = ">=0.14.0,<0.17.0 || >0.17.0,<0.17.1 || >0.17.1,<0.17.2 || >0.17.2" +typing-extensions = {version = "*", markers = "python_version < \"3.8\""} + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=1.11)"] + +[[package]] +name = "markupsafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" +optional = false +python-versions = ">=3.7" + [[package]] name = "matplotlib-inline" version = "0.1.3" @@ -258,6 +339,17 @@ typing-extensions = ">=3.7.4.3" dotenv = ["python-dotenv (>=0.10.4)"] email = ["email-validator (>=1.0.3)"] +[[package]] +name = "pydash" +version = "5.1.0" +description = "The kitchen sink of Python utility libraries for doing \"stuff\" in a functional way. Based on the Lo-Dash Javascript library." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +dev = ["black", "coverage", "docformatter", "flake8", "flake8-black", "flake8-bugbear", "flake8-isort", "invoke", "isort", "pylint", "pytest", "pytest-cov", "pytest-flake8", "pytest-pylint", "sphinx", "sphinx-rtd-theme", "tox", "twine", "wheel"] + [[package]] name = "pygments" version = "2.12.0" @@ -266,6 +358,14 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "pyrsistent" +version = "0.18.1" +description = "Persistent/Functional/Immutable data structures" +category = "main" +optional = false +python-versions = ">=3.7" + [[package]] name = "pyyaml" version = "6.0" @@ -366,7 +466,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "fd0ca1a7e1dee85f00adfd81f4b96915100c500f363d468036dce6448fd2d626" +content-hash = "4242347896b0f51112b4bf2b0c2bad5ca979e5d095fdd9bc94821a4a9f89efd2" [metadata.files] ansicon = [ @@ -374,6 +474,10 @@ ansicon = [ {file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"}, ] appnope = [] +attrs = [ + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, +] backcall = [ {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, @@ -394,6 +498,7 @@ getch = [ {file = "getch-1.0.tar.gz", hash = "sha256:a6c22717c10051ce65b8fb7bddb171af705b1175e694a73be956990f6089d8b1"}, ] importlib-metadata = [] +importlib-resources = [] ipdb = [ {file = "ipdb-0.13.9.tar.gz", hash = "sha256:951bd9a64731c444fd907a5ce268543020086a697f6be08f7cc2c9a752a278c5"}, ] @@ -402,7 +507,11 @@ jedi = [ {file = "jedi-0.18.1-py2.py3-none-any.whl", hash = "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d"}, {file = "jedi-0.18.1.tar.gz", hash = "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"}, ] +jinja2 = [] +jinja2-strcase = [] jinxed = [] +jsonschema = [] +markupsafe = [] matplotlib-inline = [ {file = "matplotlib-inline-0.1.3.tar.gz", hash = "sha256:a04bfba22e0d1395479f866853ec1ee28eea1485c1d69a6faf00dc3e24ff34ee"}, {file = "matplotlib_inline-0.1.3-py3-none-any.whl", hash = "sha256:aed605ba3b72462d64d475a21a9296f400a19c4f74a31b59103d2a99ffd5aa5c"}, @@ -426,7 +535,9 @@ ptyprocess = [ {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, ] pydantic = [] +pydash = [] pygments = [] +pyrsistent = [] pyyaml = [ {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, diff --git a/pyproject.toml b/pyproject.toml index 6b9a1f0..772d74f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,9 +20,13 @@ pydantic = "^1.8.2" typer = "^0.4.0" PyYAML = "^6.0" natsort = "^8.0.1" +jsonschema = "^4.7.2" [tool.poetry.dev-dependencies] ipdb = "^0.13.9" +Jinja2 = "^3.1.2" +jinja2-strcase = "^0.0.2" +pydash = "^5.1.0" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/definitions.py b/scripts/definitions.py new file mode 100644 index 0000000..00edec9 --- /dev/null +++ b/scripts/definitions.py @@ -0,0 +1,3 @@ +import pathlib + +ROOT_DIR = pathlib.Path(__file__).absolute().parent.parent diff --git a/scripts/templates/migrations.py.jinja2 b/scripts/templates/migrations.py.jinja2 new file mode 100644 index 0000000..ab7718f --- /dev/null +++ b/scripts/templates/migrations.py.jinja2 @@ -0,0 +1,12 @@ +from .base_migration import BaseMigration, SCHEMAS_DIRECTORY + + +class Migration{{ migration_name | to_camel }}(BaseMigration): + EXPECTED_SCHEMA = SCHEMAS_DIRECTORY / {{ schema_file | tojson}} + + def migrate(self, input_config: dict) -> dict: + return input_config + + def rollback(self, input_config: dict) -> dict: + return input_config + diff --git a/scripts/utils.py b/scripts/utils.py new file mode 100644 index 0000000..18e0ff1 --- /dev/null +++ b/scripts/utils.py @@ -0,0 +1,87 @@ +import os +import pathlib +import re +from dataclasses import dataclass + +import typer +from pydash.strings import snake_case +from jinja2 import Environment, FileSystemLoader + +from altb.config import AppConfig +from altb.migrator.migrations.base_migration import SCHEMAS_DIRECTORY, MIGRATIONS_DIRECTORY + +SCHEMA_FILENAME_PATTERN = re.compile(r"(?P(?P\d{3})_.*)\.json") +MIGRATION_FILENAME_PATTERN = re.compile(r"(?Pmigration_(?P\d{3})_.*)\.py") + +DIRECTORY = pathlib.Path(__file__).absolute().parent + + +env = Environment( + loader=FileSystemLoader(DIRECTORY / 'templates'), + extensions=['jinja2_strcase.StrcaseExtension'], +) + + +app = typer.Typer() + + +@dataclass +class Schema: + name: str + path: str + + +def get_latest_id(path: pathlib.Path, pattern: re.Pattern): + if not path.exists(): + return 0 + + latest = 0 + for filename in os.listdir(path): + match = pattern.match(filename) + if match is None: + continue + + count = int(match.group('count')) + if count > latest: + latest = count + + return latest + + +def generate_new_schema(schema_directory: pathlib.Path): + os.makedirs(schema_directory, exist_ok=True) + + schema_id = get_latest_id(schema_directory, pattern=SCHEMA_FILENAME_PATTERN) + 1 + schema_name = f"{schema_id:03d}_AppConfig.json" + + with open(schema_directory / schema_name, "w") as f: + f.write(AppConfig.schema_json()) + + return schema_name + + +@app.command() +def generate_migrations(migration_name: str): + os.makedirs(MIGRATIONS_DIRECTORY, exist_ok=True) + schema_name = generate_new_schema(SCHEMAS_DIRECTORY) + + migration_id = get_latest_id(MIGRATIONS_DIRECTORY, pattern=MIGRATION_FILENAME_PATTERN) + 1 + file_name = f"migration_{migration_id:03d}_{snake_case(migration_name)}.py" + + template = env.get_template('migrations.py.jinja2') + + with open(MIGRATIONS_DIRECTORY / file_name, "w") as f: + f.write(template.render(migration_name=migration_name, schema_file=schema_name)) + + +@app.command() +def test(): + pass + + +def main(): + app() + + +if __name__ == '__main__': + main()