sigil-cli 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.
- sigil/__init__.py +25 -0
- sigil/entrypoint.py +25 -0
- sigil/models/__init__.py +12 -0
- sigil/models/argparser.py +11 -0
- sigil/models/argument.py +22 -0
- sigil/models/command.py +28 -0
- sigil/models/script.py +8 -0
- sigil/stages/__init__.py +11 -0
- sigil/stages/builder.py +78 -0
- sigil/stages/resolver.py +48 -0
- sigil/stages/script_loader.py +55 -0
- sigil/stages/yaml_reader.py +86 -0
- sigil_cli-0.1.0.dist-info/METADATA +212 -0
- sigil_cli-0.1.0.dist-info/RECORD +17 -0
- sigil_cli-0.1.0.dist-info/WHEEL +5 -0
- sigil_cli-0.1.0.dist-info/licenses/LICENSE +19 -0
- sigil_cli-0.1.0.dist-info/top_level.txt +1 -0
sigil/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from .entrypoint import run_from_config
|
|
2
|
+
from .models import (
|
|
3
|
+
Argument,
|
|
4
|
+
LibArgParser,
|
|
5
|
+
ParserConfig,
|
|
6
|
+
SubcommandModule,
|
|
7
|
+
)
|
|
8
|
+
from .stages import (
|
|
9
|
+
Builder,
|
|
10
|
+
Resolver,
|
|
11
|
+
ScriptLoader,
|
|
12
|
+
YamlReader,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = (
|
|
16
|
+
run_from_config,
|
|
17
|
+
Argument,
|
|
18
|
+
LibArgParser,
|
|
19
|
+
ParserConfig,
|
|
20
|
+
SubcommandModule,
|
|
21
|
+
YamlReader,
|
|
22
|
+
Resolver,
|
|
23
|
+
Builder,
|
|
24
|
+
ScriptLoader,
|
|
25
|
+
)
|
sigil/entrypoint.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from .stages import Builder, Resolver, ScriptLoader, YamlReader
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# default to yamlloader, use whatever datastore you feel like
|
|
7
|
+
def run_from_config(config_root: str, loader_class = YamlReader):
|
|
8
|
+
raw_data = loader_class.load(config_root)
|
|
9
|
+
resolved = Resolver.resolve_inheritance(raw_data)
|
|
10
|
+
parser = Builder.build(resolved)
|
|
11
|
+
args, other_args = parser.parse_known_args()
|
|
12
|
+
scripts = ScriptLoader.get_scripts(args, 'root', parser, {'root': resolved})
|
|
13
|
+
|
|
14
|
+
execution_context: dict[str, Any] = {'other_args': other_args}
|
|
15
|
+
for script in scripts:
|
|
16
|
+
try:
|
|
17
|
+
module = ScriptLoader.import_module(
|
|
18
|
+
config_root,
|
|
19
|
+
resolved.script_dir,
|
|
20
|
+
script
|
|
21
|
+
)
|
|
22
|
+
except Exception as e:
|
|
23
|
+
print(f'failed to load script "{script}"\n\t{e}')
|
|
24
|
+
return
|
|
25
|
+
module.run(args, execution_context)
|
sigil/models/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
# because setting default subommands is kind of a pain in std. argparse
|
|
4
|
+
|
|
5
|
+
class LibArgParser(argparse.ArgumentParser):
|
|
6
|
+
def set_default_subparser(self, name, args=None, positional_args=0):
|
|
7
|
+
for action in self._actions:
|
|
8
|
+
if not isinstance(action, argparse._SubParsersAction):
|
|
9
|
+
continue
|
|
10
|
+
if name in action._name_parser_map.keys():
|
|
11
|
+
action.default=name
|
sigil/models/argument.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Argument:
|
|
7
|
+
name: list[str]
|
|
8
|
+
required: bool = False
|
|
9
|
+
default: bool = False
|
|
10
|
+
help: str | None = None
|
|
11
|
+
action: str | None = None
|
|
12
|
+
# anything we haven't explicitly defined.. yet
|
|
13
|
+
kw: dict[str, Any] = field(default_factory=dict)
|
|
14
|
+
|
|
15
|
+
# Source - https://stackoverflow.com/a/74459763
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def factory(cls, **kwargs: dict):
|
|
19
|
+
name = kwargs.get('name')
|
|
20
|
+
if isinstance(name, str):
|
|
21
|
+
kwargs['name'] = [name]
|
|
22
|
+
return cls(**{k: kwargs[k] for k in kwargs if k in cls.__match_args__})
|
sigil/models/command.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
|
|
4
|
+
from .argument import Argument
|
|
5
|
+
|
|
6
|
+
_logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ParserConfig:
|
|
10
|
+
name: str
|
|
11
|
+
script_dir: str | None = None # only for root
|
|
12
|
+
help: str | None = None
|
|
13
|
+
args: list[Argument] = field(default_factory=list)
|
|
14
|
+
script: str | None = None
|
|
15
|
+
parent: str | None = None
|
|
16
|
+
default: bool = False
|
|
17
|
+
load: bool = True
|
|
18
|
+
subparsers: 'dict[str, ParserConfig]' = field(default_factory=dict)
|
|
19
|
+
|
|
20
|
+
@classmethod
|
|
21
|
+
def factory(cls, **kwargs):
|
|
22
|
+
for k in kwargs:
|
|
23
|
+
if k not in cls.__match_args__:
|
|
24
|
+
_logger.warning(
|
|
25
|
+
f'found unknown key "{k}" in parser '
|
|
26
|
+
f'"{kwargs.get("name")}", ignoring'
|
|
27
|
+
)
|
|
28
|
+
return cls(**{k: kwargs[k] for k in kwargs if k in cls.__match_args__})
|
sigil/models/script.py
ADDED
sigil/stages/__init__.py
ADDED
sigil/stages/builder.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from sigil.models import Argument, LibArgParser, ParserConfig
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
import argcomplete
|
|
5
|
+
USE_ARGCOMPLETE = True
|
|
6
|
+
except Exception:
|
|
7
|
+
USE_ARGCOMPLETE=False
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
_logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
class Builder:
|
|
14
|
+
# also in place modification, returning just causes more problems anyway
|
|
15
|
+
@classmethod
|
|
16
|
+
def _build_subparsers(
|
|
17
|
+
cls,
|
|
18
|
+
base_parser: LibArgParser,
|
|
19
|
+
parser_data: ParserConfig
|
|
20
|
+
) -> None:
|
|
21
|
+
if parser_data.subparsers:
|
|
22
|
+
argparse_group = base_parser.add_subparsers(
|
|
23
|
+
dest=parser_data.name,
|
|
24
|
+
title='subcommands',
|
|
25
|
+
)
|
|
26
|
+
last_default = None # find the last default
|
|
27
|
+
for _, subcommand in parser_data.subparsers.items():
|
|
28
|
+
subcommand_name = subcommand.name
|
|
29
|
+
subcmd_help = subcommand.help
|
|
30
|
+
argparser = argparse_group.add_parser(subcommand_name, help=subcmd_help)
|
|
31
|
+
for arg in subcommand.args:
|
|
32
|
+
cls.attach_argument(argparser, arg)
|
|
33
|
+
if subcommand.default:
|
|
34
|
+
if last_default:
|
|
35
|
+
_logger.warning(
|
|
36
|
+
f'last default {last_default} overridden '
|
|
37
|
+
f'with {subcommand.name}'
|
|
38
|
+
)
|
|
39
|
+
last_default = subcommand.name
|
|
40
|
+
if subcommand.subparsers:
|
|
41
|
+
cls._build_subparsers(argparser, subcommand)
|
|
42
|
+
if last_default:
|
|
43
|
+
base_parser.set_default_subparser(last_default)
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def attach_argument(
|
|
47
|
+
cls,
|
|
48
|
+
parser: LibArgParser,
|
|
49
|
+
argument_config: Argument
|
|
50
|
+
) -> None:
|
|
51
|
+
conditional_args: dict[str, Any] = {}
|
|
52
|
+
if any(x.startswith('-') for x in argument_config.name):
|
|
53
|
+
# if no '--name' or '-n' exists it's required by default
|
|
54
|
+
conditional_args['required']=argument_config.required
|
|
55
|
+
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
*argument_config.name,
|
|
58
|
+
help=argument_config.help,
|
|
59
|
+
action=argument_config.action,
|
|
60
|
+
default=argument_config.default,
|
|
61
|
+
**(argument_config.kw | conditional_args)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def build(
|
|
66
|
+
cls,
|
|
67
|
+
root_data: ParserConfig
|
|
68
|
+
) -> LibArgParser:
|
|
69
|
+
root_name = root_data.name
|
|
70
|
+
root_parser = LibArgParser(prog=root_name)
|
|
71
|
+
for arg in root_data.args or []:
|
|
72
|
+
cls.attach_argument(root_parser, arg)
|
|
73
|
+
|
|
74
|
+
cls._build_subparsers(root_parser, root_data)
|
|
75
|
+
if USE_ARGCOMPLETE:
|
|
76
|
+
argcomplete.autocomplete(root_parser)
|
|
77
|
+
|
|
78
|
+
return root_parser
|
sigil/stages/resolver.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from sigil.models import ParserConfig
|
|
4
|
+
|
|
5
|
+
_logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
class Resolver:
|
|
8
|
+
@classmethod
|
|
9
|
+
def resolve_inheritance(
|
|
10
|
+
cls,
|
|
11
|
+
raw_config: dict[str, ParserConfig],
|
|
12
|
+
) -> ParserConfig:
|
|
13
|
+
# build the tree starting at "root", then attack items until
|
|
14
|
+
# items can no longer be attached
|
|
15
|
+
# note that this dictionary contains *references* to every resolved
|
|
16
|
+
# object, therefore editing an object at the root of this dictionary
|
|
17
|
+
# also edits it at an arbitrary nested point (because thats how references work)
|
|
18
|
+
# in the end we will just return "root"
|
|
19
|
+
resolved: dict[str, ParserConfig] = {}
|
|
20
|
+
changed = True
|
|
21
|
+
|
|
22
|
+
while changed:
|
|
23
|
+
changed = False # stays false unless something is attached
|
|
24
|
+
for internal_id, config_item in raw_config.items():
|
|
25
|
+
if internal_id in resolved: # already resolved, "del" is dangerous
|
|
26
|
+
continue
|
|
27
|
+
if not config_item.load:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
if internal_id == 'root':
|
|
31
|
+
changed=True
|
|
32
|
+
resolved[internal_id] = config_item
|
|
33
|
+
break
|
|
34
|
+
|
|
35
|
+
parent = config_item.parent
|
|
36
|
+
|
|
37
|
+
if parent and parent in resolved:
|
|
38
|
+
changed = True # only run a new pass if something was attached
|
|
39
|
+
subparsers = resolved[parent].subparsers
|
|
40
|
+
subparsers[config_item.name]= config_item
|
|
41
|
+
resolved[parent].subparsers = subparsers
|
|
42
|
+
resolved[internal_id] = config_item
|
|
43
|
+
|
|
44
|
+
# log unattached items:
|
|
45
|
+
for item in (raw_config.keys() - resolved.keys()):
|
|
46
|
+
_logger.warning(f'failed to resolve command path for {item}')
|
|
47
|
+
|
|
48
|
+
return resolved['root'] # one "root" is required
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import sys
|
|
3
|
+
from argparse import Namespace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from sigil.models import LibArgParser, ParserConfig, SubcommandModule
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ScriptLoader:
|
|
10
|
+
@classmethod
|
|
11
|
+
def import_module(cls, config_root, path:str, module_name: str) -> SubcommandModule:
|
|
12
|
+
config_root_path = Path(config_root)
|
|
13
|
+
file_path = str(config_root_path/path/f'{module_name}.py')
|
|
14
|
+
|
|
15
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
16
|
+
module = importlib.util.module_from_spec(spec)
|
|
17
|
+
spec.loader.exec_module(module)
|
|
18
|
+
return module
|
|
19
|
+
|
|
20
|
+
# method is somewhat messy, i'm aware
|
|
21
|
+
@classmethod
|
|
22
|
+
def get_scripts(
|
|
23
|
+
cls,
|
|
24
|
+
args: Namespace,
|
|
25
|
+
target: str,
|
|
26
|
+
root_parser: LibArgParser,
|
|
27
|
+
data: ParserConfig
|
|
28
|
+
) -> list[SubcommandModule]:
|
|
29
|
+
found_scripts = []
|
|
30
|
+
|
|
31
|
+
target_parser: ParserConfig = data[target]
|
|
32
|
+
subparsers = target_parser.subparsers
|
|
33
|
+
script = target_parser.script
|
|
34
|
+
|
|
35
|
+
if script:
|
|
36
|
+
found_scripts.append(script)
|
|
37
|
+
if not subparsers:
|
|
38
|
+
return found_scripts
|
|
39
|
+
|
|
40
|
+
# get the name of the next subcommand
|
|
41
|
+
if target != 'root':
|
|
42
|
+
next_value = getattr(args, target)
|
|
43
|
+
else:
|
|
44
|
+
next_value = getattr(args, target_parser.name)
|
|
45
|
+
|
|
46
|
+
if next_value is None:
|
|
47
|
+
# if subcommands exist but no subcommand is used print help instead
|
|
48
|
+
root_parser.parse_args(sys.argv[1:] + ['--help'])
|
|
49
|
+
sys.exit(2) # nothing to do here
|
|
50
|
+
return found_scripts + cls.get_scripts(
|
|
51
|
+
args,
|
|
52
|
+
next_value,
|
|
53
|
+
root_parser,
|
|
54
|
+
subparsers,
|
|
55
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from sigil.models import Argument, ParserConfig
|
|
8
|
+
|
|
9
|
+
_logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# This class converts raw data to dataclasses,
|
|
12
|
+
# however swap it out if you want to use a different form of IO
|
|
13
|
+
class YamlReader:
|
|
14
|
+
@classmethod
|
|
15
|
+
def handle_duplicates(
|
|
16
|
+
cls,
|
|
17
|
+
loaded_entry: dict[str],
|
|
18
|
+
loaded_config: dict[str],
|
|
19
|
+
entry: str
|
|
20
|
+
):
|
|
21
|
+
# warning: in place mutation if you decide to make changes to loaded config
|
|
22
|
+
if duplicates := (loaded_entry.keys() & loaded_config.keys()):
|
|
23
|
+
for duplicate in duplicates:
|
|
24
|
+
_logger.warning(f"{entry}[{duplicate}] already defined, ignoring")
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def load(
|
|
28
|
+
cls,
|
|
29
|
+
config_root: str,
|
|
30
|
+
manifest_file='manifest.yml'
|
|
31
|
+
) -> dict[str, ParserConfig]:
|
|
32
|
+
config_root_path = Path(config_root)
|
|
33
|
+
manifest = cls.read_file(config_root_path/manifest_file)
|
|
34
|
+
loaded_config = {}
|
|
35
|
+
|
|
36
|
+
for entry in manifest:
|
|
37
|
+
loaded_entry = cls.read_file(config_root_path/entry)
|
|
38
|
+
if not loaded_entry:
|
|
39
|
+
continue
|
|
40
|
+
loaded_entry = {
|
|
41
|
+
k:v
|
|
42
|
+
for k, v in loaded_entry.items()
|
|
43
|
+
if not v.get('load_ignore')
|
|
44
|
+
}
|
|
45
|
+
cls.handle_duplicates(loaded_entry, loaded_config, entry)
|
|
46
|
+
cls.convert_args(loaded_entry)
|
|
47
|
+
# override loaded entry duplicates by already found
|
|
48
|
+
loaded_config = loaded_entry | loaded_config
|
|
49
|
+
|
|
50
|
+
for k, v in loaded_config.items():
|
|
51
|
+
try:
|
|
52
|
+
loaded_config[k] = ParserConfig.factory(**v)
|
|
53
|
+
except Exception:
|
|
54
|
+
_logger.error(f'failed to parse config {k}, ignoring')
|
|
55
|
+
# pesky "dictionary changed size during iteration"
|
|
56
|
+
loaded_config = {
|
|
57
|
+
k: v
|
|
58
|
+
for k,v in loaded_config.items()
|
|
59
|
+
if isinstance(v, ParserConfig)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return loaded_config
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def convert_args(cls, loaded_entry: dict[str, dict[str, Any]]):
|
|
66
|
+
# while yes default factory this fixes the edge case where the key is defined
|
|
67
|
+
# but without values
|
|
68
|
+
for command_def in loaded_entry.values():
|
|
69
|
+
args = command_def.get('args') or []
|
|
70
|
+
build_args = []
|
|
71
|
+
for arg in args:
|
|
72
|
+
try:
|
|
73
|
+
build_args.append(Argument.factory(**arg))
|
|
74
|
+
except Exception: # any failure is skipped
|
|
75
|
+
_logger.error(f'invalid argument definition,\n{arg}\nskipping')
|
|
76
|
+
command_def['args'] = build_args
|
|
77
|
+
|
|
78
|
+
# actual IO, also adaptable
|
|
79
|
+
@classmethod
|
|
80
|
+
def read_file(cls, filename) -> dict:
|
|
81
|
+
try:
|
|
82
|
+
with open(filename) as f:
|
|
83
|
+
return yaml.load(f.read(), Loader=yaml.SafeLoader)
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
_logger.error(f'failed to load file {filename}, skipping')
|
|
86
|
+
return None
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sigil-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Configuration driven CLI builder with subcommands and script loading
|
|
5
|
+
Author-email: Kenzo Staelens <kenzostael@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/yourusername/sigil
|
|
8
|
+
Project-URL: Repository, https://github.com/yourusername/sigil
|
|
9
|
+
Project-URL: Issues, https://github.com/yourusername/sigil/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: pyyaml>=6.0
|
|
17
|
+
Provides-Extra: completion
|
|
18
|
+
Requires-Dist: argcomplete>=3.0; extra == "completion"
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# Sigil
|
|
26
|
+
|
|
27
|
+
> Declarative argparse, without the CLI boilerplate.
|
|
28
|
+
|
|
29
|
+
Sigil is a lightweight, declarative CLI framework for Python. Define your command tree in YAML (or any other format), and sigil builds the `argparse` parser on the fly. Complete with subcommands and dynamic script loading.
|
|
30
|
+
It plays nicely with `argcomplete` out of the box.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Features
|
|
35
|
+
|
|
36
|
+
- Declarative command hierarchies (parents, subparsers, defaults)
|
|
37
|
+
- Each command can point to a dynamically imported Python script
|
|
38
|
+
- `argcomplete` integration for tab‑completion
|
|
39
|
+
- Pluggable data sources – YAML is the default, but JSON, TOML, or a dict are trivial to swap in
|
|
40
|
+
- No boilerplate argparse code in your main logic
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
### Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install sigil-cli
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
or include argcomplete
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install sigil-cli[completion]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### 0. Recommended file structure
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
project_root/
|
|
60
|
+
├── mycli.py # drop‑in entry script (alias this)
|
|
61
|
+
├── manifest.yml # lists all YAML config files to load
|
|
62
|
+
├── yml/
|
|
63
|
+
│ ├── root.yml # root command definition
|
|
64
|
+
│ ├── root_run.yml # subcommand definition(s)
|
|
65
|
+
│ └── ...
|
|
66
|
+
└── scripts/
|
|
67
|
+
├── run.py # implements the 'run' command
|
|
68
|
+
└── ... # other scripts
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### 1. Entry script
|
|
72
|
+
|
|
73
|
+
Create `mycli.py`:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
#!/usr/bin/env python3
|
|
77
|
+
from pathlib import Path
|
|
78
|
+
from sigil import run_from_config
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
run_from_config(Path(__file__).parent)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 2. Configuration files
|
|
85
|
+
|
|
86
|
+
List all your YAML definitions in `manifest.yml`:
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
---
|
|
90
|
+
- root.yml
|
|
91
|
+
- root_run.yml
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Define the root command in `root.yml`:
|
|
95
|
+
|
|
96
|
+
```yaml
|
|
97
|
+
root:
|
|
98
|
+
name: mycli
|
|
99
|
+
script_dir: scripts
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Define a subcommand in `root_run.yml`:
|
|
103
|
+
|
|
104
|
+
```yaml
|
|
105
|
+
root_run:
|
|
106
|
+
name: run
|
|
107
|
+
parent: root
|
|
108
|
+
help: command utility to run containers
|
|
109
|
+
script: run
|
|
110
|
+
args:
|
|
111
|
+
- help: port to run, autoincrements from 8080
|
|
112
|
+
name:
|
|
113
|
+
- -p
|
|
114
|
+
- --port
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### 3. Write the script
|
|
118
|
+
|
|
119
|
+
Create `scripts/run.py`:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
import argparse
|
|
123
|
+
from typing import Any
|
|
124
|
+
|
|
125
|
+
def run(args: argparse.Namespace, ctx: dict[str, Any]) -> None:
|
|
126
|
+
port = getattr(args, "port", 8080)
|
|
127
|
+
port = find_next_free_port_logic(port)
|
|
128
|
+
print(f"Running container on port {port}")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### 4. Run it
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
chmod +x mycli.py
|
|
135
|
+
./mycli.py run --port 9000
|
|
136
|
+
# Running container on port 9000
|
|
137
|
+
|
|
138
|
+
./mycli.py run
|
|
139
|
+
# Running container on port 8080
|
|
140
|
+
|
|
141
|
+
./mycli.py run
|
|
142
|
+
# Running container on port 8081
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Configuration Reference
|
|
146
|
+
|
|
147
|
+
### Root
|
|
148
|
+
|
|
149
|
+
| Field | Description |
|
|
150
|
+
| --- | --- |
|
|
151
|
+
| `name` | Program name (used as `prog` in argparse) |
|
|
152
|
+
| `script_dir` | Directory (relative to the config root) where command scripts are located |
|
|
153
|
+
|
|
154
|
+
### Command
|
|
155
|
+
|
|
156
|
+
| Field | Description |
|
|
157
|
+
| --- | --- |
|
|
158
|
+
| `name` | Subcommand name |
|
|
159
|
+
| `parent` | Parent command (must exist elsewhere in a config) |
|
|
160
|
+
| `help` | Help text for this subcommand |
|
|
161
|
+
| `script` | Python module name (without `.py`) inside `script_dir` or as absolute path |
|
|
162
|
+
| `args` | List of argument definitions (see below) |
|
|
163
|
+
| `default` | If `true`, this subcommand is used when no subcommand is given |
|
|
164
|
+
|
|
165
|
+
### Argument
|
|
166
|
+
|
|
167
|
+
Each argument entry can be a plain dict which maps 1-to-1 with argparse `add_argument`, except name which maps it's `*args`
|
|
168
|
+
|
|
169
|
+
```yaml
|
|
170
|
+
- name: ["-p", "--port"] # or a single string, e.g. "positional"
|
|
171
|
+
required: false
|
|
172
|
+
default: 8069
|
|
173
|
+
help: "port number"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The `name` field can be `--flag` for flags or a string for positional arguments.
|
|
177
|
+
Both literal string and list of strings are supported.
|
|
178
|
+
|
|
179
|
+
## Tab‑Completion (argcomplete)
|
|
180
|
+
|
|
181
|
+
Argus registers itself with `argcomplete` automatically if available on your system.
|
|
182
|
+
To enable completion, install [argcomplete](https://github.com/kislyuk/argcomplete) and activate it for your entry script:
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
pip install argcomplete
|
|
186
|
+
activate-global-python-argcomplete
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Then run your script and hit <kbd>Tab</kbd> – subcommands and flags will complete.
|
|
190
|
+
|
|
191
|
+
## Pluggable Backends
|
|
192
|
+
|
|
193
|
+
Sigil uses yaml by default, but you can supply any loader that returns a `dict[str, ParserConfig]`:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from sigil import run_from_config
|
|
197
|
+
|
|
198
|
+
# Use JSON instead:
|
|
199
|
+
class JsonReader:
|
|
200
|
+
@classmethod
|
|
201
|
+
def load(cls, config_root):
|
|
202
|
+
# read *.json, parse, convert to dict of ParserConfig
|
|
203
|
+
...
|
|
204
|
+
|
|
205
|
+
run_from_config("/path/to/config", loader_class=JsonReader)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
You can also pass a pre‑loaded dictionary directly by wrapping it:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
run_from_config(my_dict, loader_class=DictLoader)
|
|
212
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
sigil/__init__.py,sha256=-rGz3JJx4edqboBImga1nJe38P-qmofMfl3m37iTtE0,390
|
|
2
|
+
sigil/entrypoint.py,sha256=4YTtMTZydYX_ieUyv6OjsRd4B0G5X9DlhGv1dU6SN78,901
|
|
3
|
+
sigil/models/__init__.py,sha256=yrYNZj2OMP94W869eKoAuqMwHlukKfLNWXwhW2hgy1M,242
|
|
4
|
+
sigil/models/argparser.py,sha256=QRQ3aBJoKIVGvRrMA4w4NNhSFSofUraWEsahHxVcpmU,428
|
|
5
|
+
sigil/models/argument.py,sha256=KEbh1sQaKcoBkpAx4VlH4NroRHFmsw0LQ2ysVfRUEcI,621
|
|
6
|
+
sigil/models/command.py,sha256=blFlNRGoFoDW6Fw9oWbd3lD0i7JJqlSuNFt1iYtW4EM,850
|
|
7
|
+
sigil/models/script.py,sha256=VxPYOT2EGknZKYwhzsgmwaVfZqloK8ujbBy64yrDcbM,188
|
|
8
|
+
sigil/stages/__init__.py,sha256=x2LTIoEO732mcnohHBfFxlwHVc1c435UnCDBrJVBKg4,212
|
|
9
|
+
sigil/stages/builder.py,sha256=4ftJ9YCn3FiuxSdHONilDvNHnJZqJnb7zSyy7zgN7OA,2657
|
|
10
|
+
sigil/stages/resolver.py,sha256=D6op5JZqzywswO5PjL0XpYEbT5vNxsqjczqJ2AJZjJI,1851
|
|
11
|
+
sigil/stages/script_loader.py,sha256=rxDR1DIUwB1_XhMaumXB5bc91cxbdrtQj8AdJHy4Ok0,1679
|
|
12
|
+
sigil/stages/yaml_reader.py,sha256=iKMpq2FahJ1Oc4gxqtUCRPD6vucV6PdNOLQf9buol4c,2968
|
|
13
|
+
sigil_cli-0.1.0.dist-info/licenses/LICENSE,sha256=D7GbINJ7rATMfIf14hpgr8uN92A0JszHKQqjO7MnoeY,1058
|
|
14
|
+
sigil_cli-0.1.0.dist-info/METADATA,sha256=lnkhuFFDTmFopHfNjL4h1NelRuo9A69qmIOY_dxEXZ4,5402
|
|
15
|
+
sigil_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
16
|
+
sigil_cli-0.1.0.dist-info/top_level.txt,sha256=JwJchkwF1qAzvIXu8_C5lSeAVY3ib64gXRdMsx1pR40,6
|
|
17
|
+
sigil_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 Kenzo Staelens
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sigil
|