From d4c78cc29d32f20eae5e734532d5c32e03bdca2d Mon Sep 17 00:00:00 2001 From: Daniel Weschke Date: Thu, 24 Sep 2026 19:06:28 +0200 Subject: [PATCH] add decorator for cli context --- .gitignore | 1 + CONTRIBUTING.md | 12 +-- justfile | 31 ++++--- scripts/generate_schema.py | 2 +- src/dbyaml/cli.py | 105 +++++++++++++++++++++++- src/dbyaml/cli.pyi | 14 +++- src/dbyaml/decorator.py | 54 ++++++++++++ src/dbyaml/decorator.pyi | 8 ++ src/dbyaml/resources/dbyaml.schema.json | 15 +++- tests/test_cli.py | 58 +++++++++++++ tests/test_decorator.py | 10 +++ 11 files changed, 285 insertions(+), 25 deletions(-) create mode 100644 src/dbyaml/decorator.py create mode 100644 src/dbyaml/decorator.pyi create mode 100644 tests/test_cli.py create mode 100644 tests/test_decorator.py diff --git a/.gitignore b/.gitignore index 756f939..2bf16d0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ build/ dist/ wheels/ *.egg-info +.venv diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da6472b..2fe2e13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,8 +9,8 @@ Prerequisites: ```shell # Arch Linux pacman -S uv just -# optinal -pacman -S just-lsp yamlfmt shellcheck +# optional +pacman -S just-lsp yamlfmt shellcheck typos ``` The development environment and testing requires multiple specific Python versions; they can be installed with (inside the pulled git project directory): @@ -31,13 +31,13 @@ just test ## Formatting -Formatting of YAML files requires [yamlfmt](https://github.com/google/yamlfmt) to be installed seperately. +Formatting of YAML files requires [yamlfmt](https://github.com/google/yamlfmt) to be installed separately. A script is available to run the format check. ```shell just format-check -just schema-scheck +just schema-check ``` ## Linting @@ -45,6 +45,7 @@ just schema-scheck Linting requires [shellcheck](https://github.com/koalaman/shellcheck) to be installed separately. Validating `pyproject.toml` against the checked-in uv schema also requires [jq](https://jqlang.org/). +Spell checking requires [typos](https://github.com/crate-ci/typos) to be installed separately. A script is available to run the lint check. @@ -55,9 +56,8 @@ just type-check ## Documentation -A script is available to run the documentaion generation. +A script is available to run the documentation generation. ```shell just docs -just man ``` diff --git a/justfile b/justfile index 3a4e3c7..869c7e7 100644 --- a/justfile +++ b/justfile @@ -7,7 +7,7 @@ app_name := "dbyaml" # interactive recipe selection [group('info')] -@select-shell: +@select: just --choose prelude := ''' @@ -25,13 +25,17 @@ version cmd=app_name: {{prelude}} {{cmd}} --version -# run all dev tools +# run checkers [parallel, group('dev')] -ci: test format-check lint-check type-check schema-check +ci-check: test format-check lint-check type-check schema-check -# run all dev tools +# run fixes [parallel, group('dev')] -ci-gen: format-fix lint-fix type-fix docs +ci-fix: format-fix lint-fix type-fix + +# run generators (docs) +[parallel, group('dev')] +ci-gen: docs # setup virtual environment [group('install')] @@ -97,9 +101,8 @@ ci-gen: format-fix lint-fix type-fix docs echo "running checker; shellcheck (shell)" shellcheck scripts/*.sh - # TODO: - # Spell checking - # uv run --only-group=check typos + echo "running spell-checker: typos" + uv run --only-group=check typos # fix typing [group('dev')] @@ -109,25 +112,29 @@ ci-gen: format-fix lint-fix type-fix docs echo "running checker; shellcheck (shell)" shellcheck scripts/*.sh + # TODO: run fix + + echo "running spell-checker: typos" + uv run --only-group=check typos --write-changes # check project schema [group('dev')] @schema-check: - echo "running schema checker (Metadata dbyaml)" + echo "running schema checker (dbyaml config)" uv run scripts/generate_schema.py | diff - src/dbyaml/resources/dbyaml.schema.json # generate all docs [group('dev')] -docs: schema-generate man +docs: schema-generate docs-man # generate project schema [group('dev')] @schema-generate: - echo "Python project metadata and dbyaml schema" + echo "running schema checker (dbyaml config)" uv run scripts/generate_schema.py --outfile src/dbyaml/resources/dbyaml.schema.json # generate man pages [group('dev')] -@man: +@docs-man: echo "generate man" uv run click-man --man-version $(uv version --short) dbyaml diff --git a/scripts/generate_schema.py b/scripts/generate_schema.py index 07981f4..a96b159 100644 --- a/scripts/generate_schema.py +++ b/scripts/generate_schema.py @@ -11,7 +11,7 @@ def generate_schema_from_click(ctx, additional_properties=False): schema: dict[str, Any] = { "$schema": "http://json-schema.org/draft-07/schema#", "$id": ("./src/dbyaml/resources/dbyaml.schema.json"), - "$comment": "tool.dbyaml table in pyproject.toml", + "$comment": "dbyaml table", } schema_ctx = generate_node_from_click(ctx, additional_properties) schema.update(schema_ctx) diff --git a/src/dbyaml/cli.py b/src/dbyaml/cli.py index 1cdb9d4..02d3a31 100644 --- a/src/dbyaml/cli.py +++ b/src/dbyaml/cli.py @@ -3,6 +3,7 @@ import platform import sys from dbyaml import COMPILED, __version__ +from dbyaml.decorator import uses_decorator def debugger_enabled(): @@ -26,8 +27,87 @@ except ImportError: sys.exit(1) else: + import enum + import json + from functools import update_wrapper - @click.group + import yaml + + class FormatType(str, enum.Enum): + @staticmethod + def _generate_next_value_(name, start, count, last_values): + return name + + raw = enum.auto() + yaml = enum.auto() + json = enum.auto() + + def pass_context(*d_args, **d_kwargs): + """ + uses FormatType for printouts in different formats + + @pass_context + @pass_context() + @pass_context(obj) + @pass_context(module) + @pass_context(module.function) + """ + + def decorator(f): + @click.pass_context + def new_func(ctx, *args, **kwargs): + ctx.ensure_object(dict) + ctx.obj.update(ctx.params) + if ctx.obj["debug"]: + click.echo(f"DEBUG {f.__name__} obj:\n{yaml.dump(ctx.obj)}", err=True) + + def echo(obj): + if ctx.obj["format_type"] == FormatType.json: + res = click.echo(json.dumps(obj)) + elif ctx.obj["format_type"] == FormatType.yaml: + res = click.echo(yaml.dump(obj)) + else: + res = click.echo(obj) + return res + + ctx.echo = echo + ret = ctx.invoke(f, ctx, *args, **kwargs) + if ctx.obj["debug"]: + click.echo(f"DEBUG {f.__name__} return: {ret}") + return ret + + return update_wrapper(new_func, f) + + isf = False + if ( + len(d_args) + and callable(d_args[0]) + and d_args[0] is not None + and uses_decorator( + func_name=d_args[0].__name__, deco_name="pass_context", filename=__file__ + ) + ): + isf = d_args[0] + return decorator(isf) if isf else decorator + + @click.group( + invoke_without_command=True, + context_settings={ + "help_option_names": ["-h", "--help"], + "auto_envvar_prefix": "DBYAML", + }, + ) + @click.argument( + "filename", + type=click.Path( + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + allow_dash=False, + path_type=str, + ), + ) @click.option( "--config", type=click.Path( @@ -41,6 +121,15 @@ else: is_eager=True, help="Read configuration options from a configuration file.", ) + @click.option( + "-t", + "--format", + "format_type", + help="Format type.", + type=click.Choice(FormatType, case_sensitive=False), + envvar="DBYAML_FORMAT", # instead of auto DBYAML_FORMAT_TYPE + show_envvar=True, + ) @click.option( "-q", "--quiet", @@ -54,6 +143,7 @@ else: "-v", "--verbose", is_flag=True, + show_envvar=True, help=("Emit verbose messages."), ) @click.version_option( @@ -65,9 +155,16 @@ else: f"Python ({platform.python_implementation()}) {platform.python_version()}" ), ) - @click.pass_context - def main(ctx, config, quiet, verbose): ... + @click.option("-D", "--debug", is_flag=True, show_envvar=True, hidden=True) + @pass_context + def main(ctx, filename, config, format_type, quiet, verbose, debug): + """The uncompromising db(yaml) utility.""" + ctx.obj["DEBUG"] = debug + if ctx.invoked_subcommand is None: + click.echo("I was invoked without subcommand") + else: + click.echo(f"I am about to invoke {ctx.invoked_subcommand}") @main.command - @click.pass_context + @pass_context def list(ctx): ... diff --git a/src/dbyaml/cli.pyi b/src/dbyaml/cli.pyi index 4903d47..0cda088 100644 --- a/src/dbyaml/cli.pyi +++ b/src/dbyaml/cli.pyi @@ -1,6 +1,18 @@ +import enum + import click def debugger_enabled() -> bool: ... @click.group -def main(ctx: click.Context, config: str | None, quiet: bool) -> None: ... +def main( + ctx: click.Context, + filename: str, + config: str | None, + format_type: FormatType | None, + quiet: bool, + verbose: bool, + debug: bool, +) -> None: ... def list(ctx: click.Context) -> None: ... + +class FormatType(str, enum.Enum): ... diff --git a/src/dbyaml/decorator.py b/src/dbyaml/decorator.py new file mode 100644 index 0000000..d87c2c3 --- /dev/null +++ b/src/dbyaml/decorator.py @@ -0,0 +1,54 @@ +import ast + + +def attribute_path(node, left_to_right=True): + """ + if reverse=True: + @module.decorator() -> ["module", "decorator()"] + """ + stack = [] + while node: + if isinstance(node, ast.Name): + stack.append(node.id) + break + elif isinstance(node, ast.Attribute): + stack.append(node.attr) + node = node.value + elif isinstance(node, ast.Call): + # if call append () to string representation + if isinstance(node.func, ast.Attribute): + stack.append(f"{node.func.attr}()") + node = node.func.value + elif isinstance(node.func, ast.Name): + stack.append(f"{node.func.id}()") + break + else: + break + + # ASTs parses from the outside in (i.e. stack is from right to left) + if left_to_right: + return list(reversed(stack)) + return stack + + +def uses_decorator(func_name="", deco_name="", filename=None): + """check if 1st level functions are decorated""" + if filename is None: + return False + + with open(filename, "r", encoding="utf-8") as file: + tree = ast.parse(file.read()) + + node = None + for n in ast.walk(tree): + if isinstance(n, ast.FunctionDef) and n.name == func_name: + node = n + break + + if not node: + return False + + for d in node.decorator_list: + if attribute_path(d)[-1] == deco_name: + return True + return False diff --git a/src/dbyaml/decorator.pyi b/src/dbyaml/decorator.pyi new file mode 100644 index 0000000..7d95b46 --- /dev/null +++ b/src/dbyaml/decorator.pyi @@ -0,0 +1,8 @@ +import ast + +def attribute_path( + node: ast.Attribute | ast.Call | ast.Name, left_to_right: bool = True +) -> list: ... +def uses_decorator( + func_name: str = "", deco_name: str = "", filename: str | None = None +) -> bool: ... diff --git a/src/dbyaml/resources/dbyaml.schema.json b/src/dbyaml/resources/dbyaml.schema.json index 46a8bfe..98b1e87 100644 --- a/src/dbyaml/resources/dbyaml.schema.json +++ b/src/dbyaml/resources/dbyaml.schema.json @@ -1,10 +1,18 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "./src/dbyaml/resources/dbyaml.schema.json", - "$comment": "tool.dbyaml table in pyproject.toml", + "$comment": "dbyaml table", "type": "object", "additionalProperties": false, "properties": { + "format-type": { + "enum": [ + "raw", + "yaml", + "json" + ], + "description": "Format type." + }, "quiet": { "type": "boolean", "description": "Stop emitting all non-critical output. Error messages will still be emitted (which can silenced by 2>/dev/null).", @@ -15,6 +23,11 @@ "description": "Emit verbose messages.", "default": false }, + "debug": { + "type": "boolean", + "description": null, + "default": false + }, "list": { "type": "object", "additionalProperties": false, diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..5807181 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,58 @@ +import click +import yaml + +import dbyaml.cli +from dbyaml.cli import pass_context, uses_decorator # ty: ignore[unresolved-import] + + +def test_uses_decorator_cli(): + assert uses_decorator("main", "pass_context", dbyaml.cli.__file__) + assert uses_decorator("list", "pass_context", dbyaml.cli.__file__) + + +def test_uses_decorator_wo(): + @click.command + @pass_context + def cli_wo(ctx): ... + + assert uses_decorator("cli_wo", "pass_context", __file__) + + +def test_uses_decorator_wo_long(): + @click.command + @dbyaml.cli.pass_context # ty: ignore[unresolved-attribute] + def cli_wh_long(ctx): ... + + assert dbyaml.cli.uses_decorator("cli_wh_long", "pass_context", __file__) # ty: ignore[unresolved-attribute] + + +def test_uses_decorator_call(): + @click.command + @pass_context() + def cli_call(ctx): ... + + assert uses_decorator("cli_call", "pass_context()", __file__) + + +def test_uses_decorator_obj(): + @click.command + @pass_context(False) + def cli_obj(ctx): ... + + assert uses_decorator("cli_obj", "pass_context()", __file__) + + +def test_uses_decorator_mod(): + @click.command + @pass_context(yaml) + def cli_mod(ctx): ... + + assert uses_decorator("cli_mod", "pass_context()", __file__) + + +def test_uses_decorator_func(): + @click.command + @pass_context(yaml.dump) + def cli_func(ctx): ... + + assert uses_decorator("cli_func", "pass_context()", __file__) diff --git a/tests/test_decorator.py b/tests/test_decorator.py new file mode 100644 index 0000000..62b522a --- /dev/null +++ b/tests/test_decorator.py @@ -0,0 +1,10 @@ +import ast + +from dbyaml.decorator import attribute_path + +tree = ast.parse("alpha.beta().gamma").body[0].value # ty: ignore[unresolved-attribute] + + +def test_attribute_path(): + path = attribute_path(tree) + assert path == ["alpha", "beta()", "gamma"]