add decorator for cli context

This commit is contained in:
2026-09-24 19:06:28 +02:00
parent b4e1fa4ec3
commit d4c78cc29d
11 changed files with 285 additions and 25 deletions
+1
View File
@@ -5,3 +5,4 @@ build/
dist/
wheels/
*.egg-info
.venv
+6 -6
View File
@@ -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
```
+19 -12
View File
@@ -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
+1 -1
View File
@@ -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)
+101 -4
View File
@@ -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): ...
+13 -1
View File
@@ -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): ...
+54
View File
@@ -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
+8
View File
@@ -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: ...
+14 -1
View File
@@ -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,
+58
View File
@@ -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__)
+10
View File
@@ -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"]