dinahosting-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.
dinacli/__init__.py ADDED
@@ -0,0 +1,27 @@
1
+ """Python client for the Dinahosting API."""
2
+
3
+ from .bootstrap import DinaClient
4
+ from .commands import COMMANDS
5
+ from .models import (
6
+ CommandSpec,
7
+ DinaApiError,
8
+ DinaConfigurationError,
9
+ DinaConnectionError,
10
+ DinaError,
11
+ DinaProtocolError,
12
+ DinaResponse,
13
+ )
14
+ from .settings import DEFAULT_ENDPOINT
15
+
16
+ __all__ = [
17
+ "COMMANDS",
18
+ "DEFAULT_ENDPOINT",
19
+ "CommandSpec",
20
+ "DinaApiError",
21
+ "DinaClient",
22
+ "DinaConfigurationError",
23
+ "DinaConnectionError",
24
+ "DinaError",
25
+ "DinaProtocolError",
26
+ "DinaResponse",
27
+ ]
dinacli/api.py ADDED
@@ -0,0 +1,119 @@
1
+ """Hierarchical access to the documented Dinahosting commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from keyword import iskeyword
7
+ from typing import Any
8
+
9
+ from .commands import COMMANDS
10
+ from .models import CommandExecutor, CommandSpec, DinaResponse
11
+
12
+ _WORD_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
13
+
14
+
15
+ def _python_name(name: str) -> str:
16
+ """Convert an API command segment into a Python attribute name."""
17
+ python_name = _WORD_BOUNDARY.sub("_", name).lower()
18
+ return f"{python_name}_" if iskeyword(python_name) else python_name
19
+
20
+
21
+ def _path(name: str) -> tuple[str, ...]:
22
+ """Return the Python namespace path for an API command name."""
23
+ return tuple(_python_name(segment) for segment in name.split("_"))
24
+
25
+
26
+ class Command:
27
+ """A callable documented Dinahosting command."""
28
+
29
+ def __init__(self, executor: CommandExecutor, spec: CommandSpec) -> None:
30
+ self._executor = executor
31
+ self._spec = spec
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ """Return the API command name sent to Dinahosting."""
36
+ return self._spec.name
37
+
38
+ @property
39
+ def required(self) -> frozenset[str]:
40
+ """Return documented required parameters."""
41
+ return self._spec.required
42
+
43
+ @property
44
+ def optional(self) -> frozenset[str]:
45
+ """Return documented optional parameters."""
46
+ return self._spec.optional
47
+
48
+ def __getattr__(self, name: str) -> Any:
49
+ """Resolve commands nested below this callable command."""
50
+ return getattr(CommandGroup(self._executor, _path(self.name)), name)
51
+
52
+ def __dir__(self) -> list[str]:
53
+ """Expose both command attributes and nested documented commands."""
54
+ group = CommandGroup(self._executor, _path(self.name))
55
+ return sorted(set(object.__dir__(self)) | set(dir(group)))
56
+
57
+ def __call__(self, **parameters: Any) -> DinaResponse:
58
+ """Validate documented parameters and execute the command."""
59
+ unknown = parameters.keys() - self._spec.parameters - {"simulate"}
60
+ if unknown:
61
+ raise TypeError(
62
+ f"{self.name} does not accept: {', '.join(sorted(unknown))}"
63
+ )
64
+ missing = self._spec.required - parameters.keys()
65
+ if missing:
66
+ raise TypeError(f"{self.name} requires: {', '.join(sorted(missing))}")
67
+ request_parameters = dict(parameters)
68
+ if "simulate" in request_parameters:
69
+ request_parameters["SIMULATE"] = request_parameters.pop("simulate")
70
+ return self._executor.call(self.name, **request_parameters)
71
+
72
+
73
+ class CommandGroup:
74
+ """A namespace that lazily resolves documented commands and subcommands."""
75
+
76
+ def __init__(self, executor: CommandExecutor, prefix: tuple[str, ...]) -> None:
77
+ self._executor = executor
78
+ self._prefix = prefix
79
+
80
+ def __getattr__(self, name: str) -> Any:
81
+ prefix = (*self._prefix, name)
82
+ matches = [
83
+ spec
84
+ for spec in COMMANDS.values()
85
+ if _path(spec.name)[: len(prefix)] == prefix
86
+ ]
87
+ if not matches:
88
+ raise AttributeError(name)
89
+ command = next((spec for spec in matches if _path(spec.name) == prefix), None)
90
+ if command is not None:
91
+ return Command(self._executor, command)
92
+ return CommandGroup(self._executor, prefix)
93
+
94
+ def __dir__(self) -> list[str]:
95
+ """Expose available child commands to interactive Python tooling."""
96
+ position = len(self._prefix)
97
+ return sorted(
98
+ {
99
+ _path(spec.name)[position]
100
+ for spec in COMMANDS.values()
101
+ if _path(spec.name)[:position] == self._prefix
102
+ and len(_path(spec.name)) > position
103
+ }
104
+ )
105
+
106
+
107
+ class Api(CommandGroup):
108
+ """Root namespace for every command in the supplied documentation."""
109
+
110
+ def __init__(self, executor: CommandExecutor) -> None:
111
+ super().__init__(executor, ())
112
+
113
+ def __getitem__(self, name: str) -> Command:
114
+ """Return a command by its exact documented API name."""
115
+ return Command(self._executor, COMMANDS[name])
116
+
117
+ def names(self) -> tuple[str, ...]:
118
+ """Return every documented API command name."""
119
+ return tuple(COMMANDS)
dinacli/bootstrap.py ADDED
@@ -0,0 +1,48 @@
1
+ """Public composition root for the default Dinahosting client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+ from typing import Self
8
+
9
+ from .client import ApiClient
10
+ from .configuration import settings_from_config, settings_from_environment
11
+ from .settings import DEFAULT_ENDPOINT, ClientSettings
12
+ from .transport import HttpTransport
13
+
14
+
15
+ class DinaClient(ApiClient):
16
+ """Create an API client with the default HTTP transport."""
17
+
18
+ def __init__(
19
+ self,
20
+ user: str,
21
+ password: str,
22
+ *,
23
+ endpoint: str = DEFAULT_ENDPOINT,
24
+ timeout: float = 20.0,
25
+ ) -> None:
26
+ settings = ClientSettings(user, password, endpoint, timeout)
27
+ super().__init__(settings, HttpTransport(settings.endpoint, settings.timeout))
28
+
29
+ @classmethod
30
+ def from_environment(cls, environ: Mapping[str, str] | None = None) -> Self:
31
+ """Build a client from DINA_USER and DINA_PASSWORD environment variables."""
32
+ return cls._from_settings(settings_from_environment(environ))
33
+
34
+ @classmethod
35
+ def from_config(
36
+ cls, path: str | Path, environ: Mapping[str, str] | None = None
37
+ ) -> Self:
38
+ """Build a client from a TOML file, overridden by DINA_* variables."""
39
+ return cls._from_settings(settings_from_config(path, environ))
40
+
41
+ @classmethod
42
+ def _from_settings(cls, settings: ClientSettings) -> Self:
43
+ return cls(
44
+ settings.user,
45
+ settings.password,
46
+ endpoint=settings.endpoint,
47
+ timeout=settings.timeout,
48
+ )
dinacli/cli.py ADDED
@@ -0,0 +1,174 @@
1
+ """Command-line interface for the Dinahosting API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+ from collections.abc import Callable, Sequence
10
+ from importlib.metadata import version
11
+ from math import isfinite
12
+ from typing import Any, NoReturn, TextIO
13
+
14
+ from .bootstrap import DinaClient
15
+ from .commands import COMMANDS
16
+ from .models import CommandSpec, DinaError, DinaResponse
17
+
18
+
19
+ def main(arguments: Sequence[str] | None = None) -> int:
20
+ """Run the dinacli command-line interface."""
21
+ parser = _parser()
22
+ namespace = parser.parse_args(arguments)
23
+ try:
24
+ handler: Callable[[argparse.Namespace], int] = namespace.handler
25
+ return handler(namespace)
26
+ except (DinaError, TypeError, ValueError) as error:
27
+ if namespace.output == "json":
28
+ _write_json({"error": str(error)}, sys.stderr)
29
+ else:
30
+ print(f"error: {error}", file=sys.stderr)
31
+ return 1
32
+
33
+
34
+ def _parser() -> argparse.ArgumentParser:
35
+ parser = argparse.ArgumentParser(
36
+ description="Execute documented Dinahosting API commands."
37
+ )
38
+ parser.add_argument(
39
+ "--version",
40
+ action="version",
41
+ version=f"%(prog)s {version('dinahosting-cli')}",
42
+ )
43
+ parser.add_argument("--config", help="TOML file with a [dina] section")
44
+ parser.add_argument("--user", help="Dinahosting API user")
45
+ parser.add_argument("--password", help="Dinahosting API password")
46
+ parser.add_argument("--endpoint", help="API endpoint URL")
47
+ parser.add_argument("--timeout", help="Request timeout in seconds")
48
+ parser.add_argument(
49
+ "--output",
50
+ choices=("json",),
51
+ help="Emit output as JSON, including errors on stderr",
52
+ )
53
+ actions = parser.add_subparsers(dest="action", required=True)
54
+
55
+ commands = actions.add_parser("commands", help="List documented API commands")
56
+ commands.set_defaults(handler=_list_commands)
57
+
58
+ describe = actions.add_parser("describe", help="Show a command's parameters")
59
+ describe.add_argument("command", help="Exact documented API command name")
60
+ describe.set_defaults(handler=_describe_command)
61
+
62
+ call = actions.add_parser("call", help="Execute a documented API command")
63
+ call.add_argument("command", help="Exact documented API command name")
64
+ call.add_argument(
65
+ "--param",
66
+ action="append",
67
+ default=[],
68
+ metavar="NAME=VALUE",
69
+ help="Parameter; VALUE is JSON when valid, otherwise text. Repeatable.",
70
+ )
71
+ call.add_argument(
72
+ "--simulate", action="store_true", help="Send the API SIMULATE parameter"
73
+ )
74
+ call.set_defaults(handler=_call_command)
75
+ return parser
76
+
77
+
78
+ def _list_commands(arguments: argparse.Namespace) -> int:
79
+ commands = sorted(COMMANDS)
80
+ if arguments.output == "json":
81
+ _write_json(commands)
82
+ else:
83
+ print("\n".join(commands))
84
+ return 0
85
+
86
+
87
+ def _describe_command(arguments: argparse.Namespace) -> int:
88
+ spec = _command_spec(arguments.command)
89
+ _write_json(
90
+ {
91
+ "command": spec.name,
92
+ "optional": sorted(spec.optional),
93
+ "required": sorted(spec.required),
94
+ }
95
+ )
96
+ return 0
97
+
98
+
99
+ def _call_command(arguments: argparse.Namespace) -> int:
100
+ spec = _command_spec(arguments.command)
101
+ parameters = _parameters(arguments.param)
102
+ if arguments.simulate:
103
+ parameters["simulate"] = True
104
+ response = _client(arguments).api[spec.name](**parameters)
105
+ _write_response(response)
106
+ return 0
107
+
108
+
109
+ def _command_spec(command: str) -> CommandSpec:
110
+ if command not in COMMANDS:
111
+ raise ValueError(f"Unknown command: {command}")
112
+ return COMMANDS[command]
113
+
114
+
115
+ def _parameters(values: Sequence[str]) -> dict[str, Any]:
116
+ parameters: dict[str, Any] = {}
117
+ for value in values:
118
+ name, separator, raw_value = value.partition("=")
119
+ if not separator or not name:
120
+ raise ValueError("Parameters must use NAME=VALUE")
121
+ if name in parameters:
122
+ raise ValueError(f"Parameter specified more than once: {name}")
123
+ try:
124
+ parameters[name] = json.loads(
125
+ raw_value,
126
+ parse_constant=_invalid_json_constant,
127
+ parse_float=_finite_json_number,
128
+ )
129
+ except json.JSONDecodeError:
130
+ parameters[name] = raw_value
131
+ return parameters
132
+
133
+
134
+ def _invalid_json_constant(value: str) -> NoReturn:
135
+ raise json.JSONDecodeError("Invalid JSON constant", value, 0)
136
+
137
+
138
+ def _finite_json_number(value: str) -> float:
139
+ number = float(value)
140
+ if not isfinite(number):
141
+ raise json.JSONDecodeError("JSON number is not finite", value, 0)
142
+ return number
143
+
144
+
145
+ def _client(arguments: argparse.Namespace) -> DinaClient:
146
+ environment = dict(os.environ)
147
+ for option, variable in (
148
+ ("user", "DINA_USER"),
149
+ ("password", "DINA_PASSWORD"),
150
+ ("endpoint", "DINA_ENDPOINT"),
151
+ ("timeout", "DINA_TIMEOUT"),
152
+ ):
153
+ value = getattr(arguments, option)
154
+ if value is not None:
155
+ environment[variable] = value
156
+ if arguments.config:
157
+ return DinaClient.from_config(arguments.config, environment)
158
+ return DinaClient.from_environment(environment)
159
+
160
+
161
+ def _write_response(response: DinaResponse) -> None:
162
+ _write_json(
163
+ {
164
+ "code": response.code,
165
+ "command": response.command,
166
+ "data": response.data,
167
+ "message": response.message,
168
+ "transaction_id": response.transaction_id,
169
+ }
170
+ )
171
+
172
+
173
+ def _write_json(value: object, stream: TextIO | None = None) -> None:
174
+ print(json.dumps(value, indent=2, sort_keys=True), file=stream)
dinacli/client.py ADDED
@@ -0,0 +1,29 @@
1
+ """Application service for documented Dinahosting API commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from .api import Api
8
+ from .models import DinaResponse, Transport, response_from_payload
9
+ from .settings import ClientSettings
10
+
11
+
12
+ class ApiClient:
13
+ """Execute commands through a supplied transport port."""
14
+
15
+ def __init__(self, settings: ClientSettings, transport: Transport) -> None:
16
+ self._settings = settings
17
+ self._transport = transport
18
+ self.api = Api(self)
19
+
20
+ def call(self, command: str, /, **parameters: Any) -> DinaResponse:
21
+ """Call a documented Dinahosting command and return its decoded response."""
22
+ if not isinstance(command, str) or not command.strip():
23
+ raise ValueError("Command is required")
24
+ payload = self._transport.post(
25
+ {**parameters, "command": command, "responseType": "Json"},
26
+ self._settings.user,
27
+ self._settings.password,
28
+ )
29
+ return response_from_payload(payload, command)