protocli 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.
protocli/__init__.py ADDED
@@ -0,0 +1,337 @@
1
+ """Signature-driven CLI dispatcher.
2
+
3
+ Command values are either module paths (``str``) for lazy import, or callables
4
+ for direct invocation. Module-backed commands support nested dispatch (set a
5
+ module-level ``_dispatcher``) or signature-driven argument parsing.
6
+
7
+ Argument handling: if the leaf's ``main`` (or a callable target) takes no
8
+ parameters, the dispatcher intercepts ``-h``/``--help`` and prints
9
+ ``Usage: <prog> [args...]`` + the docstring. If it takes parameters, the
10
+ dispatcher introspects the signature and builds an ``argparse`` parser from
11
+ it. Signature → CLI shape:
12
+
13
+ - ``name: str`` → required positional
14
+ - ``name: str | None = None`` → optional positional (``nargs="?"``)
15
+ - ``*files: str`` → variadic positional (``nargs="*"``)
16
+ - ``*, force: bool = False`` → ``--force`` (``store_true``); bools must be
17
+ keyword-only with default ``False`` (name with
18
+ negation built in if needed, e.g. ``no_color``)
19
+ - ``*, n: int = 5`` → ``--n N`` with default
20
+ - ``*, m: Literal["a","b"]`` → ``--m`` restricted to choices
21
+ - ``*, xs: list[float] = []`` → ``--xs 1.5,3.5`` (comma-separated; ``""`` → [];
22
+ keyword-only, like bools)
23
+
24
+ Argparse owns ``--help`` and error messages for signature-dispatched leaves.
25
+ """
26
+
27
+ import argparse
28
+ import importlib
29
+ import inspect
30
+ import pkgutil
31
+ import sys
32
+ import types
33
+ import typing
34
+ from collections.abc import Callable
35
+
36
+ __version__ = "0.1.0"
37
+
38
+ _SKIP = frozenset({"__main__", "__init__", "constants", "cli"})
39
+ DISP_VAR = "_dispatcher"
40
+ COMP_FUN = "get_completions"
41
+ _HELP_FLAGS = (["-h"], ["--help"])
42
+
43
+ # Sentinel a leaf's ``get_completions`` may return (as the sole candidate) to
44
+ # request the shell's native pathname completion — handles ``~``, ``/``, ``$VAR``
45
+ # and absolute/relative paths, which a static candidate list cannot. Recognised
46
+ # by the ``_proto_complete`` bash function; keep the two literals in sync.
47
+ FILE_COMPLETION = "\x1bFILES"
48
+
49
+
50
+ def _unwrap_annotation(ann: object) -> tuple[object, list | None]:
51
+ """Return ``(base_type, choices_or_None)``.
52
+
53
+ Handles ``Literal[...]`` (returns element type + values as choices) and
54
+ ``Optional[T]``/``T | None`` (returns ``T``, no choices).
55
+ """
56
+ origin = typing.get_origin(ann)
57
+ args = typing.get_args(ann)
58
+ if origin is typing.Literal:
59
+ return type(args[0]), list(args)
60
+ if origin is typing.Union or origin is types.UnionType:
61
+ non_none = [a for a in args if a is not type(None)]
62
+ if len(non_none) == 1:
63
+ return _unwrap_annotation(non_none[0])
64
+ return ann, None
65
+
66
+
67
+ def _csv_of(elem: type) -> Callable[[str], list]:
68
+ """Parser for a comma-separated ``list[elem]`` flag value; ``""`` → []."""
69
+
70
+ def parse(s: str) -> list:
71
+ return [elem(e) for e in s.split(",") if e]
72
+
73
+ return parse
74
+
75
+
76
+ def _build_parser(prog: str, fn: Callable) -> argparse.ArgumentParser:
77
+ """Generate an argparse parser from ``fn``'s signature."""
78
+ sig = inspect.signature(fn)
79
+ hints = typing.get_type_hints(fn)
80
+ doc = (fn.__doc__ or "").strip().split("\n", 1)[0]
81
+ parser = argparse.ArgumentParser(prog=prog, description=doc)
82
+ for name, param in sig.parameters.items():
83
+ raw_ann = hints.get(name, str)
84
+ ann, choices = _unwrap_annotation(raw_ann)
85
+ kwargs: dict = {}
86
+ if choices is not None:
87
+ kwargs["choices"] = choices
88
+ kind = param.kind
89
+ if kind is inspect.Parameter.VAR_POSITIONAL:
90
+ kwargs["type"] = ann
91
+ kwargs["nargs"] = "*"
92
+ parser.add_argument(name, **kwargs)
93
+ continue
94
+ if ann is bool:
95
+ if kind is not inspect.Parameter.KEYWORD_ONLY or param.default is not False:
96
+ raise TypeError(
97
+ f"bool param {name!r} must be keyword-only with default False"
98
+ )
99
+ kwargs["action"] = "store_true"
100
+ parser.add_argument(f"--{name.replace('_', '-')}", **kwargs)
101
+ continue
102
+ if typing.get_origin(ann) is list:
103
+ if kind is not inspect.Parameter.KEYWORD_ONLY:
104
+ raise TypeError(f"list param {name!r} must be keyword-only")
105
+ elem = (typing.get_args(ann) or (str,))[0]
106
+ kwargs["type"] = _csv_of(elem)
107
+ kwargs["default"] = param.default
108
+ parser.add_argument(f"--{name.replace('_', '-')}", **kwargs)
109
+ continue
110
+ kwargs["type"] = ann
111
+ if kind is inspect.Parameter.KEYWORD_ONLY:
112
+ kwargs["default"] = param.default
113
+ parser.add_argument(f"--{name.replace('_', '-')}", **kwargs)
114
+ elif param.default is not inspect.Parameter.empty:
115
+ kwargs["default"] = param.default
116
+ kwargs["nargs"] = "?"
117
+ parser.add_argument(name, **kwargs)
118
+ else:
119
+ parser.add_argument(name, **kwargs)
120
+ return parser
121
+
122
+
123
+ def _sig_help(prog: str, fn: Callable) -> list[str] | None:
124
+ """Return ``[parser.format_help().rstrip()]`` if ``fn`` has parameters."""
125
+ if not inspect.signature(fn).parameters:
126
+ return None
127
+ return [_build_parser(prog, fn).format_help().rstrip()]
128
+
129
+
130
+ def _sig_completions(fn: Callable, rest: list[str]) -> list[str]:
131
+ """Derive shell-completion candidates from ``fn``'s signature.
132
+
133
+ Returns the keyword-only flag names; if the previous token is a flag whose
134
+ annotation is ``Literal[...]``, returns those choices instead.
135
+ """
136
+ sig = inspect.signature(fn)
137
+ if not sig.parameters:
138
+ return []
139
+ hints = typing.get_type_hints(fn)
140
+ if rest:
141
+ last = rest[-1]
142
+ for name, param in sig.parameters.items():
143
+ if param.kind is not inspect.Parameter.KEYWORD_ONLY:
144
+ continue
145
+ if last != f"--{name.replace('_', '-')}":
146
+ continue
147
+ _, choices = _unwrap_annotation(hints.get(name, str))
148
+ return choices or []
149
+ return [
150
+ f"--{name.replace('_', '-')}"
151
+ for name, param in sig.parameters.items()
152
+ if param.kind is inspect.Parameter.KEYWORD_ONLY
153
+ ]
154
+
155
+
156
+ class Dispatcher:
157
+ """Unified CLI dispatcher.
158
+
159
+ Construct directly with an explicit command dict, or via ``from_package``
160
+ for auto-discovery. Dict values that are ``str`` are treated as module
161
+ paths (lazy-imported on dispatch); callables are invoked directly. Leaf
162
+ ``main()`` / callable targets either take no params (dispatcher handles
163
+ ``--help`` via docstring) or accept signature-driven args (argparse owns
164
+ ``--help`` and parsing).
165
+ """
166
+
167
+ def __init__(self, prog: str, commands: dict[str, str | Callable]):
168
+ self._prog = prog
169
+ self._commands = commands
170
+
171
+ @classmethod
172
+ def from_package(cls, package: str, prog: str | None = None) -> "Dispatcher":
173
+ mod = importlib.import_module(package)
174
+ commands: dict[str, str | Callable] = {
175
+ m.name: f"{package}.{m.name}"
176
+ for m in pkgutil.iter_modules(mod.__path__)
177
+ if m.name not in _SKIP and not m.name.startswith("_")
178
+ }
179
+ return cls(prog=prog or package.rsplit(".", 1)[-1], commands=commands)
180
+
181
+ def commands(self) -> list[str]:
182
+ return sorted(self._commands)
183
+
184
+ def tree(self) -> dict[str, dict | None]:
185
+ """Return nested command tree. Leaves are ``None``."""
186
+ result: dict[str, dict | None] = {}
187
+ for cmd in self.commands():
188
+ target = self._commands[cmd]
189
+ if callable(target):
190
+ result[cmd] = None
191
+ continue
192
+ try:
193
+ mod = importlib.import_module(target)
194
+ except ImportError:
195
+ result[cmd] = None
196
+ continue
197
+ child = getattr(mod, DISP_VAR, None)
198
+ result[cmd] = child.tree() if child is not None else None
199
+ return result
200
+
201
+ def _get_doc(self, cmd: str) -> str:
202
+ target = self._commands[cmd]
203
+ if callable(target):
204
+ return (target.__doc__ or "").strip().split("\n")[0]
205
+ try:
206
+ mod = importlib.import_module(target)
207
+ return (mod.__doc__ or "").strip().split("\n")[0]
208
+ except ImportError:
209
+ return ""
210
+
211
+ def _help_lines(self) -> list[str]:
212
+ cmds = self.commands()
213
+ lines = [f"Usage: {self._prog} <command> [args...]"]
214
+ if cmds:
215
+ width = max(len(c) for c in cmds)
216
+ lines.append("Commands:")
217
+ for cmd in cmds:
218
+ doc = self._get_doc(cmd)
219
+ suffix = f" {doc}" if doc else ""
220
+ lines.append(f" {cmd:<{width}}{suffix}")
221
+ return lines
222
+
223
+ def _collect_help_all(self, sections: list[list[str]]) -> None:
224
+ sections.append(self._help_lines())
225
+ for cmd in self.commands():
226
+ prog = f"{self._prog} {cmd}"
227
+ target = self._commands[cmd]
228
+ if callable(target):
229
+ sig = _sig_help(prog, target)
230
+ if sig:
231
+ sections.append(sig)
232
+ continue
233
+ try:
234
+ mod = importlib.import_module(target)
235
+ except ImportError:
236
+ continue
237
+ child = getattr(mod, DISP_VAR, None)
238
+ if child is not None:
239
+ child._collect_help_all(sections)
240
+ continue
241
+ sig = _sig_help(prog, mod.main)
242
+ if sig:
243
+ sections.append(sig)
244
+ continue
245
+ complete_fn = getattr(mod, COMP_FUN, None)
246
+ if complete_fn:
247
+ subs = complete_fn([])
248
+ if subs:
249
+ sections.append([prog, f" {', '.join(subs)}"])
250
+
251
+ @staticmethod
252
+ def _print_leaf_help(prog: str, doc: str | None) -> None:
253
+ print(f"Usage: {prog} [args...]")
254
+ text = (doc or "").strip()
255
+ if text:
256
+ print(text)
257
+
258
+ def _invoke(
259
+ self, prog: str, fn: Callable, rest: list[str], doc: str | None
260
+ ) -> None:
261
+ sys.argv = [prog, *rest]
262
+ if not inspect.signature(fn).parameters:
263
+ if rest[:1] in _HELP_FLAGS:
264
+ self._print_leaf_help(prog, doc)
265
+ return
266
+ fn()
267
+ return
268
+ parser = _build_parser(prog, fn)
269
+ parsed = parser.parse_args(rest)
270
+ call_args, call_kwargs = [], {}
271
+ for pname, param in inspect.signature(fn).parameters.items():
272
+ val = getattr(parsed, pname)
273
+ if param.kind is inspect.Parameter.VAR_POSITIONAL:
274
+ call_args.extend(val)
275
+ elif param.kind is inspect.Parameter.KEYWORD_ONLY:
276
+ call_kwargs[pname] = val
277
+ else:
278
+ call_args.append(val)
279
+ fn(*call_args, **call_kwargs)
280
+
281
+ def run(self) -> None:
282
+ argv = sys.argv[1:]
283
+ cmds = self.commands()
284
+
285
+ if not argv or [argv[0]] in _HELP_FLAGS:
286
+ print("\n".join(self._help_lines()))
287
+ return
288
+
289
+ if argv[0] == "--help-all":
290
+ sections: list[list[str]] = []
291
+ self._collect_help_all(sections)
292
+ print("\n\n".join("\n".join(s) for s in sections))
293
+ return
294
+
295
+ if argv[0] == "--complete":
296
+ for line in self.get_completions(argv[1:]):
297
+ print(line)
298
+ return
299
+
300
+ cmd, *rest = argv
301
+ if cmd not in cmds:
302
+ print(f"Unknown command: {cmd}", file=sys.stderr)
303
+ raise SystemExit(1)
304
+
305
+ target = self._commands[cmd]
306
+ prog = f"{self._prog} {cmd}"
307
+
308
+ if callable(target):
309
+ self._invoke(prog, target, rest, target.__doc__)
310
+ return
311
+
312
+ mod = importlib.import_module(target)
313
+ child = getattr(mod, DISP_VAR, None)
314
+ if child is not None:
315
+ sys.argv = [prog, *rest]
316
+ child.run()
317
+ return
318
+ self._invoke(prog, mod.main, rest, mod.__doc__)
319
+
320
+ def get_completions(self, args: list[str]) -> list[str]:
321
+ cmds = self.commands()
322
+ if not args:
323
+ return cmds
324
+ cmd, *rest = args
325
+ if cmd not in cmds:
326
+ return []
327
+ target = self._commands[cmd]
328
+ if callable(target):
329
+ return _sig_completions(target, rest)
330
+ mod = importlib.import_module(target)
331
+ child = getattr(mod, DISP_VAR, None)
332
+ if child is not None:
333
+ return child.get_completions(rest)
334
+ complete_fn = getattr(mod, COMP_FUN, None)
335
+ if complete_fn is not None:
336
+ return complete_fn(rest)
337
+ return _sig_completions(mod.main, rest)
@@ -0,0 +1,146 @@
1
+ import sys
2
+ import types
3
+ import typing
4
+
5
+ import pytest
6
+
7
+ from protocli import (
8
+ FILE_COMPLETION,
9
+ Dispatcher,
10
+ _build_parser,
11
+ _unwrap_annotation,
12
+ )
13
+
14
+
15
+ def test_unwrap_annotation() -> None:
16
+ assert _unwrap_annotation(int) == (int, None)
17
+ assert _unwrap_annotation(typing.Literal["a", "b"]) == (str, ["a", "b"])
18
+ assert _unwrap_annotation(typing.Optional[int]) == (int, None)
19
+ assert _unwrap_annotation(str | None) == (str, None)
20
+
21
+
22
+ def test_parser_shapes() -> None:
23
+ def fn(
24
+ name: str,
25
+ opt: str | None = None,
26
+ *files: str,
27
+ force: bool = False,
28
+ n: int = 5,
29
+ mode: typing.Literal["a", "b"] = "a",
30
+ ) -> None: ...
31
+
32
+ parser = _build_parser("prog", fn)
33
+ ns = parser.parse_args(["x", "y", "f1", "f2", "--force", "--n", "7", "--mode", "b"])
34
+ assert (ns.name, ns.opt, ns.files) == ("x", "y", ["f1", "f2"])
35
+ assert (ns.force, ns.n, ns.mode) == (True, 7, "b")
36
+ ns = parser.parse_args(["x"])
37
+ assert (ns.opt, ns.files, ns.force, ns.n, ns.mode) == (None, [], False, 5, "a")
38
+ with pytest.raises(SystemExit):
39
+ parser.parse_args(["x", "--mode", "c"])
40
+
41
+
42
+ def test_list_flags() -> None:
43
+ def fn(*, bins: list[float] = [], procs: list[int] = [16, 1]) -> None: ...
44
+
45
+ parser = _build_parser("prog", fn)
46
+ ns = parser.parse_args(["--bins", "1.5,3.5", "--procs", "4"])
47
+ assert ns.bins == [1.5, 3.5] and ns.procs == [4]
48
+ ns = parser.parse_args(["--bins", ""])
49
+ assert ns.bins == [] and ns.procs == [16, 1]
50
+
51
+
52
+ def test_list_must_be_keyword_only() -> None:
53
+ def fn(bins: list[float] = []) -> None: ...
54
+
55
+ with pytest.raises(TypeError, match="keyword-only"):
56
+ _build_parser("prog", fn)
57
+
58
+
59
+ def test_bool_constraints() -> None:
60
+ def positional(flag: bool) -> None: ...
61
+
62
+ def defaulted_true(*, flag: bool = True) -> None: ...
63
+
64
+ for fn in (positional, defaulted_true):
65
+ with pytest.raises(TypeError, match="keyword-only with default False"):
66
+ _build_parser("prog", fn)
67
+
68
+
69
+ def _run(disp: Dispatcher, *argv: str, monkeypatch) -> None:
70
+ monkeypatch.setattr(sys, "argv", ["prog", *argv])
71
+ disp.run()
72
+
73
+
74
+ def test_callable_dispatch(monkeypatch) -> None:
75
+ calls = []
76
+
77
+ def greet(name: str, *, loud: bool = False) -> None:
78
+ calls.append((name, loud))
79
+
80
+ disp = Dispatcher("prog", {"greet": greet})
81
+ _run(disp, "greet", "ada", "--loud", monkeypatch=monkeypatch)
82
+ assert calls == [("ada", True)]
83
+
84
+
85
+ def test_module_and_nested_dispatch(monkeypatch, capsys) -> None:
86
+ leaf = types.ModuleType("fake_leaf")
87
+ leaf.__doc__ = "Leaf docs."
88
+ calls = []
89
+ leaf.main = lambda: calls.append("ran")
90
+ sys.modules["fake_leaf"] = leaf
91
+
92
+ inner = types.ModuleType("fake_nested")
93
+ inner.ping = lambda: calls.append("pong")
94
+ inner._dispatcher = Dispatcher("prog nest", {"ping": inner.ping})
95
+ sys.modules["fake_nested"] = inner
96
+
97
+ disp = Dispatcher("prog", {"leaf": "fake_leaf", "nest": "fake_nested"})
98
+ _run(disp, "leaf", monkeypatch=monkeypatch)
99
+ _run(disp, "nest", "ping", monkeypatch=monkeypatch)
100
+ assert calls == ["ran", "pong"]
101
+ assert disp.tree() == {"leaf": None, "nest": {"ping": None}}
102
+
103
+ _run(disp, "leaf", "-h", monkeypatch=monkeypatch)
104
+ out = capsys.readouterr().out
105
+ assert "Usage: prog leaf [args...]" in out and "Leaf docs." in out
106
+
107
+
108
+ def test_unknown_command_exits(monkeypatch) -> None:
109
+ disp = Dispatcher("prog", {})
110
+ with pytest.raises(SystemExit):
111
+ _run(disp, "ghost", monkeypatch=monkeypatch)
112
+
113
+
114
+ def test_top_help_lists_commands(monkeypatch, capsys) -> None:
115
+ def doer() -> None:
116
+ """Does things."""
117
+
118
+ disp = Dispatcher("prog", {"do": doer})
119
+ _run(disp, monkeypatch=monkeypatch)
120
+ out = capsys.readouterr().out
121
+ assert "Usage: prog <command>" in out and "do Does things." in out
122
+
123
+
124
+ def test_completions() -> None:
125
+ def fn(*, mode: typing.Literal["x", "y"] = "x", n: int = 1) -> None: ...
126
+
127
+ disp = Dispatcher("prog", {"cmd": fn})
128
+ assert disp.get_completions([]) == ["cmd"]
129
+ assert sorted(disp.get_completions(["cmd"])) == ["--mode", "--n"]
130
+ assert disp.get_completions(["cmd", "--mode"]) == ["x", "y"]
131
+ assert disp.get_completions(["ghost"]) == []
132
+ assert FILE_COMPLETION.startswith("\x1b")
133
+
134
+
135
+ def test_from_package(tmp_path, monkeypatch) -> None:
136
+ pkg = tmp_path / "fakepkg"
137
+ pkg.mkdir()
138
+ (pkg / "__init__.py").write_text("")
139
+ (pkg / "hello.py").write_text('"""Say hi."""\ndef main():\n print("hi")\n')
140
+ (pkg / "_private.py").write_text("def main():\n pass\n")
141
+ monkeypatch.syspath_prepend(str(tmp_path))
142
+
143
+ disp = Dispatcher.from_package("fakepkg")
144
+ assert disp.commands() == ["hello"]
145
+ monkeypatch.setattr(sys, "argv", ["fakepkg", "hello"])
146
+ disp.run()
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.5
2
+ Name: protocli
3
+ Version: 0.1.0
4
+ Summary: Signature-driven CLI dispatcher: typed function signatures become argparse parsers, modules become lazily-imported subcommands, with nested dispatch, --help-all and shell completions.
5
+ Project-URL: Homepage, https://github.com/endremborza/protocli
6
+ Author-email: Endre Márk Borza <endremborza@gmail.com>
7
+ License: Copyright 2026 Endre Márk Borza
8
+
9
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
14
+ License-File: LICENSE
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+
18
+ # protocli
19
+
20
+ [![pypi](https://img.shields.io/pypi/v/protocli.svg)](https://pypi.org/project/protocli/)
21
+
22
+ Signature-driven CLI dispatcher: typed function signatures become argparse parsers, modules become lazily-imported subcommands, with nested dispatch, `--help-all` and shell completions.
23
+
24
+ ```python
25
+ # mytool/__main__.py
26
+ from protocli import Dispatcher
27
+
28
+ Dispatcher(
29
+ "mytool",
30
+ {
31
+ "greet": greet, # a callable: its signature IS the parser
32
+ "db": "mytool.db", # a module path: lazy-imported on dispatch
33
+ },
34
+ ).run()
35
+ ```
36
+
37
+ A callable (or a module's `main`) maps to CLI arguments by signature:
38
+
39
+ ```python
40
+ def greet(
41
+ name: str, # required positional
42
+ title: str | None = None, # optional positional
43
+ *rest: str, # variadic
44
+ loud: bool = False, # --loud (keyword-only, default False)
45
+ times: int = 1, # --times N
46
+ lang: Literal["en", "hu"] = "en", # --lang restricted to choices
47
+ tags: list[str] = [], # --tags a,b,c (comma-separated)
48
+ ): ...
49
+ ```
50
+
51
+ A module can instead expose its own nested `_dispatcher = Dispatcher(...)`,
52
+ or a `get_completions(rest)` hook for custom shell completion (return
53
+ `[FILE_COMPLETION]` to request native pathname completion).
54
+
55
+ `mytool --help-all` prints every parser in the tree; `mytool --complete <args…>`
56
+ emits completion candidates for shell integration.
57
+
58
+ Auto-discovery over a package's public modules:
59
+
60
+ ```python
61
+ Dispatcher.from_package("mytool").run()
62
+ ```
@@ -0,0 +1,6 @@
1
+ protocli/__init__.py,sha256=quPZbR5ugsPI3t2zTLsdPlhjf463cTPeNp1sqqbJidc,12578
2
+ protocli/tests/test_dispatcher.py,sha256=5Zr3FOGvKtg4ZYAgT_uw6V0zZU8waFgSwG_qvP-x5dk,4729
3
+ protocli-0.1.0.dist-info/METADATA,sha256=VieX6jr-KQbRSku2wXjFP8NXfWvHuRAyVMdcelaoBTY,3110
4
+ protocli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
5
+ protocli-0.1.0.dist-info/licenses/LICENSE,sha256=NrHeFb1pfm6nQHXGcuumvUALSfJjJv9EZTv4rK-jwR0,1056
6
+ protocli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,7 @@
1
+ Copyright 2026 Endre Márk Borza
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.