diff --git a/docs/requirements.txt b/docs/requirements.txt index ac409c8..3721ed8 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,7 @@ # # This file is autogenerated by hatch-pip-compile with Python 3.12 # -# [constraints] requirements.txt (SHA256: 46a5e85c2e204a780e967f13eea604515f17aaf08e510c0a08060d9e93c60039) +# [constraints] requirements.txt (SHA256: d6147fe36a113178ba35ce440afb64c9866486184a83096746369cd12e2fad2a) # # - sphinx # - sphinx-autobuild @@ -20,6 +20,7 @@ # - black # - cappa # - cookiecutter +# - cruft # - djlint # - honcho # - httpx @@ -37,6 +38,8 @@ anyio==4.4.0 # httpx # starlette # watchfiles +appnope==0.1.4 + # via ipykernel arrow==1.3.0 # via # -c requirements.txt @@ -90,7 +93,9 @@ click==8.1.7 # -c requirements.txt # black # cookiecutter + # cruft # djlint + # typer # uvicorn colorama==0.4.6 # via @@ -105,6 +110,11 @@ cookiecutter==2.6.0 # via # -c requirements.txt # hatch.envs.docs + # cruft +cruft==2.15.0 + # via + # -c requirements.txt + # hatch.envs.docs cssbeautifier==1.15.1 # via # -c requirements.txt @@ -139,6 +149,14 @@ executing==2.1.0 # stack-data fastjsonschema==2.20.0 # via nbformat +gitdb==4.0.11 + # via + # -c requirements.txt + # gitpython +gitpython==3.1.43 + # via + # -c requirements.txt + # cruft h11==0.14.0 # via # -c requirements.txt @@ -364,12 +382,17 @@ rich==13.8.0 # hatch.envs.docs # cappa # cookiecutter + # typer rpds-py==0.20.0 # via # jsonschema # referencing setuptools==74.1.1 # via sphinx-togglebutton +shellingham==1.5.4 + # via + # -c requirements.txt + # typer shibuya==2024.8.30 # via hatch.envs.docs six==1.16.0 @@ -380,6 +403,10 @@ six==1.16.0 # cssbeautifier # jsbeautifier # python-dateutil +smmap==5.0.1 + # via + # -c requirements.txt + # gitdb sniffio==1.3.1 # via # -c requirements.txt @@ -466,6 +493,10 @@ traitlets==5.14.3 # nbclient # nbconvert # nbformat +typer==0.12.5 + # via + # -c requirements.txt + # cruft types-python-dateutil==2.9.0.20240821 # via # -c requirements.txt @@ -474,6 +505,7 @@ typing-extensions==4.12.2 # via # -c requirements.txt # cappa + # typer # typing-inspect typing-inspect==0.9.0 # via diff --git a/docs/the_cli/start_project/index.rst b/docs/the_cli/start_project/index.rst index 14ee0c4..fe76911 100644 --- a/docs/the_cli/start_project/index.rst +++ b/docs/the_cli/start_project/index.rst @@ -32,6 +32,18 @@ can still bring you value. configuration. If you haven't set it yet, `see this page `_. +.. admonition:: Experimental + :class: important + + You can update your project to stay current with the latest changes in the project starter. Please note that this is an experimental feature. Ensure you commit your + changes before running the command. If there are conflicts, `.rej` files will be generated, which you will need to review and manually update the corresponding files + if necessary. + + .. code-block:: shell + + falco update-project + + The subsequent sections will delve deeper into the folder structure, package choices, and provide guidance on deploying your project. Alternative starters diff --git a/justfile b/justfile index 9840a71..a070467 100644 --- a/justfile +++ b/justfile @@ -4,6 +4,9 @@ default := "blueprints/falco_tailwind" _default: @just --list +@install: + hatch run python --version + # Install dependencies @bootstrap: hatch env create diff --git a/pyproject.toml b/pyproject.toml index 890425e..fb703a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "black", "cappa", "cookiecutter", + "cruft", "djlint", "honcho", "httpx", diff --git a/requirements.txt b/requirements.txt index e796961..b4d4d42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,7 @@ # - black # - cappa # - cookiecutter +# - cruft # - djlint # - honcho # - httpx @@ -66,16 +67,21 @@ click==8.1.7 # black # bump-my-version # cookiecutter + # cruft # djlint # rich-click + # typer colorama==0.4.6 # via djlint cookiecutter==2.6.0 # via # hatch.envs.default + # cruft # pytest-cookies coverage==7.6.1 # via hatch.envs.default +cruft==2.15.0 + # via hatch.envs.default cssbeautifier==1.15.1 # via djlint decorator==5.1.1 @@ -106,6 +112,10 @@ executing==2.1.0 # via stack-data filelock==3.15.4 # via virtualenv +gitdb==4.0.11 + # via gitpython +gitpython==3.1.43 + # via cruft h11==0.14.0 # via httpcore honcho==1.1.0 @@ -236,14 +246,19 @@ rich==13.8.0 # cappa # cookiecutter # rich-click + # typer rich-click==1.8.3 # via bump-my-version +shellingham==1.5.4 + # via typer six==1.16.0 # via # asttokens # cssbeautifier # jsbeautifier # python-dateutil +smmap==5.0.1 + # via gitdb sniffio==1.3.1 # via # anyio @@ -264,6 +279,8 @@ traitlets==5.14.3 # via # ipython # matplotlib-inline +typer==0.12.5 + # via cruft types-python-dateutil==2.9.0.20240821 # via arrow typing-extensions==4.12.2 @@ -272,6 +289,7 @@ typing-extensions==4.12.2 # pydantic # pydantic-core # rich-click + # typer # typing-inspect typing-inspect==0.9.0 # via cappa diff --git a/src/falco/__main__.py b/src/falco/__main__.py index 81a5441..b93b9f6 100644 --- a/src/falco/__main__.py +++ b/src/falco/__main__.py @@ -8,6 +8,7 @@ from falco.commands import StartApp from falco.commands import StartProject from falco.commands import SyncDotenv +from falco.commands import UpdateProject from falco.commands import Work @@ -17,6 +18,7 @@ class Falco: subcommand: cappa.Subcommands[ StartProject + | UpdateProject | StartApp | ModelCRUD | InstallCrudUtils diff --git a/src/falco/commands/__init__.py b/src/falco/commands/__init__.py index cb7c971..53bb8fb 100644 --- a/src/falco/commands/__init__.py +++ b/src/falco/commands/__init__.py @@ -7,4 +7,5 @@ from .start_app import StartApp # noqa from .start_project import StartProject # noqa from .sync_dotenv import SyncDotenv # noqa +from .update_project import UpdateProject # noqa from .work import Work # noqa diff --git a/src/falco/commands/update_project.py b/src/falco/commands/update_project.py new file mode 100644 index 0000000..6ca5fa1 --- /dev/null +++ b/src/falco/commands/update_project.py @@ -0,0 +1,245 @@ +import json +import secrets +from contextlib import contextmanager +from pathlib import Path +from typing import Annotated +from typing import Any +from typing import Dict +from typing import Optional +from typing import Set + +import cappa +import tomlkit +import typer +from cruft import diff as cruft_diff +from cruft._commands import utils +from cruft._commands.update import _apply_project_updates +from cruft._commands.utils.iohelper import AltTemporaryDirectory +from falco import checks +from falco.config import FalcoConfig +from falco.config import read_falco_config +from falco.utils import get_project_name +from falco.utils import RICH_INFO_MARKER +from falco.utils import RICH_SUCCESS_MARKER +from rich import print as rich_print + + +@contextmanager +def cruft_file(cruft_state: dict): + file_path = Path(".cruft.json") + try: + file_path.write_text(json.dumps(cruft_state)) + yield + finally: + file_path.unlink() + + +def cruft_state_from( + config: FalcoConfig, project_name: str, author_name: str, author_email: str +) -> dict: + return { + "template": config["blueprint"], + "commit": config["revision"], + "skip": config["skip"], + "checkout": None, + "context": { + "cookiecutter": { + "project_name": project_name, + "author_name": author_name, + "author_email": author_email, + "secret_key": secrets.token_hex(24), + "_template": config["blueprint"], + } + }, + "directory": None, + } + + +@cappa.command(help="Update your project with changes from falco.") +class UpdateProject: + diff: Annotated[ + bool, + cappa.Arg( + default=False, short="-d", long="--diff", help="Show diff of changes." + ), + ] + + def __call__( + self, project_name: Annotated[str, cappa.Dep(get_project_name)] + ) -> None: + # if is_new_falco_cli_available(fail_on_error=True): + # raise cappa.Exit( + # "You need have the latest version of falco-cli to update.", code=1 + # ) + + checks.clean_git_repo() + + pyproject_path = Path("pyproject.toml") + try: + pyproject = tomlkit.parse(pyproject_path.read_text()) + except FileNotFoundError as e: + raise cappa.Exit( + "Could not find a pyproject.toml file in the current directory.", code=1 + ) from e + + cruft_state = cruft_state_from( + config=pyproject["tool"]["falco"], + project_name=project_name, + author_name=pyproject["project"]["authors"][0]["name"], + author_email=pyproject["project"]["authors"][0]["email"], + ) + + if self.diff: + with cruft_file(cruft_state): + cruft_diff() + raise cappa.Exit(code=0) + + with cruft_file(cruft_state): + last_commit = cruft_update(allow_untracked_files=True) + if last_commit is None: + rich_print( + f"{RICH_INFO_MARKER} Nothing to do, project is already up to date!" + ) + raise cappa.Exit(code=0) + pyproject["tool"]["falco"]["revision"] = last_commit + pyproject_path.write_text(tomlkit.dumps(pyproject)) + rich_print( + f"{RICH_SUCCESS_MARKER} Great! Your project has been updated to the latest version!" + ) + + +def cruft_update( + project_dir: Path = Path("."), + cookiecutter_input: bool = False, + refresh_private_variables: bool = False, + skip_apply_ask: bool = True, + skip_update: bool = False, + checkout: Optional[str] = None, + strict: bool = True, + allow_untracked_files: bool = False, + extra_context: Optional[Dict[str, Any]] = None, + extra_context_file: Optional[Path] = None, +) -> bool: + """Update specified project's cruft to the latest and greatest release.""" + cruft_file = utils.cruft.get_cruft_file(project_dir) + + if extra_context_file: + if extra_context_file.samefile(cruft_file): + typer.secho( + f"The file path given to --variables-to-update-file cannot be the same as the" + f" project's cruft file ({cruft_file}), as the update process needs" + f" to know the old/original values of variables as well. Please specify a" + f" different path, and the project's cruft file will be updated as" + f" part of the process.", + fg=typer.colors.RED, + ) + return False + + extra_context_from_cli = extra_context + with open(extra_context_file, "r") as extra_context_fp: + extra_context = json.load(extra_context_fp) or {} + extra_context = extra_context.get("context") or {} + extra_context = extra_context.get("cookiecutter") or {} + if extra_context_from_cli: + extra_context.update(extra_context_from_cli) + + # If the project dir is a git repository, we ensure + # that the user has a clean working directory before proceeding. + # if not _is_project_repo_clean(project_dir, allow_untracked_files): + # typer.secho( + # "Cruft cannot apply updates on an unclean git project." + # " Please make sure your git working tree is clean before proceeding.", + # fg=typer.colors.RED, + # ) + # return False + + cruft_state = json.loads(cruft_file.read_text()) + + directory = cruft_state.get("directory", "") + if directory: + directory = str(Path("repo") / directory) + else: + directory = "repo" + + with AltTemporaryDirectory(directory) as tmpdir_: + # Initial setup + tmpdir = Path(tmpdir_) + repo_dir = tmpdir / "repo" + current_template_dir = tmpdir / "current_template" + new_template_dir = tmpdir / "new_template" + deleted_paths: Set[Path] = set() + # Clone the template + with utils.cookiecutter.get_cookiecutter_repo( + cruft_state["template"], repo_dir, checkout + ) as repo: + last_commit = repo.head.object.hexsha + + # Bail early if the repo is already up to date and no inputs are asked + if not ( + extra_context or cookiecutter_input or refresh_private_variables + ) and utils.cruft.is_project_updated( + repo, cruft_state["commit"], last_commit, strict + ): + typer.secho( + "Nothing to do, project's cruft is already up to date!", + fg=typer.colors.GREEN, + ) + return True + + # Generate clean outputs via the cookiecutter + # from the current cruft state commit of the cookiecutter and the updated + # cookiecutter. + # For the current cruft state, we do not try to update the cookiecutter_input + # because we want to keep the current context input intact. + _ = utils.generate.cookiecutter_template( + output_dir=current_template_dir, + repo=repo, + cruft_state=cruft_state, + project_dir=project_dir, + checkout=cruft_state["commit"], + deleted_paths=deleted_paths, + update_deleted_paths=True, + ) + # Remove private variables from cruft_state to refresh their values + # from the cookiecutter template config + # if refresh_private_variables: + # _clean_cookiecutter_private_variables(cruft_state) + + # Add new input data from command line to cookiecutter context + if extra_context: + extra = cruft_state["context"]["cookiecutter"] + for k, v in extra_context.items(): + extra[k] = v + + new_context = utils.generate.cookiecutter_template( + output_dir=new_template_dir, + repo=repo, + cruft_state=cruft_state, + project_dir=project_dir, + cookiecutter_input=cookiecutter_input, + checkout=last_commit, + deleted_paths=deleted_paths, + ) + + # Given the two versions of the cookiecutter outputs based + # on the current project's context we calculate the diff and + # apply the updates to the current project. + if _apply_project_updates( + current_template_dir, + new_template_dir, + project_dir, + skip_update, + skip_apply_ask, + allow_untracked_files, + ): + # Update the cruft state and dump the new state + # to the cruft file + cruft_state["commit"] = last_commit + cruft_state["checkout"] = checkout + cruft_state["context"] = new_context + cruft_file.write_text(utils.cruft.json_dumps(cruft_state)) + # typer.secho( + # "Good work! Project's cruft has been updated and is as clean as possible!", + # fg=typer.colors.GREEN, + # ) + return last_commit