duho 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- duho/__init__.py +69 -0
- duho/_compat.py +27 -0
- duho/_introspect.py +182 -0
- duho/args.py +937 -0
- duho/completion.py +364 -0
- duho/logging.py +165 -0
- duho/parsers.py +117 -0
- duho/presets.py +66 -0
- duho/py.typed +0 -0
- duho-0.1.0.dist-info/METADATA +525 -0
- duho-0.1.0.dist-info/RECORD +13 -0
- duho-0.1.0.dist-info/WHEEL +4 -0
- duho-0.1.0.dist-info/licenses/LICENSE +21 -0
duho/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Duho: A declarative CLI framework for Python.
|
|
2
|
+
|
|
3
|
+
Build command-line applications with minimal boilerplate by declaring
|
|
4
|
+
your arguments and commands as Python classes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .args import (
|
|
8
|
+
Append,
|
|
9
|
+
Args,
|
|
10
|
+
Arg,
|
|
11
|
+
Argument,
|
|
12
|
+
ArgumentBuilder,
|
|
13
|
+
AUTO,
|
|
14
|
+
Choice,
|
|
15
|
+
Const,
|
|
16
|
+
Count,
|
|
17
|
+
Extend,
|
|
18
|
+
main,
|
|
19
|
+
NS,
|
|
20
|
+
parse,
|
|
21
|
+
print_completion,
|
|
22
|
+
UpdateAction,
|
|
23
|
+
value_sources,
|
|
24
|
+
)
|
|
25
|
+
from . import completion
|
|
26
|
+
from .logging import (
|
|
27
|
+
DefaultFormatter,
|
|
28
|
+
add_logging_level,
|
|
29
|
+
init_stderr_logging,
|
|
30
|
+
parse_loglevels,
|
|
31
|
+
)
|
|
32
|
+
from .presets import LoggingArgs
|
|
33
|
+
|
|
34
|
+
__version__ = "0.1.0"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parser(cls, *args, **kwargs):
|
|
38
|
+
"""Build an ArgumentParser for an Args class.
|
|
39
|
+
|
|
40
|
+
Public module-level entry point (delegates to cls._parser_).
|
|
41
|
+
"""
|
|
42
|
+
return cls._parser_(*args, **kwargs)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"Append",
|
|
47
|
+
"Args",
|
|
48
|
+
"Arg",
|
|
49
|
+
"Argument",
|
|
50
|
+
"ArgumentBuilder",
|
|
51
|
+
"AUTO",
|
|
52
|
+
"Choice",
|
|
53
|
+
"completion",
|
|
54
|
+
"Const",
|
|
55
|
+
"Count",
|
|
56
|
+
"Extend",
|
|
57
|
+
"LoggingArgs",
|
|
58
|
+
"main",
|
|
59
|
+
"NS",
|
|
60
|
+
"parse",
|
|
61
|
+
"parser",
|
|
62
|
+
"print_completion",
|
|
63
|
+
"UpdateAction",
|
|
64
|
+
"value_sources",
|
|
65
|
+
"add_logging_level",
|
|
66
|
+
"DefaultFormatter",
|
|
67
|
+
"init_stderr_logging",
|
|
68
|
+
"parse_loglevels",
|
|
69
|
+
]
|
duho/_compat.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Version compatibility shims for duho.
|
|
2
|
+
|
|
3
|
+
Centralizes all version-specific logic and fallbacks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import logging as _logging
|
|
7
|
+
import types as _types
|
|
8
|
+
import typing as _ty
|
|
9
|
+
|
|
10
|
+
# Union type origins: Union on all versions, UnionType only 3.10+
|
|
11
|
+
UNION_ORIGINS: tuple = (
|
|
12
|
+
_ty.Union,
|
|
13
|
+
*([_types.UnionType] if hasattr(_types, "UnionType") else []),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_level_names_mapping() -> dict[str, int]:
|
|
18
|
+
"""Get mapping of level names to level integers.
|
|
19
|
+
|
|
20
|
+
Fallback for Python < 3.10 which lacks getLevelNamesMapping.
|
|
21
|
+
"""
|
|
22
|
+
if hasattr(_logging, "getLevelNamesMapping"):
|
|
23
|
+
return _logging.getLevelNamesMapping()
|
|
24
|
+
return _logging._nameToLevel.copy()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["UNION_ORIGINS", "get_level_names_mapping"]
|
duho/_introspect.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import ast as _ast
|
|
2
|
+
import functools as _functools
|
|
3
|
+
import inspect as _inspect
|
|
4
|
+
import sys as _sys
|
|
5
|
+
import textwrap as _textwrap
|
|
6
|
+
import typing as _ty
|
|
7
|
+
from dataclasses import dataclass as _data
|
|
8
|
+
from pathlib import Path as _Path
|
|
9
|
+
|
|
10
|
+
# Classes from these modules are never user-defined Args mixins; skip scanning
|
|
11
|
+
# their source entirely (stops re-parsing e.g. argparse.py for Namespace).
|
|
12
|
+
_SKIP_MODULES = frozenset({"argparse", "builtins", "typing"})
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@_functools.lru_cache(maxsize=None)
|
|
16
|
+
def _module_index(filename: str) -> "dict[str, _ast.ClassDef]":
|
|
17
|
+
"""Parse a source file once and index all ClassDefs by qualname.
|
|
18
|
+
|
|
19
|
+
Qualname is reconstructed by walking the tree while tracking the
|
|
20
|
+
enclosing scope chain: entering a ClassDef appends "Name.", entering a
|
|
21
|
+
Function/AsyncFunctionDef appends "name.<locals>." — this reproduces
|
|
22
|
+
__qualname__ exactly, so nested and function-local classes resolve.
|
|
23
|
+
"""
|
|
24
|
+
index: "dict[str, _ast.ClassDef]" = {}
|
|
25
|
+
src = _Path(filename).read_text()
|
|
26
|
+
tree = _ast.parse(src)
|
|
27
|
+
|
|
28
|
+
def walk(node, prefix: str):
|
|
29
|
+
for child in _ast.iter_child_nodes(node):
|
|
30
|
+
if isinstance(child, _ast.ClassDef):
|
|
31
|
+
qualname = prefix + child.name
|
|
32
|
+
index[qualname] = child
|
|
33
|
+
walk(child, qualname + ".")
|
|
34
|
+
elif isinstance(child, (_ast.FunctionDef, _ast.AsyncFunctionDef)):
|
|
35
|
+
walk(child, prefix + child.name + ".<locals>.")
|
|
36
|
+
else:
|
|
37
|
+
walk(child, prefix)
|
|
38
|
+
|
|
39
|
+
walk(tree, "")
|
|
40
|
+
return index
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def getclsdef(cls: type) -> "_ast.ClassDef | None":
|
|
44
|
+
"""Locate the ClassDef AST node for cls. Never raises."""
|
|
45
|
+
try:
|
|
46
|
+
module = _sys.modules.get(getattr(cls, "__module__", None))
|
|
47
|
+
file = getattr(module, "__file__", None)
|
|
48
|
+
if file:
|
|
49
|
+
qualname = getattr(cls, "__qualname__", cls.__name__)
|
|
50
|
+
found = _module_index(file).get(qualname)
|
|
51
|
+
if found is not None:
|
|
52
|
+
return found
|
|
53
|
+
|
|
54
|
+
src = _inspect.getsource(cls)
|
|
55
|
+
src = _textwrap.dedent(src)
|
|
56
|
+
for node in _ast.walk(_ast.parse(src)):
|
|
57
|
+
if isinstance(node, _ast.ClassDef) and node.name == cls.__name__:
|
|
58
|
+
return node
|
|
59
|
+
return None
|
|
60
|
+
except (OSError, TypeError, SyntaxError):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NotDefined: ...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
NOT_DEFINED = NotDefined()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@_data
|
|
71
|
+
class ClsArgDeclaration:
|
|
72
|
+
default: object
|
|
73
|
+
type: type
|
|
74
|
+
annotations: list
|
|
75
|
+
docstring: str
|
|
76
|
+
exprs: list
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _class_constants(cls: type) -> "dict[str, list]":
|
|
80
|
+
"""Scan a single class body for name -> [docstring?, *exprs] lists.
|
|
81
|
+
|
|
82
|
+
Cached on the class itself (checked via vars(), not getattr, so
|
|
83
|
+
inheritance can't false-hit a parent's cache).
|
|
84
|
+
"""
|
|
85
|
+
if cls is object:
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
if "_duho_constants_" in vars(cls):
|
|
89
|
+
return cls._duho_constants_ # type:ignore
|
|
90
|
+
|
|
91
|
+
if cls.__module__ in _SKIP_MODULES:
|
|
92
|
+
result: "dict[str, list]" = {}
|
|
93
|
+
else:
|
|
94
|
+
result = {}
|
|
95
|
+
clsdef = getclsdef(cls)
|
|
96
|
+
if clsdef is not None:
|
|
97
|
+
argument = None
|
|
98
|
+
for node in clsdef.body:
|
|
99
|
+
if isinstance(node, (_ast.Assign, _ast.AnnAssign)):
|
|
100
|
+
if isinstance(node, _ast.Assign):
|
|
101
|
+
if len(node.targets) == 1 and isinstance(node.targets[0], _ast.Name):
|
|
102
|
+
argument = node.targets[0].id
|
|
103
|
+
else:
|
|
104
|
+
argument = None
|
|
105
|
+
else:
|
|
106
|
+
target = node.target
|
|
107
|
+
argument = target.id if isinstance(target, _ast.Name) else None
|
|
108
|
+
continue
|
|
109
|
+
elif isinstance(node, _ast.Expr) and argument:
|
|
110
|
+
try:
|
|
111
|
+
value = _ast.literal_eval(node.value)
|
|
112
|
+
except ValueError:
|
|
113
|
+
continue
|
|
114
|
+
props = result.setdefault(argument, [])
|
|
115
|
+
props.append(value)
|
|
116
|
+
else:
|
|
117
|
+
argument = None
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
setattr(cls, "_duho_constants_", result)
|
|
121
|
+
except TypeError:
|
|
122
|
+
pass # some builtin/extension types forbid attribute assignment
|
|
123
|
+
return result
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def get_clsargs_constants(cls: type) -> "dict[str, list]":
|
|
127
|
+
argsexprs: "dict[str, list]" = {}
|
|
128
|
+
for base in cls.__mro__:
|
|
129
|
+
for name, exprs in _class_constants(base).items():
|
|
130
|
+
argsexprs.setdefault(name, []).extend(exprs)
|
|
131
|
+
return argsexprs
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_routine_or_descriptor(value) -> bool:
|
|
135
|
+
if _inspect.isroutine(value):
|
|
136
|
+
return True
|
|
137
|
+
return hasattr(type(value), "__get__")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_clsargs(cls: type) -> "dict[str, ClsArgDeclaration]":
|
|
141
|
+
if "_duho_clsargs_" in vars(cls):
|
|
142
|
+
return cls._duho_clsargs_ # type:ignore
|
|
143
|
+
|
|
144
|
+
typehints = _ty.get_type_hints(cls, include_extras=True)
|
|
145
|
+
constants = get_clsargs_constants(cls)
|
|
146
|
+
args: "dict[str, ClsArgDeclaration]" = {}
|
|
147
|
+
for name, type in typehints.items():
|
|
148
|
+
if name.startswith("_"):
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
annotations = []
|
|
152
|
+
if hasattr(type, "__metadata__"):
|
|
153
|
+
annotations.extend(type.__metadata__)
|
|
154
|
+
type = type.__origin__
|
|
155
|
+
|
|
156
|
+
argconstant = constants.get(name, [])
|
|
157
|
+
if argconstant and isinstance(argconstant[0], str):
|
|
158
|
+
docstring = argconstant[0]
|
|
159
|
+
argconstant = argconstant[1:]
|
|
160
|
+
else:
|
|
161
|
+
docstring = ""
|
|
162
|
+
|
|
163
|
+
default = _inspect.getattr_static(cls, name, NOT_DEFINED)
|
|
164
|
+
if default is not NOT_DEFINED and _is_routine_or_descriptor(default):
|
|
165
|
+
default = NOT_DEFINED
|
|
166
|
+
|
|
167
|
+
args[name] = ClsArgDeclaration(
|
|
168
|
+
type=type,
|
|
169
|
+
default=default,
|
|
170
|
+
annotations=annotations,
|
|
171
|
+
docstring=docstring,
|
|
172
|
+
exprs=argconstant,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
setattr(cls, "_duho_clsargs_", args)
|
|
177
|
+
except TypeError:
|
|
178
|
+
pass # some builtin/extension types forbid attribute assignment
|
|
179
|
+
return args
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = ["getclsdef", "NotDefined", "NOT_DEFINED", "ClsArgDeclaration", "get_clsargs", "get_clsargs_constants"]
|