From 0dcc83087ab07593734bf40a9c1f8c7df426bba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Link?= Date: Fri, 16 Oct 2026 16:19:08 -0300 Subject: [PATCH 1/2] Rename the project in every Python file of the package and the tests init_project.py only renamed a fixed list of files, so any new module that imports the package by name (e.g. cli/main.py importing its commands) kept the template's name and broke after renaming. It now renames every .py file under src// and tests/, except init_project's own tests, and also requirements-test.txt, whose compiled comments name the package. --- init_project.py | 21 ++++++++++++++------- tests/test_init_project.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/init_project.py b/init_project.py index c5f0e11..55e184a 100644 --- a/init_project.py +++ b/init_project.py @@ -19,9 +19,7 @@ "pyproject.toml", "README.md", "requirements.txt", - "tests/test_assets.py", - "tests/test_version.py", - "tests/cli/test_main.py", + "requirements-test.txt", ) PATHS_TO_REMOVE = ( @@ -29,10 +27,13 @@ "docs/contributing", ) -VERSION_FILE_TPL = "{src_dir}/{old_name}/version.py" - SOURCE_DIR = pathlib.Path("src") +TESTS_DIR = pathlib.Path("tests") + +# Its tests use the template's name on purpose. +INIT_PROJECT_TESTS = "test_init_project.py" + class Name: """Encapsulates a project name with methods to convert between dash and underscore styles.""" @@ -126,6 +127,12 @@ def update_project_name( print(f"🎉 Project renamed to: {new_name.with_dashes}.") +def python_files(src_dir: pathlib.Path, tests_dir: pathlib.Path, package: str) -> list[str]: + """Returns the Python files of the package and the tests, which import the package by name.""" + files = sorted((src_dir / package).rglob("*.py")) + sorted(tests_dir.rglob("*.py")) + return [str(f) for f in files if f.name != INIT_PROJECT_TESTS] + + def remove_template_only_paths(paths_to_remove: tuple[str, ...] = PATHS_TO_REMOVE) -> None: """Removes files that only belong to the template, like the shared contributing guide.""" for path in paths_to_remove: @@ -138,6 +145,7 @@ def main( files_to_update: tuple[str, ...] = FILES_TO_UPDATE, src_dir: pathlib.Path = SOURCE_DIR, paths_to_remove: tuple[str, ...] = PATHS_TO_REMOVE, + tests_dir: pathlib.Path = TESTS_DIR, ): """Entry point for the script. @@ -153,8 +161,7 @@ def main( new_name = Name(args[1]) files_to_update = list(files_to_update) - version_file = VERSION_FILE_TPL.format(src_dir=src_dir, old_name=old_name.with_underscores) - files_to_update.append(version_file) + files_to_update.extend(python_files(src_dir, tests_dir, old_name.with_underscores)) update_project_name( old_name=old_name, diff --git a/tests/test_init_project.py b/tests/test_init_project.py index 3b6de33..ce42134 100644 --- a/tests/test_init_project.py +++ b/tests/test_init_project.py @@ -32,6 +32,14 @@ def test_main_renames_project(tmp_path): f"See the [contributing guide]({init_project.CONTRIBUTING_GUIDE_URL}).\n" ) + # Create tests that import the package, and init_project's own tests + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + test_module = tests_dir / "test_module.py" + test_module.write_text("from python_app_template import module\n") + init_project_tests = tests_dir / "test_init_project.py" + init_project_tests.write_text('OLD_NAME = "python-app-template"\n') + # Create the template-only files contributing = tmp_path / "CONTRIBUTING.md" contributing.write_text("# How to Contribute\n") @@ -48,6 +56,7 @@ def test_main_renames_project(tmp_path): files_to_update=files_to_update, src_dir=src_dir, paths_to_remove=(str(contributing), str(contributing_docs)), + tests_dir=tests_dir, ) # Check that file was updated @@ -57,6 +66,10 @@ def test_main_renames_project(tmp_path): assert "Welcome to my-project!" in updated_readme assert init_project.CONTRIBUTING_GUIDE_URL in updated_readme + # Check that tests were updated, except init_project's own tests + assert test_module.read_text() == "from my_project import module\n" + assert init_project_tests.read_text() == 'OLD_NAME = "python-app-template"\n' + # Check that template-only files were removed assert not contributing.exists() assert not contributing_docs.exists() From f03007024e5f932cb88976ee9f82537473e16bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Link?= Date: Fri, 16 Oct 2026 16:19:08 -0300 Subject: [PATCH 2/2] Add an example CLI built on gfw-common, without Beam Replaces the plain sys.argv entry point with gfw-common's CLI, laid out as in pipe-anchorages: - cli/main.py builds the CLI and registers the commands, with the shared --labels option. - cli/commands/hello.py: an example command that only defines its name, description and options, and delegates to its pipeline. - pipelines/hello/: its configuration (HelloConfig, a PipelineConfig) and its logic (run()). - config/hello/sample.yaml: an example configuration file. Command-line arguments take precedence over it, and it over the defaults. - Tests for the command (with run mocked), the configuration and the logic. - README: how to use the CLI and the new modules. gfw-common is installed without extras, so no Beam: the production image doesn't include it. gfw-common doesn't ship a py.typed marker, so mypy sees its classes as Any; subclassing them needs a type: ignore[misc]. --- README.md | 35 +++++++++++++- config/hello/sample.yaml | 6 +++ pyproject.toml | 4 +- requirements-test.txt | 41 ++++++++++++++++- requirements.txt | 20 ++++++++ src/python_app_template/__init__.py | 1 + src/python_app_template/cli/__init__.py | 1 + .../cli/commands/__init__.py | 6 +++ src/python_app_template/cli/commands/hello.py | 46 +++++++++++++++++++ src/python_app_template/cli/main.py | 36 ++++++++++++--- src/python_app_template/pipelines/__init__.py | 1 + .../pipelines/hello/__init__.py | 1 + .../pipelines/hello/config.py | 34 ++++++++++++++ .../pipelines/hello/main.py | 28 +++++++++++ tests/__init__.py | 0 tests/cli/__init__.py | 0 tests/cli/commands/__init__.py | 0 tests/cli/commands/test_hello.py | 33 +++++++++++++ tests/cli/test_main.py | 20 ++++++-- tests/pipelines/__init__.py | 0 tests/pipelines/hello/__init__.py | 0 tests/pipelines/hello/test_config.py | 26 +++++++++++ tests/pipelines/hello/test_main.py | 8 ++++ 23 files changed, 333 insertions(+), 14 deletions(-) create mode 100644 config/hello/sample.yaml create mode 100644 src/python_app_template/cli/commands/__init__.py create mode 100644 src/python_app_template/cli/commands/hello.py create mode 100644 src/python_app_template/pipelines/__init__.py create mode 100644 src/python_app_template/pipelines/hello/__init__.py create mode 100644 src/python_app_template/pipelines/hello/config.py create mode 100644 src/python_app_template/pipelines/hello/main.py create mode 100644 tests/__init__.py create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/commands/__init__.py create mode 100644 tests/cli/commands/test_hello.py create mode 100644 tests/pipelines/__init__.py create mode 100644 tests/pipelines/hello/__init__.py create mode 100644 tests/pipelines/hello/test_config.py create mode 100644 tests/pipelines/hello/test_main.py diff --git a/README.md b/README.md index fd51ac8..b939f8a 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,11 @@ A template for Python applications. [used by pytest-cov]: https://pytest-cov.readthedocs.io/en/latest/config.html [cli/main.py]: src/python_app_template/cli/main.py +[cli/commands/]: src/python_app_template/cli/commands/ +[pipelines/]: src/python_app_template/pipelines/ +[config/]: config/ +[config/hello/sample.yaml]: config/hello/sample.yaml +[gfw-common]: https://github.com/GlobalFishingWatch/gfw-common [.github/]: .github/ @@ -172,6 +177,7 @@ This is a brief summary of all the relevant directories of the repository. | ------------------------------- | ------------------------------------------------------------------------------- | |[.github/] | Configuration for GitHub actions. | |[cloudbuild/] | Terraform config for the Cloud Build triggers. | +|[config/] | Example configuration files for the CLI commands. | |[docs/] | Markdown files with detailed documentation. | |[notebooks/] | All jupyter notebooks go here. | |[src/python_app_template/] | All source code go here. | @@ -213,13 +219,36 @@ This is a brief summary of all the relevant files of the repository. ### Using the CLI -_Write instructions on how to use the CLI of the application here._ +_Write instructions on how to use the CLI of the application here. +The template ships an example `hello` command built on [gfw-common]'s CLI: +replace it with your own commands._ + +Run `python-app-template -h` for the list of commands, +or `python-app-template -h` for a command's own options. For example: + +```shell +python-app-template hello --name GFW --times 2 --labels environment=development +``` + +Every command also accepts a configuration file (`-c/--config-file`). +Command-line arguments take precedence over the configuration file, +and the configuration file over the command's defaults. #### Config file example _**Optional**_. _Provide an example of an input configuration file._ +Example ([config/hello/sample.yaml]): +```yaml +name: GFW +times: 2 + +# Labels for cost tracking (label values: lowercase letters, digits, '_' and '-'). +labels: + environment: development +``` + ## Output schema _**Optional**_. @@ -259,7 +288,9 @@ Example: | Module | Description | | --- | --- | -| [cli/main.py] | Defines the application CLI. | +| [cli/main.py] | Defines the application CLI and registers its commands. | +| [cli/commands/] | One module per command: its name, description and options. Delegates to its pipeline. | +| [pipelines/] | One package per command: its configuration (`config.py`) and its logic (`run()` in `main.py`). | diff --git a/config/hello/sample.yaml b/config/hello/sample.yaml new file mode 100644 index 0000000..1c43c5e --- /dev/null +++ b/config/hello/sample.yaml @@ -0,0 +1,6 @@ +name: GFW +times: 2 + +# Labels for cost tracking (label values: lowercase letters, digits, '_' and '-'). +labels: + environment: development diff --git a/pyproject.toml b/pyproject.toml index 9d3ec3b..cffd14a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,9 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] requires-python = ">= 3.12" -dependencies = [] +dependencies = [ + "gfw-common~=0.20.2", +] [project.urls] Homepage = "https://github.com/GlobalFishingWatch/python-app-template" diff --git a/requirements-test.txt b/requirements-test.txt index 13bc6f2..95a487d 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,8 +2,28 @@ # uv pip compile --group test -c requirements.txt -o requirements-test.txt pyproject.toml coverage==7.16.2 # via pytest-cov +gfw-common==0.20.2 + # via + # -c requirements.txt + # python-app-template (pyproject.toml) iniconfig==2.3.1 # via pytest +jinja2==3.1.6 + # via + # -c requirements.txt + # gfw-common +markdown-it-py==4.2.0 + # via + # -c requirements.txt + # rich +markupsafe==3.0.4 + # via + # -c requirements.txt + # jinja2 +mdurl==0.1.2 + # via + # -c requirements.txt + # markdown-it-py packaging==26.3 # via pytest pluggy==1.7.0 @@ -11,7 +31,10 @@ pluggy==1.7.0 # pytest # pytest-cov pygments==2.21.0 - # via pytest + # via + # -c requirements.txt + # pytest + # rich pytest==9.1.1 # via # python-app-template (pyproject.toml:test) @@ -21,3 +44,19 @@ pytest-cov==7.1.0 # via python-app-template (pyproject.toml:test) pytest-mock==3.16.0 # via python-app-template (pyproject.toml:test) +python-dateutil==2.9.0.post0 + # via + # -c requirements.txt + # gfw-common +pyyaml==6.0.3 + # via + # -c requirements.txt + # gfw-common +rich==14.3.4 + # via + # -c requirements.txt + # gfw-common +six==1.17.0 + # via + # -c requirements.txt + # python-dateutil diff --git a/requirements.txt b/requirements.txt index 0f0c262..75e1905 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,22 @@ # This file was autogenerated by uv via the following command: # uv pip compile -o requirements.txt pyproject.toml +gfw-common==0.20.2 + # via python-app-template (pyproject.toml) +jinja2==3.1.6 + # via gfw-common +markdown-it-py==4.2.0 + # via rich +markupsafe==3.0.4 + # via jinja2 +mdurl==0.1.2 + # via markdown-it-py +pygments==2.21.0 + # via rich +python-dateutil==2.9.0.post0 + # via gfw-common +pyyaml==6.0.3 + # via gfw-common +rich==14.3.4 + # via gfw-common +six==1.17.0 + # via python-dateutil diff --git a/src/python_app_template/__init__.py b/src/python_app_template/__init__.py index e69de29..8e7b794 100644 --- a/src/python_app_template/__init__.py +++ b/src/python_app_template/__init__.py @@ -0,0 +1 @@ +"""A template for python (dockerized) applications.""" diff --git a/src/python_app_template/cli/__init__.py b/src/python_app_template/cli/__init__.py index e69de29..80f44ff 100644 --- a/src/python_app_template/cli/__init__.py +++ b/src/python_app_template/cli/__init__.py @@ -0,0 +1 @@ +"""Command-line interface of the application.""" diff --git a/src/python_app_template/cli/commands/__init__.py b/src/python_app_template/cli/commands/__init__.py new file mode 100644 index 0000000..aad46f9 --- /dev/null +++ b/src/python_app_template/cli/commands/__init__.py @@ -0,0 +1,6 @@ +"""Commands of the CLI. Each one defines its options and delegates to its pipeline package.""" + +from .hello import Hello + + +__all__ = ["Hello"] diff --git a/src/python_app_template/cli/commands/hello.py b/src/python_app_template/cli/commands/hello.py new file mode 100644 index 0000000..eab5283 --- /dev/null +++ b/src/python_app_template/cli/commands/hello.py @@ -0,0 +1,46 @@ +"""CLI command that greets someone. An example of how to define a command.""" + +from types import SimpleNamespace + +from gfw.common.cli import Command, Option +from python_app_template.pipelines.hello.config import HelloConfig +from python_app_template.pipelines.hello.main import run + + +DESCRIPTION = """\ +Greets someone a number of times. + +You can provide a configuration file or command-line arguments. +The latter take precedence, so if you provide both, command-line arguments +will overwrite options in the config file provided. +""" + +HELP_NAME = "Who to greet." +HELP_TIMES = "How many times to greet." + + +# gfw-common doesn't ship a py.typed marker, so mypy sees its classes as Any. +class Hello(Command): # type: ignore[misc] + """Greets someone a number of times.""" + + @property + def name(self) -> str: + """The command's name.""" + return "hello" + + @property + def description(self) -> str: + """The command's description.""" + return DESCRIPTION + + @property + def options(self) -> list[Option]: + """The command's options.""" + return [ + Option("--name", type=str, default="World", help=HELP_NAME), + Option("--times", type=int, default=1, help=HELP_TIMES), + ] + + def run(self, config: SimpleNamespace, **kwargs: object) -> list[str]: + """Runs the command with the resolved configuration.""" + return run(HelloConfig.from_namespace(config), **kwargs) diff --git a/src/python_app_template/cli/main.py b/src/python_app_template/cli/main.py index 4d8be48..bacd0cb 100644 --- a/src/python_app_template/cli/main.py +++ b/src/python_app_template/cli/main.py @@ -1,17 +1,39 @@ +"""Command-line interface of the application, built on gfw-common's CLI.""" + import sys -import logging -from ..version import __version__ +from typing import Any + +from gfw.common.cli import CLI, labels_option +from python_app_template.cli.commands import Hello +from python_app_template.version import __version__ + + +NAME = "python-app-template" +DESCRIPTION = "A template for python (dockerized) applications." +EXAMPLES = ( + "python-app-template hello --name GFW --times 2 --labels environment=development", + "python-app-template hello -c config/hello/sample.yaml", +) -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger(__name__) +def run(args: list[str]) -> Any: + """Builds the CLI and executes the command given in ``args``.""" + cli = CLI( + name=NAME, + description=DESCRIPTION, + subcommands=[Hello], + options=[labels_option()], # Common options for all subcommands. + version=__version__, + examples=EXAMPLES, + ) -def run(args: list): - logger.info("Starting APP (v{})...".format(__version__)) + result, _ = cli.execute(args) + return result -def main(): # Entry point for the python package. +def main() -> None: + """Entry point of the installed package.""" run(sys.argv[1:]) diff --git a/src/python_app_template/pipelines/__init__.py b/src/python_app_template/pipelines/__init__.py new file mode 100644 index 0000000..2ec6c10 --- /dev/null +++ b/src/python_app_template/pipelines/__init__.py @@ -0,0 +1 @@ +"""Pipelines run by the CLI commands: one package per command, with its config and run().""" diff --git a/src/python_app_template/pipelines/hello/__init__.py b/src/python_app_template/pipelines/hello/__init__.py new file mode 100644 index 0000000..e414978 --- /dev/null +++ b/src/python_app_template/pipelines/hello/__init__.py @@ -0,0 +1 @@ +"""Pipeline of the hello command, independent of the CLI.""" diff --git a/src/python_app_template/pipelines/hello/config.py b/src/python_app_template/pipelines/hello/config.py new file mode 100644 index 0000000..f237f58 --- /dev/null +++ b/src/python_app_template/pipelines/hello/config.py @@ -0,0 +1,34 @@ +"""Configuration of the hello command.""" + +from dataclasses import dataclass + +from gfw.common.config import PipelineConfig + + +ERROR_TIMES = "times must be at least 1. Got: {}." + + +# gfw-common doesn't ship a py.typed marker, so mypy sees its classes as Any. +@dataclass(frozen=True, kw_only=True) +class HelloConfig(PipelineConfig): # type: ignore[misc] + """Configuration of the hello command. + + Inherits the settings every pipeline has, like its ``labels``, + and how to create it from the CLI's namespace (``from_namespace``). + + Raises: + ValueError: + If ``times`` is lower than 1. + """ + + name: str = "World" + """Who to greet.""" + + times: int = 1 + """How many times to greet.""" + + def __post_init__(self) -> None: + """Validates the configuration.""" + super().__post_init__() + if self.times < 1: + raise ValueError(ERROR_TIMES.format(self.times)) diff --git a/src/python_app_template/pipelines/hello/main.py b/src/python_app_template/pipelines/hello/main.py new file mode 100644 index 0000000..06e9a3b --- /dev/null +++ b/src/python_app_template/pipelines/hello/main.py @@ -0,0 +1,28 @@ +"""Runs the hello command.""" + +import logging + +from python_app_template.pipelines.hello.config import HelloConfig + + +logger = logging.getLogger(__name__) + + +def run(config: HelloConfig, **kwargs: object) -> list[str]: + """Greets ``config.name``, ``config.times`` times. + + Args: + config: + The command's configuration. + + **kwargs: + Extra arguments the CLI passes through. Unused here. + + Returns: + The greetings. + """ + greetings = [f"Hello, {config.name}!" for _ in range(config.times)] + for greeting in greetings: + logger.info(greeting) + + return greetings diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/commands/__init__.py b/tests/cli/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/commands/test_hello.py b/tests/cli/commands/test_hello.py new file mode 100644 index 0000000..7e20f20 --- /dev/null +++ b/tests/cli/commands/test_hello.py @@ -0,0 +1,33 @@ +import pytest + +from python_app_template.cli import main +from python_app_template.pipelines.hello.config import HelloConfig + + +LABELS = {"environment": "test"} + + +@pytest.fixture +def run(mocker): + return mocker.patch("python_app_template.cli.commands.hello.run") + + +def test_defaults(run): + main.run(["hello", "--labels", "environment=test"]) + + run.assert_called_once_with(HelloConfig(labels=LABELS, name="World", times=1)) + + +def test_command_line_options(run): + main.run(["hello", "--name", "GFW", "--times", "3", "--labels", "environment=test"]) + + run.assert_called_once_with(HelloConfig(labels=LABELS, name="GFW", times=3)) + + +def test_command_line_overrides_config_file(run, tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text("name: GFW\ntimes: 2\nlabels:\n environment: test\n") + + main.run(["hello", "-c", str(config_file), "--times", "5"]) + + run.assert_called_once_with(HelloConfig(labels=LABELS, name="GFW", times=5)) diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 706d917..e98d3cc 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -1,5 +1,19 @@ -from python_app_template.cli.main import main +import sys +from python_app_template.cli import main -def test_main(): - main() + +LABELS = ["--labels", "environment=test"] + + +def test_run_executes_command(): + assert main.run(["hello", "--name", "GFW", *LABELS]) == ["Hello, GFW!"] + + +def test_main_reads_sys_argv(monkeypatch, mocker): + monkeypatch.setattr(sys, "argv", ["python-app-template", "hello", *LABELS]) + run = mocker.patch("python_app_template.cli.commands.hello.run") + + main.main() + + run.assert_called_once() diff --git a/tests/pipelines/__init__.py b/tests/pipelines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/pipelines/hello/__init__.py b/tests/pipelines/hello/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/pipelines/hello/test_config.py b/tests/pipelines/hello/test_config.py new file mode 100644 index 0000000..7bbc6de --- /dev/null +++ b/tests/pipelines/hello/test_config.py @@ -0,0 +1,26 @@ +from types import SimpleNamespace + +import pytest + +from gfw.common.config import PipelineConfigError + +from python_app_template.pipelines.hello.config import HelloConfig + + +LABELS = {"environment": "test"} + + +def test_from_namespace(): + config = HelloConfig.from_namespace(SimpleNamespace(labels=LABELS, name="GFW", times=2)) + + assert config == HelloConfig(labels=LABELS, name="GFW", times=2) + + +def test_times_lower_than_one_raises(): + with pytest.raises(ValueError, match="times must be at least 1"): + HelloConfig(labels=LABELS, times=0) + + +def test_empty_labels_raise(): + with pytest.raises(PipelineConfigError): + HelloConfig(labels={}) diff --git a/tests/pipelines/hello/test_main.py b/tests/pipelines/hello/test_main.py new file mode 100644 index 0000000..1012ccc --- /dev/null +++ b/tests/pipelines/hello/test_main.py @@ -0,0 +1,8 @@ +from python_app_template.pipelines.hello.config import HelloConfig +from python_app_template.pipelines.hello.main import run + + +def test_run_greets_times(): + config = HelloConfig(labels={"environment": "test"}, name="GFW", times=2) + + assert run(config) == ["Hello, GFW!", "Hello, GFW!"]