splight-runner 1.0.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.
Files changed (33) hide show
  1. splight_runner-1.0.0/PKG-INFO +127 -0
  2. splight_runner-1.0.0/README.md +110 -0
  3. splight_runner-1.0.0/pyproject.toml +35 -0
  4. splight_runner-1.0.0/src/splight_runner/__init__.py +3 -0
  5. splight_runner-1.0.0/src/splight_runner/api/__init__.py +0 -0
  6. splight_runner-1.0.0/src/splight_runner/api/component_reporter.py +55 -0
  7. splight_runner-1.0.0/src/splight_runner/api/logging_interceptor.py +85 -0
  8. splight_runner-1.0.0/src/splight_runner/api/settings.py +63 -0
  9. splight_runner-1.0.0/src/splight_runner/bootstrap/__init__.py +0 -0
  10. splight_runner-1.0.0/src/splight_runner/bootstrap/sitecustomize.py +39 -0
  11. splight_runner-1.0.0/src/splight_runner/commands/__init__.py +0 -0
  12. splight_runner-1.0.0/src/splight_runner/commands/admin.py +136 -0
  13. splight_runner-1.0.0/src/splight_runner/commands/execute_agent.py +43 -0
  14. splight_runner-1.0.0/src/splight_runner/commands/execute_component.py +84 -0
  15. splight_runner-1.0.0/src/splight_runner/config.py +72 -0
  16. splight_runner-1.0.0/src/splight_runner/hooks/__init__.py +150 -0
  17. splight_runner-1.0.0/src/splight_runner/hooks/constants.py +1 -0
  18. splight_runner-1.0.0/src/splight_runner/hooks/finder.py +46 -0
  19. splight_runner-1.0.0/src/splight_runner/hooks/loader.py +88 -0
  20. splight_runner-1.0.0/src/splight_runner/hooks/meta_paths.py +16 -0
  21. splight_runner-1.0.0/src/splight_runner/hooks/registry.py +39 -0
  22. splight_runner-1.0.0/src/splight_runner/hooks/utils.py +5 -0
  23. splight_runner-1.0.0/src/splight_runner/log_streamer/__init__.py +0 -0
  24. splight_runner-1.0.0/src/splight_runner/log_streamer/log_buffer.py +55 -0
  25. splight_runner-1.0.0/src/splight_runner/log_streamer/log_client.py +30 -0
  26. splight_runner-1.0.0/src/splight_runner/log_streamer/log_streamer.py +91 -0
  27. splight_runner-1.0.0/src/splight_runner/logging.py +13 -0
  28. splight_runner-1.0.0/src/splight_runner/runner.py +30 -0
  29. splight_runner-1.0.0/src/splight_runner/version.py +3 -0
  30. splight_runner-1.0.0/src/splight_runner/wrapper/__init__.py +0 -0
  31. splight_runner-1.0.0/src/splight_runner/wrapper/healthcheck.py +24 -0
  32. splight_runner-1.0.0/src/splight_runner/wrapper/hooks.py +16 -0
  33. splight_runner-1.0.0/src/splight_runner/wrapper/logging.py +27 -0
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.1
2
+ Name: splight-runner
3
+ Version: 1.0.0
4
+ Summary: Splight Runner
5
+ Author: Splight Dev
6
+ Author-email: dev@splight-ae.com
7
+ Requires-Python: >=3.8,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.8
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Dist: requests (>=2.26.0,<3.0.0)
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Splight Runner
18
+
19
+ ---
20
+
21
+ The `splight-runner` package is a tool for running processes that may interact
22
+ with the **Splight Engine** in order to report monitoring information of the
23
+ process.
24
+
25
+ ## Description
26
+
27
+ The `splight-runner` main functionality is to run different kinds of *Python*
28
+ processes related in some way to the **Splight Engine**, for example,
29
+ *components* that were developed using the **Splight Library**.
30
+
31
+ For this reason, the different features in the runner are for reporting the
32
+ status of the process and centralizing logs to be accessed through the
33
+ **Splight Engine**. So the `splight-runner` is used when the process is
34
+ launched from the **Splight Engine**, this means that it should not be used
35
+ during the development of the component even though the process will be
36
+ running under the `splight-runner`.
37
+
38
+ ## Usage
39
+
40
+ The package has different commands that can be used. Here you can find a list
41
+ of all the commands with a short description and how to use them.
42
+
43
+ ### Running Components
44
+
45
+ The first command is used for running components that use the **Splight Library**.
46
+ The command is
47
+ ```bash
48
+ splight-runner run-component <path>/main.py --component-id=<component_id>
49
+ ```
50
+ This command will execute the component whose main file is called `main.py` and
51
+ will modify some default functioning in order to report the status of the component and
52
+ send the logs of the component to the **Splight Engine**. In the next sections, you can
53
+ find how these modifications are done by the `splight-runner`.
54
+
55
+ In order to this command work as expected some environment variables are needed.
56
+ The following are the list of variables that should be configured before running the
57
+ command:
58
+ ```bash
59
+ SPLIGHT_ACCESS_ID=<access_id>
60
+ SPLIGHT_SECRET_KEY=<secret_key
61
+ SPLIGHT_PLATFORM_API_HOST=https://api.splight-ai.com
62
+ COMPONENT_ID=<component_id>
63
+ ```
64
+ Some of the variables are pointing to the production environment, but if you are
65
+ interested in using another environment like "integration" or maybe a local
66
+ environment you need to modify the corresponding variable value.
67
+
68
+ It is important to mention that the `splight-runner` should be used for components that
69
+ use the **Splight Lib** and **Splight CLI** grater than version `4.0.0`. For components
70
+ with older versions we can't ensure the proper functioning.
71
+
72
+ ## Structure
73
+
74
+ You may be asking what the `splight-runner` does in order to do whatever it does.
75
+ Well, that's not an easy question to respond but let's try to explain a little bit.
76
+
77
+ So far, the package has two main functionalities, sending logs and reporting status.
78
+ With the two features we already have a lot of magic is happening, but maybe in the future
79
+ we will add some more.
80
+
81
+ `splight-runner` works as a wrapper around the process that will be executed,
82
+ this means some configurations are done, so the process in question is executed.
83
+
84
+ For example, when the command `run-component` is used, the environment variable
85
+ `PYTHONPATH` is modified in order to include the directory where the file `sitecustomize.py`
86
+ of the `splight-runner` is located. That file is a *Python* configuration file that can
87
+ be used to customize the behavior of Python's site module. The site module is
88
+ responsible for setting up Python's runtime environment, including configuring
89
+ paths, importing modules, and performing other site-specific tasks.
90
+
91
+ So, using this file we can modify the default behaviors of libraries, for example, we
92
+ can modify the import process of modules or libraries in order to change or add
93
+ a new behavior. This way we can modify the `logging` module and intercept
94
+ the logging process to show the logs messages and also send the logs messages
95
+ to the **Splight Engine**. Some similar implementation is used for
96
+ reporting the component's status.
97
+
98
+
99
+ ## Development and Contributing
100
+
101
+ Since the main goal of the tool is to be used for running different kinds of
102
+ `Python` processes, is mandatory not to interfere with the process's
103
+ dependencies, this should be considered when adding new features for the
104
+ `splight-runner`. This is the reason why some basic functionalities
105
+ are implemented in the source code and not imported from a third-party
106
+ library, for example, a command parser for the different commands was created
107
+ in the code and no library was used like `click` or `typer`.
108
+
109
+ The package is fully dockerized, for testing new features you can use the
110
+ docker image that can be build with
111
+ the command
112
+ ```bash
113
+ make build
114
+ ```
115
+ For running the docker container the command is
116
+ ```bash
117
+ make start
118
+ ```
119
+ and to stop the container:
120
+ ```bash
121
+ make stop
122
+ ```
123
+
124
+ It is important to note that testing new features in the `splight-runner` is
125
+ not an easy task, you may need to modify the docker-compose file in order to
126
+ include new volumes.
127
+
@@ -0,0 +1,110 @@
1
+ # Splight Runner
2
+
3
+ ---
4
+
5
+ The `splight-runner` package is a tool for running processes that may interact
6
+ with the **Splight Engine** in order to report monitoring information of the
7
+ process.
8
+
9
+ ## Description
10
+
11
+ The `splight-runner` main functionality is to run different kinds of *Python*
12
+ processes related in some way to the **Splight Engine**, for example,
13
+ *components* that were developed using the **Splight Library**.
14
+
15
+ For this reason, the different features in the runner are for reporting the
16
+ status of the process and centralizing logs to be accessed through the
17
+ **Splight Engine**. So the `splight-runner` is used when the process is
18
+ launched from the **Splight Engine**, this means that it should not be used
19
+ during the development of the component even though the process will be
20
+ running under the `splight-runner`.
21
+
22
+ ## Usage
23
+
24
+ The package has different commands that can be used. Here you can find a list
25
+ of all the commands with a short description and how to use them.
26
+
27
+ ### Running Components
28
+
29
+ The first command is used for running components that use the **Splight Library**.
30
+ The command is
31
+ ```bash
32
+ splight-runner run-component <path>/main.py --component-id=<component_id>
33
+ ```
34
+ This command will execute the component whose main file is called `main.py` and
35
+ will modify some default functioning in order to report the status of the component and
36
+ send the logs of the component to the **Splight Engine**. In the next sections, you can
37
+ find how these modifications are done by the `splight-runner`.
38
+
39
+ In order to this command work as expected some environment variables are needed.
40
+ The following are the list of variables that should be configured before running the
41
+ command:
42
+ ```bash
43
+ SPLIGHT_ACCESS_ID=<access_id>
44
+ SPLIGHT_SECRET_KEY=<secret_key
45
+ SPLIGHT_PLATFORM_API_HOST=https://api.splight-ai.com
46
+ COMPONENT_ID=<component_id>
47
+ ```
48
+ Some of the variables are pointing to the production environment, but if you are
49
+ interested in using another environment like "integration" or maybe a local
50
+ environment you need to modify the corresponding variable value.
51
+
52
+ It is important to mention that the `splight-runner` should be used for components that
53
+ use the **Splight Lib** and **Splight CLI** grater than version `4.0.0`. For components
54
+ with older versions we can't ensure the proper functioning.
55
+
56
+ ## Structure
57
+
58
+ You may be asking what the `splight-runner` does in order to do whatever it does.
59
+ Well, that's not an easy question to respond but let's try to explain a little bit.
60
+
61
+ So far, the package has two main functionalities, sending logs and reporting status.
62
+ With the two features we already have a lot of magic is happening, but maybe in the future
63
+ we will add some more.
64
+
65
+ `splight-runner` works as a wrapper around the process that will be executed,
66
+ this means some configurations are done, so the process in question is executed.
67
+
68
+ For example, when the command `run-component` is used, the environment variable
69
+ `PYTHONPATH` is modified in order to include the directory where the file `sitecustomize.py`
70
+ of the `splight-runner` is located. That file is a *Python* configuration file that can
71
+ be used to customize the behavior of Python's site module. The site module is
72
+ responsible for setting up Python's runtime environment, including configuring
73
+ paths, importing modules, and performing other site-specific tasks.
74
+
75
+ So, using this file we can modify the default behaviors of libraries, for example, we
76
+ can modify the import process of modules or libraries in order to change or add
77
+ a new behavior. This way we can modify the `logging` module and intercept
78
+ the logging process to show the logs messages and also send the logs messages
79
+ to the **Splight Engine**. Some similar implementation is used for
80
+ reporting the component's status.
81
+
82
+
83
+ ## Development and Contributing
84
+
85
+ Since the main goal of the tool is to be used for running different kinds of
86
+ `Python` processes, is mandatory not to interfere with the process's
87
+ dependencies, this should be considered when adding new features for the
88
+ `splight-runner`. This is the reason why some basic functionalities
89
+ are implemented in the source code and not imported from a third-party
90
+ library, for example, a command parser for the different commands was created
91
+ in the code and no library was used like `click` or `typer`.
92
+
93
+ The package is fully dockerized, for testing new features you can use the
94
+ docker image that can be build with
95
+ the command
96
+ ```bash
97
+ make build
98
+ ```
99
+ For running the docker container the command is
100
+ ```bash
101
+ make start
102
+ ```
103
+ and to stop the container:
104
+ ```bash
105
+ make stop
106
+ ```
107
+
108
+ It is important to note that testing new features in the `splight-runner` is
109
+ not an easy task, you may need to modify the docker-compose file in order to
110
+ include new volumes.
@@ -0,0 +1,35 @@
1
+ [tool.poetry]
2
+ name = "splight-runner"
3
+ version = "1.0.0"
4
+ description = "Splight Runner"
5
+ authors = ["Splight Dev <dev@splight-ae.com>"]
6
+ readme = "README.md"
7
+
8
+ [tool.poetry.dependencies]
9
+ python = "^3.8"
10
+ requests = "^2.26.0"
11
+
12
+ [tool.poetry.group.dev.dependencies]
13
+ black = "23.3.0"
14
+ isort = "5.12.0"
15
+ ipdb = "^0.13.13"
16
+ ipython = "8.12.2"
17
+
18
+ [tool.poetry.scripts]
19
+ splight-runner = "splight_runner.runner:runner"
20
+
21
+ [build-system]
22
+ requires = ["poetry-core"]
23
+ build-backend = "poetry.core.masonry.api"
24
+
25
+ [tool.black]
26
+ line-length = 79
27
+ force-exclude = ".*_pb2.*py.*"
28
+
29
+ [tool.isort]
30
+ profile = "black"
31
+ line_length = 79
32
+ skip_glob = ["*_pb2*.py*"]
33
+
34
+ [tool.ruff]
35
+ line-length = 79
@@ -0,0 +1,3 @@
1
+ from splight_runner.runner import runner
2
+
3
+ __all__ = [runner]
@@ -0,0 +1,55 @@
1
+ import requests
2
+
3
+ from splight_runner.api.settings import settings
4
+
5
+
6
+ class StatusReporter:
7
+ """Class responsible for updating the component status each time the
8
+ healthcheck method is called.
9
+ """
10
+
11
+ _SPL_PREFIX = "Splight"
12
+ _BASE_PATH = "v2/engine/component/components"
13
+
14
+ def __init__(
15
+ self, api_host: str, access_id: str, secret_key: str, component_id: str
16
+ ):
17
+ self._api_host = api_host.rstrip("/")
18
+ self._component_id = component_id
19
+ self._auth_header = {
20
+ "Authorization": f"{self._SPL_PREFIX} {access_id} {secret_key}"
21
+ }
22
+ self._prev_status: str = "Unknown"
23
+
24
+ def report_status(self, status: str) -> None:
25
+ """Updates the status of the component in the Splight Platform making
26
+ POST request to the API.
27
+
28
+ Parameters
29
+ ----------
30
+ status : str
31
+ The status of the component.
32
+ It can be "Running", "Stopped" or "Succeeded".
33
+ """
34
+ if self._prev_status == status:
35
+ return None
36
+ url = f"{self._api_host}/{self._BASE_PATH}"
37
+ url = f"{url}/{self._component_id}/update-status/"
38
+ response = requests.post(
39
+ url, headers=self._auth_header, data={"deployment_status": status}
40
+ )
41
+ try:
42
+ response.raise_for_status()
43
+ except requests.exceptions.HTTPError as exc:
44
+ print("Unable to update component status")
45
+ print(exc)
46
+ self._prev_status = status
47
+ return None
48
+
49
+
50
+ reporter = StatusReporter(
51
+ api_host=settings.splight_platform_api_host,
52
+ access_id=settings.access_id,
53
+ secret_key=settings.secret_key,
54
+ component_id=settings.process_id,
55
+ )
@@ -0,0 +1,85 @@
1
+ import traceback
2
+ from logging import LogRecord
3
+
4
+ from splight_runner.api.settings import settings
5
+ from splight_runner.log_streamer.log_streamer import ComponentLogsStreamer
6
+ from splight_runner.logging import log
7
+
8
+
9
+ class ApplicationLogInterceptor:
10
+ """Class responsible for intercept logs records from the logging module
11
+ and append them into a queue for further processing. In particular for
12
+ sending the logs events to Splight GRPC API.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ host: str,
18
+ access_id: str,
19
+ secret_key: str,
20
+ process_id: str,
21
+ sender_type: str,
22
+ ):
23
+ self._streamer = ComponentLogsStreamer(
24
+ host=host,
25
+ access_id=access_id,
26
+ secret_key=secret_key,
27
+ process_id=process_id,
28
+ )
29
+ self._sender_type = sender_type
30
+ self._streamer.start()
31
+
32
+ def save_record(self, record: LogRecord) -> None:
33
+ """Saves the logging record to be sent.
34
+
35
+ Parameters
36
+ ----------
37
+ record: LogRecord
38
+ The log record.
39
+ """
40
+ exc_info = None
41
+ try:
42
+ message = str(record.msg) % record.args
43
+ except Exception as exc:
44
+ log(exc)
45
+ message = str(record.msg)
46
+
47
+ if record.exc_info:
48
+ exc_info = "".join(
49
+ traceback.format_exception(*record.exc_info)
50
+ ).replace('"', "'")
51
+ message = str(record.msg)
52
+ else:
53
+ exc_info = ""
54
+
55
+ # tags attribute is not default in logger
56
+ tags = None
57
+ if hasattr(record, "tags"):
58
+ tags = getattr(record, "tags")
59
+ event = {
60
+ "sender_type": self._sender_type,
61
+ "name": record.name,
62
+ "message": message,
63
+ "loglevel": record.levelname,
64
+ "filename": record.filename,
65
+ "traceback": exc_info,
66
+ "tags": tags,
67
+ }
68
+ self._streamer.insert_message(event)
69
+
70
+ def flush(self) -> None:
71
+ """Flushes the log records."""
72
+ self._streamer.flush()
73
+
74
+ def stop(self) -> None:
75
+ """Stops the thread."""
76
+ self._streamer.stop()
77
+
78
+
79
+ interceptor = ApplicationLogInterceptor(
80
+ host=settings.splight_platform_api_host,
81
+ access_id=settings.access_id,
82
+ secret_key=settings.secret_key,
83
+ process_id=settings.process_id,
84
+ sender_type=settings.process_type,
85
+ )
@@ -0,0 +1,63 @@
1
+ import os
2
+ from dataclasses import dataclass, field
3
+ from functools import partial
4
+ from typing import List
5
+
6
+ SECRET_DIR = "/etc/config"
7
+
8
+
9
+ class MissingEnvVariable(Exception):
10
+ ...
11
+
12
+
13
+ def load_variable(var_name: str) -> str:
14
+ secret_file = os.path.join(SECRET_DIR, var_name)
15
+ if os.path.exists(secret_file):
16
+ with open(secret_file) as f:
17
+ variable = f.read().strip()
18
+ elif os.getenv(var_name):
19
+ variable = os.getenv(var_name)
20
+ else:
21
+ raise MissingEnvVariable(f"{var_name} is missing")
22
+ return variable
23
+
24
+
25
+ def load_multiple_variable(var_names: List[str]) -> str:
26
+ value = None
27
+ for var_name in var_names:
28
+ try:
29
+ value = load_variable(var_name)
30
+ break
31
+ except MissingEnvVariable:
32
+ continue
33
+
34
+ if value is None:
35
+ raise MissingEnvVariable(f"On of {var_names} is missing")
36
+ return value
37
+
38
+
39
+ @dataclass
40
+ class SplightSettings:
41
+ """Class for holding Splight Runner settings."""
42
+
43
+ access_id: str = field(
44
+ default_factory=partial(load_variable, "SPLIGHT_ACCESS_ID")
45
+ )
46
+ secret_key: str = field(
47
+ default_factory=partial(load_variable, "SPLIGHT_SECRET_KEY")
48
+ )
49
+ splight_platform_api_host: str = field(
50
+ default_factory=partial(load_variable, "SPLIGHT_PLATFORM_API_HOST")
51
+ )
52
+ process_id: str = field(
53
+ default_factory=partial(
54
+ load_multiple_variable,
55
+ ["COMPONENT_ID", "COMPUTE_NODE_ID", "AGENT_ID"],
56
+ )
57
+ )
58
+ process_type: str = field(
59
+ default_factory=partial(load_variable, "PROCESS_TYPE")
60
+ )
61
+
62
+
63
+ settings = SplightSettings()
@@ -0,0 +1,39 @@
1
+ # This file is used for configuring splight runner in runtime. In particular
2
+ # loads some env vars and activate hooks for sending logs.
3
+ # All hooks or configurations for the Splight Runner should be loaded in
4
+ # this file.
5
+
6
+ import ast
7
+ import os
8
+ import sys
9
+
10
+ runner_active = ast.literal_eval(os.getenv("SPLIGHT_RUNNER_ACTIVE", "False"))
11
+
12
+ boot_directory = os.path.dirname(__file__)
13
+ root_directory = os.path.dirname(os.path.dirname(boot_directory))
14
+
15
+ path = list(sys.path)
16
+
17
+ if boot_directory in path:
18
+ del path[path.index(boot_directory)]
19
+
20
+ try:
21
+ from importlib.machinery import PathFinder
22
+
23
+ module_spec = PathFinder.find_spec("sitecustomize", path=path)
24
+ except ImportError as exc:
25
+ sys.stdout.write("Unable to import Splight Runner sitecustomie", exc)
26
+ sys.stdout.flush()
27
+ else:
28
+ if module_spec is not None:
29
+ module_spec.loader.load_module("sitecustomize")
30
+
31
+ if runner_active:
32
+ do_insert = root_directory not in sys.path
33
+ if do_insert:
34
+ sys.path.insert(0, root_directory)
35
+
36
+ import splight_runner.config
37
+
38
+ if do_insert:
39
+ del sys.path[sys.path.index(root_directory)]
@@ -0,0 +1,136 @@
1
+ import inspect
2
+ import sys
3
+ from typing import Any, Callable, Dict, List, Optional
4
+
5
+
6
+ class MissingCommandError(Exception):
7
+ """Exception"""
8
+
9
+ ...
10
+
11
+
12
+ class MissingArgument(Exception):
13
+ """Exception raises when there is a missing argument for a command"""
14
+
15
+ ...
16
+
17
+
18
+ class CommandAdmin:
19
+ """Class respobsible for taking registry of all the commands and callbaks"""
20
+
21
+ def __init__(self, name: str, version: str):
22
+ """Constructor
23
+
24
+ Parameters
25
+ ----------
26
+ name : str
27
+ Name of the application
28
+ version : str
29
+ The application version
30
+ """
31
+ self._name = name
32
+ self._version = version
33
+ self._commands: Dict[str, Callable] = {}
34
+ self._callback: Dict[str, Callable] = {}
35
+
36
+ def command(self, name: Optional[str] = None) -> Callable:
37
+ """Decorator to register a command
38
+
39
+ Parameters
40
+ ----------
41
+ name: Optional[str]
42
+ The name for the command
43
+
44
+ Returns
45
+ -------
46
+ Callable
47
+ The decorated function
48
+ """
49
+
50
+ def decorator(func: Callable) -> Callable:
51
+ func_name = name or func.__name__
52
+ self._commands.update({func_name: func})
53
+ return func
54
+
55
+ return decorator
56
+
57
+ def callback(self, name: Optional[str] = None) -> Callable:
58
+ """Decorator to register a callback
59
+
60
+ Parameters
61
+ ----------
62
+ name: Optional[str]
63
+ The name for the command
64
+
65
+ Returns
66
+ -------
67
+ Callable
68
+ The decorated function
69
+ """
70
+
71
+ def decorator(func: Callable) -> Callable:
72
+ func_name = name or func.__name__
73
+ self._callback.update({func_name: func})
74
+ return func
75
+
76
+ return decorator
77
+
78
+ def print_commands(self) -> None:
79
+ """Prints the commands and callbacks"""
80
+ title = f"{self._name} - {self._version}\n"
81
+ sys.stdout.write(title)
82
+ sys.stdout.write("{:=<{}}\n".format("", len(title)))
83
+ sys.stdout.write("Commands:\n")
84
+ for command in self._commands:
85
+ sys.stdout.write(f"{command: >20}\n")
86
+ sys.stdout.write("Callbacks:\n")
87
+ for callback in self._callback:
88
+ name = f"--{callback}"
89
+ sys.stdout.write(f"{name: >20}\n")
90
+
91
+ def __call__(self, *args: Any, **kwargs: Any):
92
+ """Dunder method to run a given command."""
93
+ if len(sys.argv) == 1:
94
+ raise MissingCommandError("No command provided")
95
+
96
+ command_name = sys.argv[1]
97
+ raw_args = sys.argv[2:]
98
+ if command_name.startswith("--"):
99
+ command = self._retrieve_callback(command_name.lstrip("--"))
100
+ else:
101
+ command = self._retrieve_command(command_name)
102
+
103
+ signature = inspect.signature(command)
104
+ command_args = self._parse_arguments(raw_args, signature=signature)
105
+ command(**command_args)
106
+
107
+ def _parse_arguments(
108
+ self, raw_args: List, signature: inspect.Signature
109
+ ) -> Dict:
110
+ arguments = {}
111
+ for idx, (name, parameter) in enumerate(signature.parameters.items()):
112
+ try:
113
+ raw_value = raw_args[idx]
114
+ except IndexError as exc:
115
+ raise MissingArgument(f"Missing argument {name}") from exc
116
+
117
+ argument = (
118
+ raw_value.split("=")[-1] if "=" in raw_value else raw_value
119
+ )
120
+ arg_value = parameter.annotation(argument)
121
+ arguments.update({name: arg_value})
122
+ return arguments
123
+
124
+ def _retrieve_command(self, command_name: str) -> Callable:
125
+ try:
126
+ command = self._commands[command_name]
127
+ except KeyError as exc:
128
+ sys.stderr.write(f"Command {command_name} not found: {exc}")
129
+ return command
130
+
131
+ def _retrieve_callback(self, command_name: str) -> Callable:
132
+ try:
133
+ command = self._callback[command_name]
134
+ except KeyError as exc:
135
+ sys.stderr.write(f"Callback {command_name} not found: {exc}")
136
+ return command