commit dc57ecd228095979e47df220a36aaaf7baaee745 Author: Marc Cataford Date: Fri Apr 12 01:09:56 2024 -0400 feat: scaffolding + init and print configuration diff --git a/.forgejo/workflows/main.yml b/.forgejo/workflows/main.yml new file mode 100644 index 0000000..dc97b5b --- /dev/null +++ b/.forgejo/workflows/main.yml @@ -0,0 +1,31 @@ +on: [push] + +jobs: + static-analysis: + name: Static Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v2 + with: + python-version: 3.12 + - run: | + pip install -r ./requirements.txt + pip install -r ./requirements_sast.txt + - name: Formatting + run: python -m black . + - name: Linting + run: python -m pylint . + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v2 + with: + python-version: 3.12 + - run: | + pip install -r ./requirements.txt + pip install -r ./requirements_test.txt + - name: Test suites + run: python -m pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c06d776 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*egg-info* +*venv* +*.pyc + diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7588f27 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "spud" +version = "0.0.0" +requires-python = "~=3.12" +dependencies = [ + "click~=8.0", + "httpx", + "pydantic~=2.0", + "fastapi~=0.110.0", + "uvicorn[standard]~=0.29", +] + +[project.optional-dependencies] +sast = [ + "black", + "pylint" +] +test = [ + "pytest" +] + +[project.scripts] +spud = "spud.cli:cli" + +[tool.setuptools] +packages = ["spud"] + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..340a80f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,60 @@ +annotated-types==0.6.0 + # via pydantic +anyio==4.3.0 + # via + # httpx + # starlette + # watchfiles +certifi==2024.2.2 + # via + # httpcore + # httpx +click==8.1.7 + # via + # spud (pyproject.toml) + # uvicorn +fastapi==0.110.1 + # via spud (pyproject.toml) +h11==0.14.0 + # via + # httpcore + # uvicorn +httpcore==1.0.5 + # via httpx +httptools==0.6.1 + # via uvicorn +httpx==0.27.0 + # via spud (pyproject.toml) +idna==3.7 + # via + # anyio + # httpx +pydantic==2.6.4 + # via + # fastapi + # spud (pyproject.toml) +pydantic-core==2.16.3 + # via pydantic +python-dotenv==1.0.1 + # via uvicorn +pyyaml==6.0.1 + # via uvicorn +sniffio==1.3.1 + # via + # anyio + # httpx +starlette==0.37.2 + # via fastapi +typing-extensions==4.11.0 + # via + # fastapi + # pydantic + # pydantic-core +uvicorn[standard]==0.29.0 + # via spud (pyproject.toml) +uvloop==0.19.0 + # via uvicorn +watchfiles==0.21.0 + # via uvicorn +websockets==12.0 + # via uvicorn diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..4d00967 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,127 @@ +annotated-types==0.6.0 + # via + # -c requirements.txt + # pydantic +anyio==4.3.0 + # via + # -c requirements.txt + # httpx + # starlette + # watchfiles +astroid==3.1.0 + # via pylint +black==24.3.0 + # via spud (pyproject.toml) +certifi==2024.2.2 + # via + # -c requirements.txt + # httpcore + # httpx +click==8.1.7 + # via + # -c requirements.txt + # black + # spud (pyproject.toml) + # uvicorn +dill==0.3.8 + # via pylint +fastapi==0.110.1 + # via + # -c requirements.txt + # spud (pyproject.toml) +h11==0.14.0 + # via + # -c requirements.txt + # httpcore + # uvicorn +httpcore==1.0.5 + # via + # -c requirements.txt + # httpx +httptools==0.6.1 + # via + # -c requirements.txt + # uvicorn +httpx==0.27.0 + # via + # -c requirements.txt + # spud (pyproject.toml) +idna==3.7 + # via + # -c requirements.txt + # anyio + # httpx +iniconfig==2.0.0 + # via pytest +isort==5.13.2 + # via pylint +mccabe==0.7.0 + # via pylint +mypy-extensions==1.0.0 + # via black +packaging==24.0 + # via + # black + # pytest +pathspec==0.12.1 + # via black +platformdirs==4.2.0 + # via + # black + # pylint +pluggy==1.4.0 + # via pytest +pydantic==2.6.4 + # via + # -c requirements.txt + # fastapi + # spud (pyproject.toml) +pydantic-core==2.16.3 + # via + # -c requirements.txt + # pydantic +pylint==3.1.0 + # via spud (pyproject.toml) +pytest==8.1.1 + # via spud (pyproject.toml) +python-dotenv==1.0.1 + # via + # -c requirements.txt + # uvicorn +pyyaml==6.0.1 + # via + # -c requirements.txt + # uvicorn +sniffio==1.3.1 + # via + # -c requirements.txt + # anyio + # httpx +starlette==0.37.2 + # via + # -c requirements.txt + # fastapi +tomlkit==0.12.4 + # via pylint +typing-extensions==4.11.0 + # via + # -c requirements.txt + # fastapi + # pydantic + # pydantic-core +uvicorn[standard]==0.29.0 + # via + # -c requirements.txt + # spud (pyproject.toml) +uvloop==0.19.0 + # via + # -c requirements.txt + # uvicorn +watchfiles==0.21.0 + # via + # -c requirements.txt + # uvicorn +websockets==12.0 + # via + # -c requirements.txt + # uvicorn diff --git a/requirements_sast.txt b/requirements_sast.txt new file mode 100644 index 0000000..c2f013d --- /dev/null +++ b/requirements_sast.txt @@ -0,0 +1,119 @@ +annotated-types==0.6.0 + # via + # -c requirements.txt + # pydantic +anyio==4.3.0 + # via + # -c requirements.txt + # httpx + # starlette + # watchfiles +astroid==3.1.0 + # via pylint +black==24.3.0 + # via spud (pyproject.toml) +certifi==2024.2.2 + # via + # -c requirements.txt + # httpcore + # httpx +click==8.1.7 + # via + # -c requirements.txt + # black + # spud (pyproject.toml) + # uvicorn +dill==0.3.8 + # via pylint +fastapi==0.110.1 + # via + # -c requirements.txt + # spud (pyproject.toml) +h11==0.14.0 + # via + # -c requirements.txt + # httpcore + # uvicorn +httpcore==1.0.5 + # via + # -c requirements.txt + # httpx +httptools==0.6.1 + # via + # -c requirements.txt + # uvicorn +httpx==0.27.0 + # via + # -c requirements.txt + # spud (pyproject.toml) +idna==3.7 + # via + # -c requirements.txt + # anyio + # httpx +isort==5.13.2 + # via pylint +mccabe==0.7.0 + # via pylint +mypy-extensions==1.0.0 + # via black +packaging==24.0 + # via black +pathspec==0.12.1 + # via black +platformdirs==4.2.0 + # via + # black + # pylint +pydantic==2.6.4 + # via + # -c requirements.txt + # fastapi + # spud (pyproject.toml) +pydantic-core==2.16.3 + # via + # -c requirements.txt + # pydantic +pylint==3.1.0 + # via spud (pyproject.toml) +python-dotenv==1.0.1 + # via + # -c requirements.txt + # uvicorn +pyyaml==6.0.1 + # via + # -c requirements.txt + # uvicorn +sniffio==1.3.1 + # via + # -c requirements.txt + # anyio + # httpx +starlette==0.37.2 + # via + # -c requirements.txt + # fastapi +tomlkit==0.12.4 + # via pylint +typing-extensions==4.11.0 + # via + # -c requirements.txt + # fastapi + # pydantic + # pydantic-core +uvicorn[standard]==0.29.0 + # via + # -c requirements.txt + # spud (pyproject.toml) +uvloop==0.19.0 + # via + # -c requirements.txt + # uvicorn +watchfiles==0.21.0 + # via + # -c requirements.txt + # uvicorn +websockets==12.0 + # via + # -c requirements.txt + # uvicorn diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..c2f013d --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,119 @@ +annotated-types==0.6.0 + # via + # -c requirements.txt + # pydantic +anyio==4.3.0 + # via + # -c requirements.txt + # httpx + # starlette + # watchfiles +astroid==3.1.0 + # via pylint +black==24.3.0 + # via spud (pyproject.toml) +certifi==2024.2.2 + # via + # -c requirements.txt + # httpcore + # httpx +click==8.1.7 + # via + # -c requirements.txt + # black + # spud (pyproject.toml) + # uvicorn +dill==0.3.8 + # via pylint +fastapi==0.110.1 + # via + # -c requirements.txt + # spud (pyproject.toml) +h11==0.14.0 + # via + # -c requirements.txt + # httpcore + # uvicorn +httpcore==1.0.5 + # via + # -c requirements.txt + # httpx +httptools==0.6.1 + # via + # -c requirements.txt + # uvicorn +httpx==0.27.0 + # via + # -c requirements.txt + # spud (pyproject.toml) +idna==3.7 + # via + # -c requirements.txt + # anyio + # httpx +isort==5.13.2 + # via pylint +mccabe==0.7.0 + # via pylint +mypy-extensions==1.0.0 + # via black +packaging==24.0 + # via black +pathspec==0.12.1 + # via black +platformdirs==4.2.0 + # via + # black + # pylint +pydantic==2.6.4 + # via + # -c requirements.txt + # fastapi + # spud (pyproject.toml) +pydantic-core==2.16.3 + # via + # -c requirements.txt + # pydantic +pylint==3.1.0 + # via spud (pyproject.toml) +python-dotenv==1.0.1 + # via + # -c requirements.txt + # uvicorn +pyyaml==6.0.1 + # via + # -c requirements.txt + # uvicorn +sniffio==1.3.1 + # via + # -c requirements.txt + # anyio + # httpx +starlette==0.37.2 + # via + # -c requirements.txt + # fastapi +tomlkit==0.12.4 + # via pylint +typing-extensions==4.11.0 + # via + # -c requirements.txt + # fastapi + # pydantic + # pydantic-core +uvicorn[standard]==0.29.0 + # via + # -c requirements.txt + # spud (pyproject.toml) +uvloop==0.19.0 + # via + # -c requirements.txt + # uvicorn +watchfiles==0.21.0 + # via + # -c requirements.txt + # uvicorn +websockets==12.0 + # via + # -c requirements.txt + # uvicorn diff --git a/script/bootstrap.sh b/script/bootstrap.sh new file mode 100755 index 0000000..6990b8d --- /dev/null +++ b/script/bootstrap.sh @@ -0,0 +1,12 @@ +#!/usr/bin/bash + +VENV=spud.venv + +python -m venv --upgrade "$VENV" + +. "$VENV/bin/activate" + +pip install -U pip pip-tools +pip-sync requirements.txt requirements_dev.txt + +pip install -e . diff --git a/script/lock-deps.sh b/script/lock-deps.sh new file mode 100755 index 0000000..51fff6d --- /dev/null +++ b/script/lock-deps.sh @@ -0,0 +1,33 @@ +#!/bin/bash + + +echo "Generating requirements.txt..." + +python -m piptools compile \ + -o requirements.txt \ + pyproject.toml \ + --no-header + +echo "Generating requirements_dev.txt..." + +python -m piptools compile \ + -o requirements_dev.txt \ + --no-header \ + --extra sast \ + --extra test \ + --constraint requirements.txt \ + pyproject.toml + +python -m piptools compile \ + -o requirements_sast.txt \ + --no-header \ + --extra sast \ + --constraint requirements.txt \ + pyproject.toml + +python -m piptools compile \ + -o requirements_test.txt \ + --no-header \ + --extra sast \ + --constraint requirements.txt \ + pyproject.toml diff --git a/spud/base.py b/spud/base.py new file mode 100644 index 0000000..bd95cdc --- /dev/null +++ b/spud/base.py @@ -0,0 +1,9 @@ +""" +Models and datastructures +""" + +import pydantic + + +class Configuration(pydantic.BaseModel): + """Command line application configuration options""" diff --git a/spud/cli.py b/spud/cli.py new file mode 100644 index 0000000..02f5915 --- /dev/null +++ b/spud/cli.py @@ -0,0 +1,71 @@ +""" +Command-line application entry-point. + +Logic for the command-line spud tooling. +""" +import pathlib +import json + +import click + +from spud.base import Configuration + +DEFAULT_CONFIGURATION_PATH = "~/.config/spud/config.json" + + +@click.group() +@click.option( + "--config", + type=str, + default=None, + help="Configuration path", +) +@click.pass_context +def cli(context, config): + """CLI root""" + context.ensure_object(dict) + config = config if config is not None else DEFAULT_CONFIGURATION_PATH + context.obj["config_path"] = pathlib.Path(config).expanduser() + + +@click.command() +@click.pass_context +def init(context): + """Generates a default configuration and writes it to disk.""" + config_path = context.obj["config_path"] + + config_path.parent.mkdir(parents=True, exist_ok=True) + + if config_path.exists(): + raise RuntimeError( + f"File already exists ({str(config_path)}), cannot initialize." + ) + + default_configuration = Configuration() + + with open(config_path, "w", encoding="utf8") as config_file: + config_file.write(default_configuration.model_dump_json(indent=2)) + + +@click.command() +@click.pass_context +def print_config(context): + """Prints the current configuration.""" + config_path = context.obj["config_path"] + + if not config_path.exists(): + raise RuntimeError(f"Configuration file not found at {str(config_path)}.") + + with open(config_path, "r", encoding="utf8") as config_file: + config_json = json.loads(config_file.read()) + + config = Configuration(**config_json) + + click.echo(config.model_dump_json(indent=2)) + + +cli.add_command(init) +cli.add_command(print_config) + +if __name__ == "__main__": + cli(None, None) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b10c779 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +import pytest +import click.testing +import importlib + +import spud.cli + +@pytest.fixture(autouse=True) +def mock_default_config_path(monkeypatch, tmpdir): + monkeypatch.setattr(spud.cli, "DEFAULT_CONFIGURATION_PATH", tmpdir / ".config" / "spud" / "config.json") + + +@pytest.fixture(name="invoke_cli") +def invoke_cli_fixture(): + def _call_cli(args: list[str]): + runner = click.testing.CliRunner() + return runner.invoke(spud.cli.cli, args) + + return _call_cli diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..4b4044d --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,40 @@ +import pytest +import click + +import pathlib + +def test_init_raises_if_config_file_exists_default_path(invoke_cli, tmpdir, monkeypatch): + expected_default_path = tmpdir / ".config" / "spud" / "config.json" + + pathlib.Path(expected_default_path).parent.mkdir(parents=True) + expected_default_path.write("{}") + + result = invoke_cli(["init"]) + + assert result.exit_code == 1 + assert str(result.exception) == f"File already exists ({str(expected_default_path)}), cannot initialize." + +def test_init_raises_if_config_file_exists_custom_path(invoke_cli, tmpdir, monkeypatch): + expected_default_path = tmpdir / "config.json" + + expected_default_path.write("{}") + + result = invoke_cli(["--config", str(expected_default_path), "init"]) + + assert result.exit_code == 1 + assert str(result.exception) == f"File already exists ({str(expected_default_path)}), cannot initialize." + +def test_print_config_raises_if_no_config_file_default_path(invoke_cli, tmpdir, monkeypatch): + expected_default_path = tmpdir / ".config" / "spud" / "config.json" + result = invoke_cli(["print-config"]) + + assert result.exit_code == 1 + assert str(result.exception) == f"Configuration file not found at {str(expected_default_path)}." + +def test_print_config_raises_if_no_config_file_custom_path(invoke_cli, tmpdir, monkeypatch): + config_file = tmpdir / "config.json" + + result = invoke_cli(["--config", str(config_file), "print-config"]) + + assert result.exit_code == 1 + assert str(result.exception) == f"Configuration file not found at {str(config_file)}."