sigil-cli 0.1.0__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,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,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,188 @@
1
+ # Sigil
2
+
3
+ > Declarative argparse, without the CLI boilerplate.
4
+
5
+ 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.
6
+ It plays nicely with `argcomplete` out of the box.
7
+
8
+ ---
9
+
10
+ ## Features
11
+
12
+ - Declarative command hierarchies (parents, subparsers, defaults)
13
+ - Each command can point to a dynamically imported Python script
14
+ - `argcomplete` integration for tab‑completion
15
+ - Pluggable data sources – YAML is the default, but JSON, TOML, or a dict are trivial to swap in
16
+ - No boilerplate argparse code in your main logic
17
+
18
+ ## Quick Start
19
+
20
+ ### Install
21
+
22
+ ```bash
23
+ pip install sigil-cli
24
+ ```
25
+
26
+ or include argcomplete
27
+
28
+ ```bash
29
+ pip install sigil-cli[completion]
30
+ ```
31
+
32
+ ### 0. Recommended file structure
33
+
34
+ ```text
35
+ project_root/
36
+ ├── mycli.py # drop‑in entry script (alias this)
37
+ ├── manifest.yml # lists all YAML config files to load
38
+ ├── yml/
39
+ │ ├── root.yml # root command definition
40
+ │ ├── root_run.yml # subcommand definition(s)
41
+ │ └── ...
42
+ └── scripts/
43
+ ├── run.py # implements the 'run' command
44
+ └── ... # other scripts
45
+ ```
46
+
47
+ ### 1. Entry script
48
+
49
+ Create `mycli.py`:
50
+
51
+ ```python
52
+ #!/usr/bin/env python3
53
+ from pathlib import Path
54
+ from sigil import run_from_config
55
+
56
+ if __name__ == "__main__":
57
+ run_from_config(Path(__file__).parent)
58
+ ```
59
+
60
+ ### 2. Configuration files
61
+
62
+ List all your YAML definitions in `manifest.yml`:
63
+
64
+ ```yaml
65
+ ---
66
+ - root.yml
67
+ - root_run.yml
68
+ ```
69
+
70
+ Define the root command in `root.yml`:
71
+
72
+ ```yaml
73
+ root:
74
+ name: mycli
75
+ script_dir: scripts
76
+ ```
77
+
78
+ Define a subcommand in `root_run.yml`:
79
+
80
+ ```yaml
81
+ root_run:
82
+ name: run
83
+ parent: root
84
+ help: command utility to run containers
85
+ script: run
86
+ args:
87
+ - help: port to run, autoincrements from 8080
88
+ name:
89
+ - -p
90
+ - --port
91
+ ```
92
+
93
+ ### 3. Write the script
94
+
95
+ Create `scripts/run.py`:
96
+
97
+ ```python
98
+ import argparse
99
+ from typing import Any
100
+
101
+ def run(args: argparse.Namespace, ctx: dict[str, Any]) -> None:
102
+ port = getattr(args, "port", 8080)
103
+ port = find_next_free_port_logic(port)
104
+ print(f"Running container on port {port}")
105
+ ```
106
+
107
+ ### 4. Run it
108
+
109
+ ```bash
110
+ chmod +x mycli.py
111
+ ./mycli.py run --port 9000
112
+ # Running container on port 9000
113
+
114
+ ./mycli.py run
115
+ # Running container on port 8080
116
+
117
+ ./mycli.py run
118
+ # Running container on port 8081
119
+ ```
120
+
121
+ ## Configuration Reference
122
+
123
+ ### Root
124
+
125
+ | Field | Description |
126
+ | --- | --- |
127
+ | `name` | Program name (used as `prog` in argparse) |
128
+ | `script_dir` | Directory (relative to the config root) where command scripts are located |
129
+
130
+ ### Command
131
+
132
+ | Field | Description |
133
+ | --- | --- |
134
+ | `name` | Subcommand name |
135
+ | `parent` | Parent command (must exist elsewhere in a config) |
136
+ | `help` | Help text for this subcommand |
137
+ | `script` | Python module name (without `.py`) inside `script_dir` or as absolute path |
138
+ | `args` | List of argument definitions (see below) |
139
+ | `default` | If `true`, this subcommand is used when no subcommand is given |
140
+
141
+ ### Argument
142
+
143
+ Each argument entry can be a plain dict which maps 1-to-1 with argparse `add_argument`, except name which maps it's `*args`
144
+
145
+ ```yaml
146
+ - name: ["-p", "--port"] # or a single string, e.g. "positional"
147
+ required: false
148
+ default: 8069
149
+ help: "port number"
150
+ ```
151
+
152
+ The `name` field can be `--flag` for flags or a string for positional arguments.
153
+ Both literal string and list of strings are supported.
154
+
155
+ ## Tab‑Completion (argcomplete)
156
+
157
+ Argus registers itself with `argcomplete` automatically if available on your system.
158
+ To enable completion, install [argcomplete](https://github.com/kislyuk/argcomplete) and activate it for your entry script:
159
+
160
+ ```bash
161
+ pip install argcomplete
162
+ activate-global-python-argcomplete
163
+ ```
164
+
165
+ Then run your script and hit <kbd>Tab</kbd> – subcommands and flags will complete.
166
+
167
+ ## Pluggable Backends
168
+
169
+ Sigil uses yaml by default, but you can supply any loader that returns a `dict[str, ParserConfig]`:
170
+
171
+ ```python
172
+ from sigil import run_from_config
173
+
174
+ # Use JSON instead:
175
+ class JsonReader:
176
+ @classmethod
177
+ def load(cls, config_root):
178
+ # read *.json, parse, convert to dict of ParserConfig
179
+ ...
180
+
181
+ run_from_config("/path/to/config", loader_class=JsonReader)
182
+ ```
183
+
184
+ You can also pass a pre‑loaded dictionary directly by wrapping it:
185
+
186
+ ```python
187
+ run_from_config(my_dict, loader_class=DictLoader)
188
+ ```
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sigil-cli"
7
+ version = "0.1.0"
8
+ description = "Configuration driven CLI builder with subcommands and script loading"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "Kenzo Staelens", email = "kenzostael@gmail.com"}]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ ]
18
+ dependencies = [
19
+ "pyyaml>=6.0",
20
+ ]
21
+
22
+ [project.optional-dependencies]
23
+ completion = ["argcomplete>=3.0"]
24
+ dev = ["pytest", "pytest-cov", "ruff"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/yourusername/sigil"
28
+ Repository = "https://github.com/yourusername/sigil"
29
+ Issues = "https://github.com/yourusername/sigil/issues"
30
+
31
+ [tool.setuptools.packages.find]
32
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )
@@ -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)
@@ -0,0 +1,12 @@
1
+ from .argparser import LibArgParser
2
+ from .argument import Argument
3
+ from .command import ParserConfig
4
+ from .script import SubcommandModule
5
+
6
+ __all__ = [
7
+ Argument,
8
+ LibArgParser,
9
+ ParserConfig,
10
+ ParserConfig,
11
+ SubcommandModule
12
+ ]
@@ -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
@@ -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__})
@@ -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__})
@@ -0,0 +1,8 @@
1
+ import argparse
2
+ from types import ModuleType
3
+ from typing import Any
4
+
5
+
6
+ class SubcommandModule(ModuleType):
7
+ @staticmethod
8
+ def run(args: argparse.Namespace, ctx: dict[str, Any]): ...
@@ -0,0 +1,11 @@
1
+ from .builder import Builder
2
+ from .resolver import Resolver
3
+ from .script_loader import ScriptLoader
4
+ from .yaml_reader import YamlReader
5
+
6
+ __all__ = [
7
+ YamlReader,
8
+ Resolver,
9
+ Builder,
10
+ ScriptLoader,
11
+ ]
@@ -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