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 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"]