moat-lib-run 0.1.1__tar.gz

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.
@@ -0,0 +1,14 @@
1
+ The code in this repository, and all MoaT submodules it refers to,
2
+ is part of the MoaT project.
3
+
4
+ Unless a submodule's LICENSE.txt states otherwise, all included files are
5
+ licensed under the LGPL V3, as published by the FSF at
6
+ https://www.gnu.org/licenses/lgpl-3.0.html .
7
+
8
+ In addition to the LGPL's terms, the author(s) respectfully ask all users of
9
+ this code to contribute any bug fixes or enhancements. Also, please link back to
10
+ https://M-o-a-T.org.
11
+
12
+ Thank you.
13
+
14
+ Copyright © 2021 ff.: the MoaT contributor(s), as per the git changelog(s).
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/make -f
2
+
3
+ PACKAGE = moat-lib-run
4
+ MAKEINCL ?= $(shell python3 -mmoat src path)/make/py
5
+
6
+ ifneq ($(wildcard $(MAKEINCL)),)
7
+ include $(MAKEINCL)
8
+ # availabe via http://github.com/smurfix/sourcemgr
9
+
10
+ else
11
+ %:
12
+ @echo "Please fix 'python3 -mmoat src path'."
13
+ @exit 1
14
+ endif
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: moat-lib-run
3
+ Version: 0.1.1
4
+ Summary: Main command entry point infrastructure for MoaT applications
5
+ Maintainer-email: Matthias Urlichs <matthias@urlichs.de>
6
+ Project-URL: homepage, https://m-o-a-t.org
7
+ Project-URL: repository, https://github.com/M-o-a-T/moat
8
+ Keywords: MoaT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Framework :: AnyIO
11
+ Classifier: Framework :: Trio
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Intended Audience :: Developers
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE.txt
18
+ Requires-Dist: anyio~=4.0
19
+ Requires-Dist: moat-util~=0.61.1
20
+ Requires-Dist: asyncclick~=8.0
21
+ Requires-Dist: simpleeval~=1.0
22
+ Requires-Dist: moat-lib-config~=0.1.0
23
+ Requires-Dist: moat-lib-proxy~=0.1.0
24
+ Dynamic: license-file
25
+
26
+ # moat-lib-run
27
+
28
+ % start main
29
+ % start synopsis
30
+
31
+ Main command entry point infrastructure for MoaT applications.
32
+
33
+ % end synopsis
34
+
35
+ This module provides the infrastructure for building command-line interfaces
36
+ for MoaT applications. It includes:
37
+
38
+ - Command-line argument parsing with Click integration
39
+ - Subcommand loading from internal modules and extensions
40
+ - Configuration file handling
41
+ - Logging setup
42
+ - Main entry point wrappers for testing
43
+
44
+ ## Usage
45
+
46
+ ### Basic command setup
47
+
48
+ ```python
49
+ from moat.lib.run import main_, wrap_main
50
+
51
+ @main_.command()
52
+ async def my_command(ctx):
53
+ """A simple command"""
54
+ print("Hello from my command!")
55
+ ```
56
+
57
+ ### Loading subcommands
58
+
59
+ Use `load_subgroup` to create command groups that automatically load subcommands:
60
+
61
+ ```python
62
+ from moat.lib.run import load_subgroup
63
+ import asyncclick as click
64
+
65
+ @load_subgroup(prefix="myapp.commands")
66
+ @click.pass_context
67
+ async def cli(ctx):
68
+ """Main command group"""
69
+ pass
70
+ ```
71
+
72
+ ### Processing command-line arguments
73
+
74
+ The `attr_args` decorator and `process_args` function provide flexible
75
+ argument handling:
76
+
77
+ ```python
78
+ from moat.lib.run import attr_args, process_args
79
+
80
+ @main_.command()
81
+ @attr_args(with_path=True)
82
+ async def configure(**kw):
83
+ """Configure the application"""
84
+ config = process_args({}, **kw)
85
+ # config now contains parsed arguments
86
+ ```
87
+
88
+ ## Key Functions
89
+
90
+ - `main_`: The default main command handler
91
+ - `wrap_main`: Wrapper for the main command, useful for testing
92
+ - `load_subgroup`: Decorator to create command groups with automatic subcommand loading
93
+ - `attr_args`: Decorator for adding flexible argument handling to commands
94
+ - `process_args`: Function to process command-line arguments into configuration
95
+ - `Loader`: Click group class that loads commands from submodules and extensions
96
+
97
+ % end main
@@ -0,0 +1,72 @@
1
+ # moat-lib-run
2
+
3
+ % start main
4
+ % start synopsis
5
+
6
+ Main command entry point infrastructure for MoaT applications.
7
+
8
+ % end synopsis
9
+
10
+ This module provides the infrastructure for building command-line interfaces
11
+ for MoaT applications. It includes:
12
+
13
+ - Command-line argument parsing with Click integration
14
+ - Subcommand loading from internal modules and extensions
15
+ - Configuration file handling
16
+ - Logging setup
17
+ - Main entry point wrappers for testing
18
+
19
+ ## Usage
20
+
21
+ ### Basic command setup
22
+
23
+ ```python
24
+ from moat.lib.run import main_, wrap_main
25
+
26
+ @main_.command()
27
+ async def my_command(ctx):
28
+ """A simple command"""
29
+ print("Hello from my command!")
30
+ ```
31
+
32
+ ### Loading subcommands
33
+
34
+ Use `load_subgroup` to create command groups that automatically load subcommands:
35
+
36
+ ```python
37
+ from moat.lib.run import load_subgroup
38
+ import asyncclick as click
39
+
40
+ @load_subgroup(prefix="myapp.commands")
41
+ @click.pass_context
42
+ async def cli(ctx):
43
+ """Main command group"""
44
+ pass
45
+ ```
46
+
47
+ ### Processing command-line arguments
48
+
49
+ The `attr_args` decorator and `process_args` function provide flexible
50
+ argument handling:
51
+
52
+ ```python
53
+ from moat.lib.run import attr_args, process_args
54
+
55
+ @main_.command()
56
+ @attr_args(with_path=True)
57
+ async def configure(**kw):
58
+ """Configure the application"""
59
+ config = process_args({}, **kw)
60
+ # config now contains parsed arguments
61
+ ```
62
+
63
+ ## Key Functions
64
+
65
+ - `main_`: The default main command handler
66
+ - `wrap_main`: Wrapper for the main command, useful for testing
67
+ - `load_subgroup`: Decorator to create command groups with automatic subcommand loading
68
+ - `attr_args`: Decorator for adding flexible argument handling to commands
69
+ - `process_args`: Function to process command-line arguments into configuration
70
+ - `Loader`: Click group class that loads commands from submodules and extensions
71
+
72
+ % end main
@@ -0,0 +1,7 @@
1
+ /files
2
+ /*.log
3
+ /*.debhelper
4
+ /*.debhelper-build-stamp
5
+ /*.substvars
6
+ /debhelper-build-stamp
7
+ /python3-moat-lib-run
@@ -0,0 +1,5 @@
1
+ moat-lib-run (0.1.1-4) unstable; urgency=medium
2
+
3
+ * Initial release for 25.7.0
4
+
5
+ -- Matthias Urlichs <matthias@urlichs.de> Wed, 31 Dec 2025 14:01:43 +0100
@@ -0,0 +1,22 @@
1
+ Source: moat-lib-run
2
+ Maintainer: "Matthias Urlichs" <matthias@urlichs.de>
3
+ Section: python
4
+ Priority: optional
5
+ Build-Depends: dh-python, python3-all, debhelper (>= 13),
6
+ python3-setuptools,
7
+ python3-wheel,
8
+ Standards-Version: 3.9.6
9
+ Homepage: https://m-o-a-t.org
10
+ X-DH-Compat: 13
11
+
12
+ Package: python3-moat-lib-run
13
+ Architecture: all
14
+ Depends: ${misc:Depends}, ${python3:Depends},
15
+ python3-anyio (>= 4.0),
16
+ python3-moat-util (>= 0.6),
17
+ python3-asyncclick (>= 8.0),
18
+ python3-simpleeval (>= 1.0),
19
+ Description: Main command entry point infrastructure for MoaT applications
20
+ This module provides the infrastructure for building command-line interfaces
21
+ for MoaT applications, including command-line argument parsing, subcommand
22
+ loading, configuration file handling, and logging setup.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/make -f
2
+
3
+ export PYBUILD_NAME=moat-lib-run
4
+ %:
5
+ dh $@ --with python3 --buildsystem=pybuild
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ build-backend = "setuptools.build_meta"
3
+ requires = ["wheel","setuptools"]
4
+
5
+ [project]
6
+ classifiers = [
7
+ "Development Status :: 4 - Beta",
8
+ "Framework :: AnyIO",
9
+ "Framework :: Trio",
10
+ "Framework :: AsyncIO",
11
+ "Programming Language :: Python :: 3",
12
+ "Intended Audience :: Developers",
13
+ ]
14
+ dependencies = [
15
+ "anyio ~= 4.0",
16
+ "moat-util ~= 0.61.1",
17
+ "asyncclick ~= 8.0",
18
+ "simpleeval ~= 1.0",
19
+ "moat-lib-config ~= 0.1.0",
20
+ "moat-lib-proxy ~= 0.1.0",
21
+ ]
22
+ keywords = ["MoaT"]
23
+ requires-python = ">=3.8"
24
+ name = "moat-lib-run"
25
+ maintainers = [{email = "matthias@urlichs.de",name = "Matthias Urlichs"}]
26
+ description='Main command entry point infrastructure for MoaT applications'
27
+ readme = "README.md"
28
+ version = "0.1.1"
29
+
30
+ [project.urls]
31
+ homepage = "https://m-o-a-t.org"
32
+ repository = "https://github.com/M-o-a-T/moat"
33
+
34
+ [tool.setuptools]
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
37
+
38
+ [tool.setuptools.package-data]
39
+ "*" = ["*.yaml"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,947 @@
1
+ """
2
+ Support for main program, argv, etc.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import ast
8
+ import importlib
9
+ import logging
10
+ import logging.config
11
+ import sys
12
+ from contextlib import suppress
13
+ from contextvars import ContextVar
14
+ from functools import partial, wraps
15
+ from pathlib import Path as FSPath
16
+
17
+ import asyncclick as click
18
+ import simpleeval
19
+
20
+ from moat.util import NotGiven, P, Path, attrdict, ungroup
21
+ from moat.lib.config import CFG, CfgStore, current_cfg
22
+
23
+ from collections import defaultdict
24
+ from collections.abc import Mapping
25
+
26
+ try:
27
+ from moat.lib.proxy import Proxy
28
+ except ImportError:
29
+ Proxy = None
30
+
31
+ from typing import TYPE_CHECKING
32
+
33
+ if TYPE_CHECKING:
34
+ from typing import Awaitable, Literal, Sequence # noqa:UP035
35
+
36
+ logger = logging.getLogger("_loader")
37
+
38
+ __all__ = [
39
+ "Loader",
40
+ "attr_args",
41
+ "list_ext",
42
+ "load_ext",
43
+ "load_subgroup",
44
+ "main_",
45
+ "option_ng",
46
+ "process_args",
47
+ "wrap_main",
48
+ ]
49
+
50
+ this_load = ContextVar("this_load", default=None)
51
+
52
+ NoneType = type(None)
53
+
54
+ # cmd_eval is a simple and safe "eval" replacement.
55
+ _eval = simpleeval.SimpleEval(functions={})
56
+ _eval.nodes[ast.Tuple] = lambda node: tuple( # pyright: ignore[reportOptionalSubscript]
57
+ _eval._eval(x) for x in node.elts
58
+ )
59
+ _eval.nodes[ast.List] = lambda node: list( # pyright: ignore[reportOptionalSubscript]
60
+ _eval._eval(x) for x in node.elts
61
+ )
62
+ _eval.nodes[ast.Dict] = lambda node: attrdict( # pyright: ignore[reportOptionalSubscript]
63
+ (_eval._eval(x), _eval._eval(y)) for x, y in zip(node.keys, node.values, strict=False)
64
+ )
65
+ cmd_eval = _eval.eval
66
+
67
+
68
+ def _no_config(*a, **k): # noqa:ARG001
69
+ import warnings # noqa: PLC0415
70
+
71
+ warnings.warn("Call to logging config ignored", stacklevel=2)
72
+
73
+
74
+ def attr_args(
75
+ proc=None,
76
+ with_combined="s",
77
+ with_arglist=False,
78
+ with_path=True,
79
+ with_eval=True,
80
+ with_var=True,
81
+ with_proxy=False,
82
+ par_name="Parameter",
83
+ ):
84
+ """
85
+ Add an option for setting possibly-hierarchical values to `click.command`.
86
+
87
+ ``-s``/``--set`` accepts these prefix-tagged values:
88
+ * ~str
89
+ * =value (``=-``/``=t``/``=f``/``=n`` for ``undefined``/`True`/`False`/`None`)
90
+ * .path (the leading dot is stripped)
91
+ * :path (the colon is not stripped)
92
+ * ^named_proxy
93
+
94
+ These are enabled (rather, displayed) by passing ``True`` in
95
+ ``with_val``, ``with_eval``, ``with_path``, and ``with_proxy``,
96
+ respectively. The latter defaults to `False`, all others to `True`.
97
+
98
+ Legacy behavior (hidden unless `with_combined` is False): Adds separate
99
+ ``-v``/``--var``, ``-e``/``--eval``, -p``/``--path, and
100
+ ``-P``/``--proxy`` arguments.
101
+
102
+ Use ``with_combined=False`` to get legacy behavior.
103
+ Use ``with_combined=LETTER`` to change the default from ``-s``.
104
+
105
+ In new mode, Legacy short options are not availabile;
106
+ long options are available but hidden.
107
+
108
+ All arguments are of the form "-X path value". A path consisting of a
109
+ single ``+`` expands to ``:n:n`` for easy appending to argument lists.
110
+ Use ``:=`` if you ever need a path that consist of a single plus character.
111
+ """
112
+
113
+ def _proc(proc):
114
+ if with_combined:
115
+ ht = []
116
+ if with_var:
117
+ ht.append("~str")
118
+ if with_eval:
119
+ ht.append("=expr")
120
+ if with_path:
121
+ ht.append(".path, :path")
122
+ if with_proxy:
123
+ ht.append("^proxy")
124
+ ht = " | ".join(ht)
125
+
126
+ args = ("--set",) + (
127
+ ("-" + ("s" if isinstance(with_combined, bool) else with_combined),)
128
+ if with_combined
129
+ else ()
130
+ )
131
+ proc = click.option(
132
+ *args,
133
+ "set_",
134
+ nargs=2,
135
+ type=(str, str),
136
+ multiple=True,
137
+ metavar="name|path value",
138
+ help=f"{par_name} (value: {ht})",
139
+ hidden=not with_eval or not with_combined,
140
+ )(proc)
141
+
142
+ if with_arglist:
143
+ proc = click.argument(
144
+ "args_",
145
+ nargs=-1,
146
+ type=str,
147
+ )(proc)
148
+
149
+ args = (f"--{with_path}" if isinstance(with_path, str) else "--path",) + (
150
+ ("-p",) if with_path else ()
151
+ )
152
+ proc = click.option(
153
+ *args,
154
+ "path_",
155
+ nargs=2,
156
+ type=(P, P),
157
+ multiple=True,
158
+ help=f"{par_name} (name value), as path",
159
+ hidden=not with_path or with_combined,
160
+ )(proc)
161
+
162
+ args = (f"--{with_eval}" if isinstance(with_eval, str) else "--eval",) + (
163
+ ("-e",) if with_eval is True else ()
164
+ )
165
+ proc = click.option(
166
+ *args,
167
+ "eval_",
168
+ nargs=2,
169
+ type=(P, str),
170
+ multiple=True,
171
+ help=f"{par_name} (name value), evaluated",
172
+ hidden=not with_eval or with_combined,
173
+ )(proc)
174
+
175
+ args = (f"--{with_var}" if isinstance(with_var, str) else "--var",) + (
176
+ ("-v",) if with_var is True else ()
177
+ )
178
+ proc = click.option(
179
+ *args,
180
+ "vars_",
181
+ nargs=2,
182
+ type=(P, str),
183
+ multiple=True,
184
+ help=f"{par_name} (name value)",
185
+ hidden=not with_var or with_combined,
186
+ )(proc)
187
+
188
+ args = (f"--{with_proxy}" if isinstance(with_proxy, str) else "--proxy",) + (
189
+ ("-P",) if with_proxy is True else ()
190
+ )
191
+ proc = click.option(
192
+ *args,
193
+ "proxy_",
194
+ nargs=2,
195
+ type=(P, str),
196
+ multiple=True,
197
+ help="Remote proxy (name value)",
198
+ hidden=not with_proxy or with_combined,
199
+ )(proc)
200
+
201
+ return proc
202
+
203
+ if proc is None:
204
+ return _proc
205
+ else:
206
+ return _proc(proc)
207
+
208
+
209
+ def process_args(
210
+ val: dict | None = None,
211
+ set_=(),
212
+ args_=(),
213
+ vars_=(),
214
+ eval_=(),
215
+ path_=(),
216
+ proxy_=(),
217
+ no_path=False,
218
+ vs=None,
219
+ ):
220
+ """
221
+ process ``set_``/``args_``/``vars_``/``eval_``/``path_``/``proxy_`` args.
222
+
223
+ Arguments:
224
+ val: dict to modify
225
+ set_, vars_, args_, eval_, path_, proxy_: via `attr_args`
226
+ vs: if given: set of vars
227
+ Returns:
228
+ the new value.
229
+ """
230
+ # otherwise these are assumes to be empty tuples.
231
+ if isinstance(set_, Mapping):
232
+ set_ = set_.items()
233
+ if isinstance(vars_, Mapping):
234
+ vars_ = vars_.items()
235
+ if isinstance(eval_, Mapping):
236
+ eval_ = eval_.items()
237
+ if isinstance(path_, Mapping):
238
+ path_ = path_.items()
239
+ if isinstance(proxy_, Mapping):
240
+ proxy_ = proxy_.items()
241
+
242
+ def data():
243
+ def s_eval(v):
244
+ if v[0] == "~":
245
+ v = v[1:]
246
+ elif v == "=-":
247
+ v = NotGiven
248
+ elif v == "=t":
249
+ v = True
250
+ elif v == "=f":
251
+ v = False
252
+ elif v == "=n":
253
+ v = None
254
+ elif v[0] == "=":
255
+ v = cmd_eval(v[1:]) # pylint: disable=W0631
256
+ elif v[0] == ":":
257
+ v = P(v)
258
+ elif v[0] == ".":
259
+ v = P(v[1:])
260
+ elif v[0] == "^":
261
+ v = Proxy(v[1:])
262
+ else:
263
+ try:
264
+ v = int(v)
265
+ except ValueError:
266
+ try:
267
+ v = float(v)
268
+ except ValueError:
269
+ pass # leave it as a string
270
+ return v
271
+
272
+ for k, v in set_:
273
+ yield k, s_eval(v)
274
+ for k, v in vars_:
275
+ yield k, v
276
+ for k, v in eval_:
277
+ # ruff:noqa:PLW2901 # var overwritten
278
+ if v == "-":
279
+ v = NotGiven
280
+ elif v == "/": # pylint: disable=W0631
281
+ if vs is None:
282
+ raise click.BadOptionUsage(
283
+ option_name=k,
284
+ message="A slash value doesn't work here.",
285
+ )
286
+ v = NoneType
287
+ else:
288
+ v = eval(v) # pylint: disable=W0631
289
+ yield k, v
290
+ for k, v in path_:
291
+ v = P(v)
292
+ if no_path:
293
+ v = tuple(v)
294
+ yield k, v
295
+ if proxy_:
296
+ if Proxy is None:
297
+ raise ImportError("No Proxy")
298
+ for k, v in proxy_:
299
+ v = Proxy(v)
300
+ yield k, v
301
+
302
+ # Arguments are given last, thus they get processed last.
303
+ for v in args_:
304
+ yield ((None, None), s_eval(v))
305
+
306
+ if set_:
307
+ dd = data()
308
+ else:
309
+ dd = [(len(k), k, v) for k, v in data()]
310
+ dd.sort()
311
+ dd = [(k, v) for _l, k, v in dd]
312
+
313
+ for k, v in dd:
314
+ if isinstance(k, str):
315
+ if k == "+":
316
+ k = Path.build((None, None))
317
+ else:
318
+ k = P(k)
319
+ if val is None:
320
+ CFG.mod(k, v)
321
+ continue
322
+
323
+ if not len(k):
324
+ if vs is not None:
325
+ raise click.BadOptionUsage(
326
+ option_name=k,
327
+ message="You can't use empty paths here.",
328
+ )
329
+ val = v
330
+ continue
331
+ if not isinstance(val, Mapping):
332
+ val = attrdict()
333
+ if vs is not None:
334
+ vs.add(str(k))
335
+ if v is NotGiven:
336
+ val = attrdict._delete(val, k) # pylint: disable=protected-access
337
+ elif v is NoneType:
338
+ val = attrdict._delete(val, k) # pylint: disable=protected-access
339
+ vs.discard(str(k))
340
+ else:
341
+ val = attrdict._update(val, k, v) # pylint: disable=protected-access
342
+ return val
343
+
344
+
345
+ def load_ext(name, *attr, err=False):
346
+ """
347
+ Load a module
348
+ """
349
+ path = name.split(".")
350
+ path.extend(attr[:-1])
351
+ dp = ".".join(path)
352
+ ".".join(path[:-1])
353
+ try:
354
+ mod = importlib.import_module(dp)
355
+ except ModuleNotFoundError as exc:
356
+ if err and not exc.name.endswith("._main"):
357
+ logger.debug("Err %s: %r", dp, exc, exc_info=exc)
358
+ return None
359
+ except FileNotFoundError:
360
+ if err:
361
+ raise
362
+ return None
363
+ else:
364
+ if attr:
365
+ try:
366
+ mod = getattr(mod, attr[-1])
367
+ except AttributeError:
368
+ logger.debug("Err %s.%s", dp, attr[-1])
369
+ return None
370
+ return mod
371
+
372
+
373
+ def _namespaces(name):
374
+ import pkgutil # pylint: disable=import-outside-toplevel # noqa: PLC0415
375
+
376
+ if name is NotGiven:
377
+ return ()
378
+ try:
379
+ ext = importlib.import_module(name)
380
+ except ModuleNotFoundError:
381
+ logger.debug("No NS: %s", name)
382
+ return ()
383
+ try:
384
+ p = ext.__path__
385
+ except AttributeError:
386
+ p = (str(FSPath(ext.__file__).parent),)
387
+ logger.debug("NS: %s %s", name, p)
388
+ return pkgutil.iter_modules(p, ext.__name__ + ".")
389
+
390
+
391
+ _ext_cache = defaultdict(dict)
392
+
393
+
394
+ def _cache_ext(ext_name, pkg_only):
395
+ """List external modules
396
+
397
+ Yields (name,path) tuples.
398
+
399
+ TODO: This is not zip safe.
400
+ """
401
+ for finder, name, ispkg in _namespaces(ext_name):
402
+ if pkg_only and not ispkg:
403
+ logger.debug("ExtNoC %s", name)
404
+ continue
405
+ logger.debug("ExtC %s", name)
406
+ x = name.rsplit(".", 1)[-1]
407
+ f = FSPath(finder.path) / x
408
+ _ext_cache[ext_name][x] = f
409
+
410
+
411
+ def list_ext(name, func=None, pkg_only=True):
412
+ """List external modules
413
+
414
+ Yields (name,path) tuples.
415
+
416
+ TODO: This is not zip safe.
417
+ """
418
+ logger.debug("List Ext %s (%s)", name, func)
419
+ if name not in _ext_cache:
420
+ with suppress(ModuleNotFoundError):
421
+ _cache_ext(name, pkg_only)
422
+ if func is None:
423
+ for a, b in _ext_cache[name].items():
424
+ logger.debug("Found %s %s", a, b)
425
+ yield a, b
426
+ return
427
+
428
+ for x, f in _ext_cache[name].items():
429
+ if (f / ".no_load").is_file():
430
+ logger.debug("Skip %s", f)
431
+ continue
432
+ fn = f / (func + ".py")
433
+ if not fn.is_file():
434
+ fn = f / func / "__init__.py"
435
+ if not fn.is_file():
436
+ # XXX this might be a namespace
437
+ logger.debug("No file: %s/%s", f, func)
438
+ continue
439
+ logger.debug("Found2 %s %s", x, f)
440
+ yield (x, f)
441
+
442
+
443
+ def load_subgroup(
444
+ _fn=None,
445
+ prefix=None,
446
+ sub_pre=None,
447
+ sub_post=None,
448
+ ext_pre=None,
449
+ ext_post=None,
450
+ **kw,
451
+ ) -> click.Command:
452
+ """
453
+ A decorator like click.group, enabling loading of subcommands
454
+
455
+ Internal extensions are loaded as ``{sub_pre}.*.{sub_post}``.
456
+ External extensions are loaded as ``{ext_pre}.*.{ext_post}``.
457
+
458
+ All other arguments are forwarded to `click.command`.
459
+ """
460
+
461
+ def _ext(fn, **kw):
462
+ return click.command(**kw)(fn)
463
+
464
+ kw["cls"] = partial(
465
+ kw.get("cls", Loader),
466
+ _util_sub_pre=sub_pre or this_load.get() or prefix,
467
+ _util_sub_post=sub_post or (None if prefix is None else "cli"),
468
+ _util_ext_pre=ext_pre or prefix,
469
+ _util_ext_post=ext_post or (None if prefix is None else "_main.cli"),
470
+ )
471
+
472
+ if _fn is None:
473
+ return partial(_ext, **kw)
474
+ else:
475
+ return _ext(_fn, **kw)
476
+
477
+
478
+ class Loader(click.Group):
479
+ """
480
+ A `click.group` that loads additional commands from subfolders and/or extensions.
481
+
482
+ Subfolders: set _util_sub_pre to your module's name.
483
+ This works with namespace packages.
484
+ E.g. "distkv.command" loads "distkv.command.*.cli".
485
+
486
+ Extensions: set _util_ext_pre to the extension basename.
487
+ Set _util_ext_post to the name of the extension.
488
+
489
+ E.g. "distkv_ext"+"client" loads "distkv_ext.*.client.cli".
490
+
491
+ Both work in parallel.
492
+
493
+ Caller:
494
+
495
+ from moat.util import Loader
496
+ from functools import partial
497
+
498
+ @click.command(cls=partial(Loader,_util_sub_post='command'))
499
+ async def cmd()
500
+ print("I am the main program")
501
+
502
+ Sub-Command Usage (``main`` is defined for you), e.g. in ``command/subcmd.py``::
503
+
504
+ from moat.util import Loader
505
+ from functools import partial
506
+
507
+ @main.command / group()
508
+ async def cmd(self):
509
+ print("I am", self.name) # prints "subcmd"
510
+ """
511
+
512
+ # ruff:noqa:SLF001
513
+
514
+ def __init__(
515
+ self,
516
+ *,
517
+ _util_sub_pre=None,
518
+ _util_sub_post=None,
519
+ _util_ext_pre=None,
520
+ _util_ext_post=None,
521
+ **kw,
522
+ ):
523
+ logger.debug(
524
+ "* Load: %s.*.%s / %s.*.%s",
525
+ _util_sub_pre,
526
+ _util_sub_post,
527
+ _util_ext_pre,
528
+ _util_ext_post,
529
+ )
530
+ if _util_sub_pre is not None:
531
+ self._util_sub_pre = _util_sub_pre
532
+ if _util_sub_post is not None:
533
+ self._util_sub_post = _util_sub_post
534
+ if _util_ext_pre is not None:
535
+ self._util_ext_pre = _util_ext_pre
536
+ if _util_ext_post is not None:
537
+ self._util_ext_post = _util_ext_post
538
+ super().__init__(**kw)
539
+
540
+ def get_sub_ext(self, ctx):
541
+ """Fetch extension variables"""
542
+ sub_pre = getattr(
543
+ # pylint: disable=protected-access
544
+ self,
545
+ "_util_sub_pre",
546
+ ctx.obj._util_sub_pre,
547
+ )
548
+ sub_post = getattr(
549
+ # pylint: disable=protected-access
550
+ self,
551
+ "_util_sub_post",
552
+ ctx.obj._util_sub_post,
553
+ )
554
+ ext_pre = getattr(
555
+ # pylint: disable=protected-access
556
+ self,
557
+ "_util_ext_pre",
558
+ ctx.obj._util_ext_pre,
559
+ )
560
+ ext_post = getattr(
561
+ # pylint: disable=protected-access
562
+ self,
563
+ "_util_ext_post",
564
+ ctx.obj._util_ext_post,
565
+ )
566
+
567
+ if sub_pre is None:
568
+ sub_post = None
569
+ elif sub_post is None:
570
+ sub_pre = ("cli",)
571
+ elif isinstance(sub_post, str):
572
+ sub_post = sub_post.split(".")
573
+
574
+ if ext_pre is None:
575
+ ext_post = None
576
+ elif ext_post is None:
577
+ ext_pre = None
578
+ elif isinstance(ext_post, str):
579
+ ext_post = ext_post.split(".")
580
+ if len(ext_post) == 1:
581
+ ext_post.append("cli")
582
+
583
+ return sub_pre, sub_post, ext_pre, ext_post
584
+
585
+ def list_commands(self, ctx):
586
+ "show subpackages"
587
+ rv = super().list_commands(ctx)
588
+ sub_pre, sub_post, ext_pre, ext_post = self.get_sub_ext(ctx)
589
+ logger.debug("* List: %s.*.%s / %s.*.%s", sub_pre, sub_post, ext_pre, ext_post)
590
+
591
+ if sub_pre:
592
+ logger.debug("Adding sub %s", sub_pre)
593
+ for _finder, name, _ispkg in _namespaces(sub_pre):
594
+ # ruff:noqa:PLW2901 # var overwritten
595
+ name = name.rsplit(".", 1)[1]
596
+ if name[0] == "_":
597
+ continue
598
+ if load_ext(sub_pre, name, *sub_post, err=ctx.obj.debug_loader):
599
+ rv.append(name)
600
+
601
+ if ext_pre:
602
+ logger.debug("Adding ext %s", ext_pre)
603
+ for n, _ in list_ext(ext_pre):
604
+ if load_ext(ext_pre, n, *ext_post, err=ctx.obj.debug_loader):
605
+ rv.append(n)
606
+ rv.sort()
607
+ logger.debug("List: %r", rv)
608
+ return rv
609
+
610
+ def get_command(self, ctx, cmd_name):
611
+ "add subpackages"
612
+ command = super().get_command(ctx, cmd_name)
613
+
614
+ sub_pre, sub_post, ext_pre, ext_post = self.get_sub_ext(ctx)
615
+
616
+ if command is None and ext_pre is not None:
617
+ command = load_ext(ext_pre, cmd_name, *ext_post)
618
+ if command is not None:
619
+ CFG.with_(f"{ext_pre}.{cmd_name}")
620
+
621
+ if command is None:
622
+ if sub_pre is None or sub_pre is NotGiven:
623
+ return None
624
+ if sub_post is None or sub_post is NotGiven:
625
+ pass
626
+ else:
627
+ command = load_ext(sub_pre, cmd_name, *sub_post)
628
+ if command is not None:
629
+ CFG.with_(f"{sub_pre}.{cmd_name}")
630
+
631
+ if command is None:
632
+ # raise click.UsageError(f"No such subcommand: {cmd_name}")
633
+ return None
634
+ command.__name__ = command.name = cmd_name
635
+ return command
636
+
637
+
638
+ class MainLoader(Loader):
639
+ """
640
+ A special loader that runs the main setup code even if there's a
641
+ subcommand with "--help".
642
+ """
643
+
644
+ async def invoke(self, ctx):
645
+ if not getattr(ctx, "_moat_invoked", False):
646
+ await ctx.invoke(self.callback, **ctx.params)
647
+ return await super().invoke(ctx)
648
+
649
+
650
+ #
651
+ # There are two ways this can start up.
652
+ # (a) `main_` is the "real" main function. It sets up the Click environment and then
653
+ # starts anyio and runs the function body, which calls `wrap_main`
654
+ # synchronously to set up our object.
655
+ #
656
+ # (b) `wrap_main` is used as a wrapper, used mainly for testing. It sets up the context
657
+ # and then returns "main_.main()", which is an awaitable, thus
658
+ # `wrap_main` acts as an async function.
659
+
660
+
661
+ @load_subgroup(
662
+ cls=MainLoader,
663
+ add_help_option=False,
664
+ invoke_without_command=True,
665
+ ) # , __file__, "command"))
666
+ @click.option("-V", "--verbose", count=True, help="Be more verbose. Can be used multiple times.")
667
+ @click.option("-L", "--debug-loader", is_flag=True, help="Debug submodule loading.")
668
+ @click.option("-Q", "--quiet", count=True, help="Be less verbose. Opposite of '--verbose'.")
669
+ @click.option("-D", "--debug", count=True, help="Enable debug speed-ups (smaller keys etc).")
670
+ @click.option(
671
+ "-l",
672
+ "--log",
673
+ multiple=True,
674
+ help="Adjust log level. Example: '--log asyncactor=DEBUG'.",
675
+ )
676
+ @click.option(
677
+ "-c",
678
+ "--cfg",
679
+ "cfg_files",
680
+ type=click.Path("r"),
681
+ default=None,
682
+ help="Configuration file (YAML).",
683
+ multiple=True,
684
+ )
685
+ @click.option(
686
+ "-h",
687
+ "-?",
688
+ "--help",
689
+ is_flag=True,
690
+ help="Show help. Subcommands only understand '--help'.",
691
+ )
692
+ @attr_args(par_name="Config item")
693
+ @click.pass_context
694
+ async def main_(ctx, verbose, quiet, help=False, **kv): # pylint: disable=redefined-builtin
695
+ """
696
+ This is the main command. (You might want to override this text.)
697
+
698
+ You need to add a subcommand for this to do anything.
699
+ """
700
+ ctx.allow_interspersed_args = True
701
+
702
+ # The above `MainLoader.invoke` call causes this code to be called
703
+ # twice instead of never.
704
+ if hasattr(ctx, "_moat_invoked"):
705
+ return
706
+ ctx._moat_invoked = True # pylint: disable=protected-access
707
+ cfg = current_cfg.get()
708
+ if cfg is not None:
709
+ kv["cfg"] = cfg
710
+ wrap_main(ctx=ctx, verbose=max(0, 1 + verbose - quiet), **kv)
711
+ try:
712
+ main = ctx.obj.moat.main_cmd
713
+ except AttributeError:
714
+ main = None
715
+ if help or (
716
+ main is None
717
+ and ctx.invoked_subcommand is None
718
+ and not ctx.args
719
+ and not ctx._protected_args
720
+ ):
721
+ print(ctx.get_help())
722
+ await ctx.aexit()
723
+ elif main is not None:
724
+ await main(ctx)
725
+
726
+
727
+ def wrap_main( # pylint: disable=redefined-builtin,inconsistent-return-statements
728
+ main=main_,
729
+ *,
730
+ set_=(),
731
+ vars_=(),
732
+ eval_=(),
733
+ path_=(),
734
+ proxy_=(),
735
+ name=None,
736
+ sub_pre=None,
737
+ sub_post=None,
738
+ ext_pre=None,
739
+ ext_post=None,
740
+ ext_name: str | None = None,
741
+ cfg: attrdict | None | Literal[False] = None,
742
+ cfg_files: str | Sequence[str] = (),
743
+ cfg_load_all: bool | None = True,
744
+ args=None,
745
+ wrap=False,
746
+ verbose=1,
747
+ debug=0,
748
+ debug_loader=False,
749
+ log=(),
750
+ ctx=None,
751
+ help=None,
752
+ ) -> Awaitable:
753
+ """
754
+ The main command entry point, when testing.
755
+
756
+ main: special main function, defaults to moat.run.main_
757
+ name: command name, defaults to {main}'s toplevel module name.
758
+ {sub,ext}_{pre,post}: commands to load in submodules or extensions.
759
+
760
+ cfg: additional configuration to preconfigure
761
+ cfg_files: additional configuration file(s) to load
762
+ cfg_load_all: Flag whether to load the default config file(s): True=all,
763
+ False=first found, None=No.
764
+
765
+ wrap: Flag: this is a subcommand. Don't set up logging, return the awaitable.
766
+ args: Argument list if called from a test, `None` otherwise.
767
+ help: Help text of your code.
768
+
769
+ Internal extensions are loaded as ``{sub_pre}.*.{sub_post}``.
770
+ External extensions are loaded as ``{ext_pre}.*.{ext_post}``.
771
+
772
+ cfg.moat may contain values for {sub,ext}_{pre,post}.
773
+ """
774
+
775
+ obj = getattr(ctx, "obj", None)
776
+ if obj is None:
777
+ obj = attrdict()
778
+
779
+ opts = obj.get("moat", None)
780
+ if opts is None:
781
+ obj.moat = opts = attrdict()
782
+
783
+ if sub_pre is None:
784
+ sub_pre = opts.get("sub_pre", None)
785
+ else:
786
+ opts["sub_pre"] = sub_pre
787
+
788
+ if sub_post is None:
789
+ sub_post = opts.get("sub_post", None)
790
+ else:
791
+ opts["sub_post"] = sub_post
792
+
793
+ if ext_pre is None:
794
+ ext_pre = opts.get("ext_pre", None)
795
+ else:
796
+ opts["ext_pre"] = ext_pre
797
+
798
+ if ext_post is None:
799
+ ext_post = opts.get("ext_post", None)
800
+ else:
801
+ opts["ext_post"] = ext_post
802
+
803
+ # Name defaults to "moat", of course ;-)
804
+ if name is None:
805
+ name = opts.setdefault("name", "moat")
806
+ else:
807
+ opts["name"] = name
808
+
809
+ if sub_pre is True:
810
+ "discover from caller"
811
+ import inspect # pylint: disable=import-outside-toplevel # noqa: PLC0415
812
+
813
+ sub_pre = inspect.currentframe().f_back.f_globals["__package__"]
814
+ elif sub_pre is None:
815
+ sub_pre = name
816
+ if sub_post is None:
817
+ sub_post = "_main.cli"
818
+
819
+ if main is None:
820
+ if help is not None:
821
+ raise RuntimeError("You can't set the help text this way")
822
+ else:
823
+ main.context_settings["obj"] = obj
824
+ if help is not None:
825
+ main.help = help
826
+
827
+ obj._util_sub_pre = sub_pre # pylint: disable=protected-access
828
+ obj._util_sub_post = sub_post # pylint: disable=protected-access
829
+ obj._util_ext_pre = ext_pre # pylint: disable=protected-access
830
+ obj._util_ext_post = ext_post # pylint: disable=protected-access
831
+
832
+ if not isinstance(cfg, CfgStore):
833
+ cfg = CfgStore(name, preload=cfg, load_all=cfg_load_all, ext=ext_name)
834
+ CFG.set_real_cfg(cfg)
835
+
836
+ if isinstance(cfg_files, str):
837
+ cfg_files = (cfg_files,)
838
+ for fn in cfg_files:
839
+ cfg.add(fn)
840
+
841
+ # our toplevel config file(s)
842
+ CFG.with_("moat")
843
+ if name != "moat":
844
+ CFG.with_(name)
845
+
846
+ obj.debug = verbose
847
+ obj.DEBUG = debug
848
+
849
+ if wrap:
850
+ pass
851
+ elif hasattr(logging.root, "_MoaT"):
852
+ logging.debug("Logging already set up") # noqa:LOG015
853
+ else:
854
+ # Configure logging. This is a somewhat arcane art.
855
+ cfg.mod(
856
+ P("logging.root.level"),
857
+ "DEBUG"
858
+ if verbose > 2
859
+ else "INFO"
860
+ if verbose > 1
861
+ else "WARNING"
862
+ if verbose
863
+ else "ERROR",
864
+ )
865
+ for k in log:
866
+ k, v = k.split("=")
867
+ cfg.mod(P("logging.loggers") / k / "level", v)
868
+ logging.config.dictConfig(cfg.logging)
869
+
870
+ process_args(
871
+ set_=set_,
872
+ vars_=vars_,
873
+ eval_=eval_,
874
+ path_=path_,
875
+ proxy_=proxy_,
876
+ )
877
+
878
+ try:
879
+ in_test = cfg.env.in_test
880
+ except AttributeError:
881
+ pass
882
+ else:
883
+ in_test(cfg)
884
+
885
+ if not wrap and not hasattr(logging.root, "_MoaT"):
886
+ logging.basicConfig = _no_config
887
+ logging.config.dictConfig = _no_config
888
+ logging.config.fileConfig = _no_config
889
+
890
+ logging.captureWarnings(verbose > 0)
891
+ logger.disabled = False
892
+ if debug_loader:
893
+ logger.level = logging.DEBUG
894
+ for p in sys.path:
895
+ logger.debug("Path: %s", p)
896
+ logging.root._MoaT = True
897
+
898
+ obj.logger = logging.getLogger(name)
899
+ obj.debug_loader = debug_loader
900
+ obj.cfg = cfg.result[name]
901
+ try:
902
+ obj.stdout = cfg.result.env.stdout
903
+ except AttributeError:
904
+ obj.stdout = sys.stdout
905
+
906
+ try:
907
+ # pylint: disable=no-value-for-parameter,unexpected-keyword-arg
908
+ # NOTE this return an awaitable
909
+ if ctx is not None:
910
+ ctx.obj = obj
911
+ elif main is not None:
912
+ if wrap:
913
+ main = main.main
914
+ with ungroup():
915
+ return main(args=args, standalone_mode=False, obj=obj)
916
+
917
+ except click.exceptions.MissingParameter as exc:
918
+ print(
919
+ f"You need to provide an argument {exc.param.name.upper()!r}.\n",
920
+ file=sys.stderr,
921
+ )
922
+ print(exc.cmd.get_help(exc.ctx), file=sys.stderr)
923
+ sys.exit(2)
924
+ except click.exceptions.UsageError as exc:
925
+ try:
926
+ s = str(exc)
927
+ except TypeError:
928
+ logger.exception("??", exc_info=exc)
929
+ else:
930
+ print(s, file=sys.stderr)
931
+ sys.exit(2)
932
+ except click.exceptions.Abort:
933
+ print("Aborted.", file=sys.stderr)
934
+
935
+
936
+ def _ng(type_):
937
+ @wraps(type_)
938
+ def gen(data):
939
+ if data is NotGiven:
940
+ return data
941
+ return type_(data)
942
+
943
+ return gen
944
+
945
+
946
+ def option_ng(*a, type=str, **kw): # noqa: A002, D103
947
+ return click.option(*a, **kw, type=_ng(type), default=NotGiven)
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: moat-lib-run
3
+ Version: 0.1.1
4
+ Summary: Main command entry point infrastructure for MoaT applications
5
+ Maintainer-email: Matthias Urlichs <matthias@urlichs.de>
6
+ Project-URL: homepage, https://m-o-a-t.org
7
+ Project-URL: repository, https://github.com/M-o-a-T/moat
8
+ Keywords: MoaT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Framework :: AnyIO
11
+ Classifier: Framework :: Trio
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Intended Audience :: Developers
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE.txt
18
+ Requires-Dist: anyio~=4.0
19
+ Requires-Dist: moat-util~=0.61.1
20
+ Requires-Dist: asyncclick~=8.0
21
+ Requires-Dist: simpleeval~=1.0
22
+ Requires-Dist: moat-lib-config~=0.1.0
23
+ Requires-Dist: moat-lib-proxy~=0.1.0
24
+ Dynamic: license-file
25
+
26
+ # moat-lib-run
27
+
28
+ % start main
29
+ % start synopsis
30
+
31
+ Main command entry point infrastructure for MoaT applications.
32
+
33
+ % end synopsis
34
+
35
+ This module provides the infrastructure for building command-line interfaces
36
+ for MoaT applications. It includes:
37
+
38
+ - Command-line argument parsing with Click integration
39
+ - Subcommand loading from internal modules and extensions
40
+ - Configuration file handling
41
+ - Logging setup
42
+ - Main entry point wrappers for testing
43
+
44
+ ## Usage
45
+
46
+ ### Basic command setup
47
+
48
+ ```python
49
+ from moat.lib.run import main_, wrap_main
50
+
51
+ @main_.command()
52
+ async def my_command(ctx):
53
+ """A simple command"""
54
+ print("Hello from my command!")
55
+ ```
56
+
57
+ ### Loading subcommands
58
+
59
+ Use `load_subgroup` to create command groups that automatically load subcommands:
60
+
61
+ ```python
62
+ from moat.lib.run import load_subgroup
63
+ import asyncclick as click
64
+
65
+ @load_subgroup(prefix="myapp.commands")
66
+ @click.pass_context
67
+ async def cli(ctx):
68
+ """Main command group"""
69
+ pass
70
+ ```
71
+
72
+ ### Processing command-line arguments
73
+
74
+ The `attr_args` decorator and `process_args` function provide flexible
75
+ argument handling:
76
+
77
+ ```python
78
+ from moat.lib.run import attr_args, process_args
79
+
80
+ @main_.command()
81
+ @attr_args(with_path=True)
82
+ async def configure(**kw):
83
+ """Configure the application"""
84
+ config = process_args({}, **kw)
85
+ # config now contains parsed arguments
86
+ ```
87
+
88
+ ## Key Functions
89
+
90
+ - `main_`: The default main command handler
91
+ - `wrap_main`: Wrapper for the main command, useful for testing
92
+ - `load_subgroup`: Decorator to create command groups with automatic subcommand loading
93
+ - `attr_args`: Decorator for adding flexible argument handling to commands
94
+ - `process_args`: Function to process command-line arguments into configuration
95
+ - `Loader`: Click group class that loads commands from submodules and extensions
96
+
97
+ % end main
@@ -0,0 +1,14 @@
1
+ LICENSE.txt
2
+ Makefile
3
+ README.md
4
+ pyproject.toml
5
+ debian/.gitignore
6
+ debian/changelog
7
+ debian/control
8
+ debian/rules
9
+ src/moat/lib/run/__init__.py
10
+ src/moat_lib_run.egg-info/PKG-INFO
11
+ src/moat_lib_run.egg-info/SOURCES.txt
12
+ src/moat_lib_run.egg-info/dependency_links.txt
13
+ src/moat_lib_run.egg-info/requires.txt
14
+ src/moat_lib_run.egg-info/top_level.txt
@@ -0,0 +1,6 @@
1
+ anyio~=4.0
2
+ asyncclick~=8.0
3
+ moat-lib-config~=0.1.0
4
+ moat-lib-proxy~=0.1.0
5
+ moat-util~=0.61.1
6
+ simpleeval~=1.0