From 2ab827abe81266fb5c69ed1db10fa5c1f1b9551c Mon Sep 17 00:00:00 2001 From: Witold Wolski Date: Mon, 19 Oct 2026 17:48:32 +0200 Subject: [PATCH] feat: example CLI uses cyclopts, logging uses loguru Replace the argparse example with a cyclopts app and show the logging convention: the library emits loguru records, the CLI configures the sink (--verbose for debug). Invalid names now log an error and exit 1. --- AGENTS.md | 2 + CHANGELOG.md | 2 + README.md | 2 +- pyproject.toml | 7 ++- src/fgcz_reference_project/cli.py | 49 ++++++++++++------ src/fgcz_reference_project/naming.py | 3 ++ tests/test_cli.py | 42 ++++++++------- uv.lock | 76 ++++++++++++++++++++++++++++ 8 files changed, 148 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 20d30f6..646712a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,8 @@ instructions override repository files. - Keep `src/fgcz_reference_project/__init__.py` empty; import from concrete modules. - Pyright strict mode and 100% line/branch coverage are blocking. +- CLIs use `cyclopts`; logging uses `loguru`. Library modules only emit log + records; the CLI entry point configures sinks and levels. - Add domain-specific checks as hooks, then invoke the same hooks from CI. - Record user-visible changes under `Unreleased` in `CHANGELOG.md`. - `.ci/check-tests-structure/` is vendored, MIT-licensed, and keeps its own diff --git a/CHANGELOG.md b/CHANGELOG.md index d0bbe6c..1dbbef7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- The example CLI uses `cyclopts` and logs with `loguru` (configured in the CLI, + emitted by the library), replacing `argparse`; `--verbose` shows debug logs. - Documented the reference project's intent and complete quality-gate stack. - Enabled blocking Ruff complexity and function-design checks. - Switched the build backend from hatchling to `uv_build`. diff --git a/README.md b/README.md index bdc82ff..0fdd021 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ structure and quality gates. | Area | Included | |---|---| | Packaging | `src/` layout, `pyproject.toml`, uv build backend, uv lockfile, typed-package marker, console entry point | -| Example | One typed library function and a small `argparse` CLI | +| Example | One typed library function and a small `cyclopts` CLI with `loguru` logging | | Tests | Unit and CLI error-path tests with 100% line and branch coverage | | Documentation | MkDocs Material site built with `--strict` and deployable to GitHub Pages | | Agent guidance | Verified root and scoped `AGENTS.md` files with Claude/Gemini symlinks | diff --git a/pyproject.toml b/pyproject.toml index db43454..dab9087 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,10 @@ license = "Apache-2.0" authors = [ { name = "Functional Genomics Center Zurich" }, ] -dependencies = [] +dependencies = [ + "cyclopts>=5.2,<6", + "loguru>=0.7.3,<0.8", +] [project.scripts] fgcz-reference = "fgcz_reference_project.cli:main" @@ -107,7 +110,7 @@ show_missing = true [tool.deptry] known_first_party = ["fgcz_reference_project"] # The vendored hook in .ci/ is a separate distribution with its own dependencies; -# deptry would otherwise report them against this zero-dependency project. +# deptry would otherwise report them against this project. extend_exclude = ["\\.ci"] [tool.check-tests-structure] diff --git a/src/fgcz_reference_project/cli.py b/src/fgcz_reference_project/cli.py index 8b3e8dd..8376517 100644 --- a/src/fgcz_reference_project/cli.py +++ b/src/fgcz_reference_project/cli.py @@ -2,29 +2,48 @@ from __future__ import annotations -import argparse +import sys +from typing import TYPE_CHECKING + +import cyclopts +from loguru import logger from fgcz_reference_project import naming +if TYPE_CHECKING: + from collections.abc import Sequence + +app = cyclopts.App( + name="fgcz-reference", + help="Normalize a project name to a stable slug.", +) + -def _parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser( - prog="fgcz-reference", - description="Normalize a project name to a stable slug.", - ) - parser.add_argument("name", help="Human-readable project name.") - return parser +@app.default +def slug(name: str, *, verbose: bool = False) -> int: + """Print the stable slug for a project name. + Args: + name: Human-readable project name. + verbose: Also log debug messages to stderr. -def main() -> int: - """Run the example command-line interface.""" - parser = _parser() - arguments = parser.parse_args() + Returns: + Exit code: 0 on success, 1 when the name has no letters or digits. + """ + # The CLI owns logging configuration; library modules only emit records. + logger.remove() + logger.add(sys.stderr, level="DEBUG" if verbose else "INFO") try: - slug = naming.project_slug(arguments.name) + result = naming.project_slug(name) except ValueError as error: - parser.error(str(error)) + logger.error(str(error)) + return 1 - print(slug) + print(result) return 0 + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the command-line interface and return its exit code.""" + return app(argv, result_action="return_value") diff --git a/src/fgcz_reference_project/naming.py b/src/fgcz_reference_project/naming.py index 6b5c775..d3a6767 100644 --- a/src/fgcz_reference_project/naming.py +++ b/src/fgcz_reference_project/naming.py @@ -4,6 +4,8 @@ import re +from loguru import logger + _NON_ALPHANUMERIC = re.compile(r"[^a-z0-9]+") @@ -23,4 +25,5 @@ def project_slug(name: str) -> str: if not slug: message = "Project name must contain at least one letter or digit." raise ValueError(message) + logger.debug("Slug for {!r}: {!r}", name, slug) return slug diff --git a/tests/test_cli.py b/tests/test_cli.py index a570a36..fb6c97f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,30 +1,38 @@ """Tests for the example command-line interface.""" -import sys +from collections.abc import Iterator import pytest +from loguru import logger from fgcz_reference_project import cli -def test_main_prints_slug( - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - monkeypatch.setattr(sys, "argv", ["fgcz-reference", "Example Project"]) +@pytest.fixture(autouse=True) +def _reset_logging() -> Iterator[None]: + yield + logger.remove() - assert cli.main() == 0 - assert capsys.readouterr().out == "example-project\n" +def test_main_prints_slug(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["Example Project"]) == 0 -def test_main_reports_invalid_name( - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - monkeypatch.setattr(sys, "argv", ["fgcz-reference", "///"]) + captured = capsys.readouterr() + assert captured.out == "example-project\n" + assert "Slug for" not in captured.err - with pytest.raises(SystemExit) as error: - cli.main() - assert error.value.code == 2 - assert "at least one letter or digit" in capsys.readouterr().err +def test_main_verbose_logs_debug(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["--verbose", "Example Project"]) == 0 + + captured = capsys.readouterr() + assert captured.out == "example-project\n" + assert "Slug for 'Example Project': 'example-project'" in captured.err + + +def test_main_reports_invalid_name(capsys: pytest.CaptureFixture[str]) -> None: + assert cli.main(["///"]) == 1 + + captured = capsys.readouterr() + assert captured.out == "" + assert "at least one letter or digit" in captured.err diff --git a/uv.lock b/uv.lock index 90a4874..574e02c 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "babel" version = "2.18.0" @@ -267,6 +276,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/f3/56ccb2884aaa3db5622368e5191a3384b15f35392aa93df8b2f508c660d2/cyclonedx_python_lib-11.11.0-py3-none-any.whl", hash = "sha256:3049fc83e06a059b5c5907a527625a8ed5073caab10607ed4c9e5503b590fd44", size = 528689, upload-time = "2026-06-17T11:57:47.358Z" }, ] +[[package]] +name = "cyclopts" +version = "5.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/c1/3debeeb6e0eb74a51d6f8cf1f273ed7c479e3321631cbf89fb9700e65e28/cyclopts-5.2.0.tar.gz", hash = "sha256:b63c1b1beaadf3ead19214385a0f90b990f152c4107c174e1724c45dc71e9541", size = 246542, upload-time = "2026-10-06T15:02:39.212Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/dd/3f73d04c95f6acc6a54700011637e49362e337139b518be895edf4786f87/cyclopts-5.2.0-py3-none-any.whl", hash = "sha256:5da2a5d65164e03008621fc4795946b88a722b881398b5dfa58afa6218342cff", size = 289196, upload-time = "2026-10-06T15:02:37.57Z" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -323,10 +347,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + [[package]] name = "fgcz-python-project-reference" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "cyclopts" }, + { name = "loguru" }, +] [package.dev-dependencies] dev = [ @@ -344,6 +381,10 @@ docs = [ ] [package.metadata] +requires-dist = [ + { name = "cyclopts", specifier = ">=5.2,<6" }, + { name = "loguru", specifier = ">=0.7.3,<0.8" }, +] [package.metadata.requires-dev] dev = [ @@ -430,6 +471,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, ] +[[package]] +name = "loguru" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -995,6 +1049,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "rich-rst" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/faf7c7e36630561e3e9611730c47510cd972dd5fde8f95941ff78f76accd/rich_rst-2.2.0.tar.gz", hash = "sha256:b1e6a67f8f694a6f36035624bf73e2b1a0a4be13edaf3ba5e654d9758b61073a", size = 326270, upload-time = "2026-09-27T19:12:42.163Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/4e/716b42bf066e180c1235783b46f095a5d871d05dca7006d2295aac29c491/rich_rst-2.2.0-py3-none-any.whl", hash = "sha256:1ea1c43813dc4a8d86475fce27ffb74e0ada3d4656c9f8130ba2a59868055fe9", size = 279274, upload-time = "2026-09-27T19:12:40.548Z" }, +] + [[package]] name = "ruff" version = "0.16.7" @@ -1149,3 +1216,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, +]