Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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 <command> -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**_.
Expand Down Expand Up @@ -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`). |

</div>

Expand Down
6 changes: 6 additions & 0 deletions config/hello/sample.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
name: GFW
times: 2

# Labels for cost tracking (label values: lowercase letters, digits, '_' and '-').
labels:
environment: development
21 changes: 14 additions & 7 deletions init_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,21 @@
"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 = (
"CONTRIBUTING.md",
"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."""
Expand Down Expand Up @@ -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:
Expand All @@ -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.

Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
41 changes: 40 additions & 1 deletion requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,39 @@
# 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
# via
# 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)
Expand All @@ -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
20 changes: 20 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/python_app_template/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""A template for python (dockerized) applications."""
1 change: 1 addition & 0 deletions src/python_app_template/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Command-line interface of the application."""
6 changes: 6 additions & 0 deletions src/python_app_template/cli/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
46 changes: 46 additions & 0 deletions src/python_app_template/cli/commands/hello.py
Original file line number Diff line number Diff line change
@@ -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)
36 changes: 29 additions & 7 deletions src/python_app_template/cli/main.py
Original file line number Diff line number Diff line change
@@ -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:])


Expand Down
1 change: 1 addition & 0 deletions src/python_app_template/pipelines/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Pipelines run by the CLI commands: one package per command, with its config and run()."""
1 change: 1 addition & 0 deletions src/python_app_template/pipelines/hello/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Pipeline of the hello command, independent of the CLI."""
34 changes: 34 additions & 0 deletions src/python_app_template/pipelines/hello/config.py
Original file line number Diff line number Diff line change
@@ -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))
Loading
Loading