bricklogger 0.2.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.
- bricklogger/__init__.py +17 -0
- bricklogger/__main__.py +6 -0
- bricklogger/cli/__init__.py +107 -0
- bricklogger/cli/client.py +62 -0
- bricklogger/cli/config.py +199 -0
- bricklogger/cli/context.py +32 -0
- bricklogger/cli/daemon_commands.py +386 -0
- bricklogger/cli/guided.py +141 -0
- bricklogger/cli/init.py +184 -0
- bricklogger/cli/mcp_command.py +330 -0
- bricklogger/cli/model.py +422 -0
- bricklogger/cli/notify.py +93 -0
- bricklogger/cli/output.py +41 -0
- bricklogger/cli/plugins.py +410 -0
- bricklogger/cli/query.py +102 -0
- bricklogger/cli/roles.py +685 -0
- bricklogger/cli/serve.py +66 -0
- bricklogger/config/__init__.py +95 -0
- bricklogger/config/daemon.py +76 -0
- bricklogger/config/examples.py +110 -0
- bricklogger/config/instances.py +91 -0
- bricklogger/config/issues.py +72 -0
- bricklogger/config/loader.py +325 -0
- bricklogger/config/schema.py +425 -0
- bricklogger/config/validation.py +283 -0
- bricklogger/config/values.py +125 -0
- bricklogger/daemon/__init__.py +1 -0
- bricklogger/daemon/api.py +505 -0
- bricklogger/daemon/codes.py +53 -0
- bricklogger/daemon/core.py +1199 -0
- bricklogger/daemon/explorer.py +818 -0
- bricklogger/daemon/instances.py +432 -0
- bricklogger/daemon/jobs.py +133 -0
- bricklogger/daemon/logging.py +93 -0
- bricklogger/daemon/metadata.py +79 -0
- bricklogger/daemon/notify.py +790 -0
- bricklogger/daemon/overlay.py +171 -0
- bricklogger/daemon/plan.py +184 -0
- bricklogger/daemon/plugins.py +212 -0
- bricklogger/daemon/rules.py +306 -0
- bricklogger/daemon/run.py +62 -0
- bricklogger/daemon/sink.py +115 -0
- bricklogger/daemon/sparql.py +92 -0
- bricklogger/daemon/spool.py +164 -0
- bricklogger/daemon/state.py +650 -0
- bricklogger/docs/README.md +24 -0
- bricklogger/docs/architecture.md +808 -0
- bricklogger/docs/docker.md +376 -0
- bricklogger/docs/features/api.md +316 -0
- bricklogger/docs/features/cli.md +409 -0
- bricklogger/docs/features/configuration.md +285 -0
- bricklogger/docs/features/daemon.md +287 -0
- bricklogger/docs/features/destinations.md +101 -0
- bricklogger/docs/features/mcp.md +233 -0
- bricklogger/docs/features/notifications.md +191 -0
- bricklogger/docs/features/plugins.md +594 -0
- bricklogger/docs/features/sources.md +404 -0
- bricklogger/docs/features/web.md +248 -0
- bricklogger/docs/getting-started.md +254 -0
- bricklogger/docs/vision.md +82 -0
- bricklogger/mcp/__init__.py +7 -0
- bricklogger/mcp/http.py +95 -0
- bricklogger/mcp/instructions.py +47 -0
- bricklogger/mcp/server.py +609 -0
- bricklogger/model/Brick.ttl +57114 -0
- bricklogger/model/LICENSE-Brick.txt +32 -0
- bricklogger/model/__init__.py +109 -0
- bricklogger/model/diff.py +77 -0
- bricklogger/model/inference.py +257 -0
- bricklogger/model/ontology.py +22 -0
- bricklogger/model/prefixes.py +91 -0
- bricklogger/model/shapes.ttl +31 -0
- bricklogger/model/upload.py +121 -0
- bricklogger/model/versions.py +145 -0
- bricklogger/model/working_graph.py +93 -0
- bricklogger/ops/__init__.py +28 -0
- bricklogger/ops/catalogue.py +135 -0
- bricklogger/ops/client.py +216 -0
- bricklogger/ops/config.py +239 -0
- bricklogger/ops/context.py +61 -0
- bricklogger/ops/docs.py +57 -0
- bricklogger/ops/environment.py +588 -0
- bricklogger/ops/errors.py +53 -0
- bricklogger/ops/plugin_volume.py +257 -0
- bricklogger/ops/status.py +104 -0
- bricklogger/ops/tools.py +46 -0
- bricklogger/ops/tree.py +84 -0
- bricklogger/plugins/__init__.py +1 -0
- bricklogger/plugins/bacnet_ip/__init__.py +1 -0
- bricklogger/plugins/bacnet_ip/config.py +215 -0
- bricklogger/plugins/bacnet_ip/declaration.py +80 -0
- bricklogger/plugins/bacnet_ip/references.py +313 -0
- bricklogger/plugins/bacnet_ip/source.py +705 -0
- bricklogger/plugins/bacnet_ip/tools.py +594 -0
- bricklogger/plugins/bacnet_ip/values.py +126 -0
- bricklogger/plugins/timescaledb/__init__.py +1 -0
- bricklogger/plugins/timescaledb/config.py +26 -0
- bricklogger/plugins/timescaledb/declaration.py +17 -0
- bricklogger/plugins/timescaledb/destination.py +316 -0
- bricklogger/py.typed +0 -0
- bricklogger/sdk/__init__.py +73 -0
- bricklogger/sdk/bacnet.py +112 -0
- bricklogger/sdk/contract.py +299 -0
- bricklogger/sdk/declaration.py +169 -0
- bricklogger/sdk/registry.py +198 -0
- bricklogger/sdk/testing.py +269 -0
- bricklogger/web/__init__.py +1 -0
- bricklogger/web/app.py +967 -0
- bricklogger/web/client.py +131 -0
- bricklogger/web/forms.py +158 -0
- bricklogger/web/static/css/app.css +155 -0
- bricklogger/web/static/css/explorer.css +149 -0
- bricklogger/web/static/fonts/ibm-plex-mono-400.woff2 +0 -0
- bricklogger/web/static/fonts/ibm-plex-mono-500.woff2 +0 -0
- bricklogger/web/static/fonts/ibm-plex-mono-600.woff2 +0 -0
- bricklogger/web/static/fonts/ibm-plex-sans-var.woff2 +0 -0
- bricklogger/web/static/js/explorer.js +1185 -0
- bricklogger/web/static/vendor/LICENSES.md +12 -0
- bricklogger/web/static/vendor/codemirror-yaml.min.js +8 -0
- bricklogger/web/static/vendor/codemirror.min.css +8 -0
- bricklogger/web/static/vendor/codemirror.min.js +8 -0
- bricklogger/web/static/vendor/cytoscape.min.js +31 -0
- bricklogger/web/static/vendor/htmx.min.js +1 -0
- bricklogger/web/static/vendor/yasgui.min.css +1 -0
- bricklogger/web/static/vendor/yasgui.min.js +3 -0
- bricklogger/web/templates/base.html +62 -0
- bricklogger/web/templates/config.html +77 -0
- bricklogger/web/templates/daemon.html +6 -0
- bricklogger/web/templates/explorer.html +124 -0
- bricklogger/web/templates/instances.html +44 -0
- bricklogger/web/templates/login.html +24 -0
- bricklogger/web/templates/model.html +30 -0
- bricklogger/web/templates/notifications.html +7 -0
- bricklogger/web/templates/overview.html +7 -0
- bricklogger/web/templates/partials/config_result.html +13 -0
- bricklogger/web/templates/partials/daemon.html +20 -0
- bricklogger/web/templates/partials/diff.html +14 -0
- bricklogger/web/templates/partials/health.html +1 -0
- bricklogger/web/templates/partials/instances.html +69 -0
- bricklogger/web/templates/partials/job.html +31 -0
- bricklogger/web/templates/partials/model_versions.html +37 -0
- bricklogger/web/templates/partials/notifications.html +20 -0
- bricklogger/web/templates/partials/notify_test.html +9 -0
- bricklogger/web/templates/partials/overview.html +47 -0
- bricklogger/web/templates/partials/plugins.html +53 -0
- bricklogger/web/templates/partials/points.html +36 -0
- bricklogger/web/templates/partials/statusline.html +13 -0
- bricklogger/web/templates/partials/tool_result.html +26 -0
- bricklogger/web/templates/partials/unreachable.html +4 -0
- bricklogger/web/templates/plugins.html +6 -0
- bricklogger/web/templates/points.html +37 -0
- bricklogger/web/templates/query.html +24 -0
- bricklogger/web/texts.py +363 -0
- bricklogger-0.2.0.dist-info/METADATA +208 -0
- bricklogger-0.2.0.dist-info/RECORD +158 -0
- bricklogger-0.2.0.dist-info/WHEEL +4 -0
- bricklogger-0.2.0.dist-info/entry_points.txt +9 -0
- bricklogger-0.2.0.dist-info/licenses/LICENSE +21 -0
bricklogger/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Bricklogger: a data bridge for building automation, driven by a Brick model.
|
|
2
|
+
|
|
3
|
+
The package is laid out after the architecture in ``docs/architecture.md``:
|
|
4
|
+
|
|
5
|
+
- ``bricklogger.daemon`` — the long-running core that collects and writes,
|
|
6
|
+
- ``bricklogger.cli`` — the ``bricklogger`` command, the control plane,
|
|
7
|
+
- ``bricklogger.web`` — the web interface served by ``bricklogger serve``,
|
|
8
|
+
- ``bricklogger.mcp`` — the MCP server served by ``bricklogger mcp serve``, for
|
|
9
|
+
an AI assistant,
|
|
10
|
+
- ``bricklogger.ops`` — the operations the CLI and the MCP server share,
|
|
11
|
+
- ``bricklogger.sdk`` — the contract and the helpers plugins are built on,
|
|
12
|
+
- ``bricklogger.plugins`` — the built-in source (BACnet/IP) and
|
|
13
|
+
destination (TimescaleDB), registered through the same entry points as
|
|
14
|
+
external plugins.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__version__ = "0.2.0"
|
bricklogger/__main__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The ``bricklogger`` command: the control plane and the primary user interface.
|
|
2
|
+
|
|
3
|
+
Every command maps to one operation on the daemon's API; see
|
|
4
|
+
``docs/features/cli.md``. This module holds the root of the command tree and
|
|
5
|
+
the global options; the command groups live in their own modules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Annotated
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from bricklogger import __version__
|
|
16
|
+
from bricklogger.cli.config import rules_app, validate_command
|
|
17
|
+
from bricklogger.cli.context import CliContext
|
|
18
|
+
from bricklogger.cli.daemon_commands import daemon_app, points_command, status_command
|
|
19
|
+
from bricklogger.cli.init import init_command
|
|
20
|
+
from bricklogger.cli.mcp_command import mcp_app
|
|
21
|
+
from bricklogger.cli.model import model_app
|
|
22
|
+
from bricklogger.cli.notify import notify_app
|
|
23
|
+
from bricklogger.cli.plugins import plugins_app
|
|
24
|
+
from bricklogger.cli.query import query_command
|
|
25
|
+
from bricklogger.cli.roles import destinations_app, sources_app
|
|
26
|
+
from bricklogger.cli.serve import serve_command
|
|
27
|
+
from bricklogger.config import resolve_config_dir
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
name="bricklogger",
|
|
31
|
+
help="Data bridge for building automation, driven by a Brick model.",
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
add_completion=False,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _show_version(value: bool) -> None:
|
|
38
|
+
if value:
|
|
39
|
+
typer.echo(f"bricklogger {__version__}")
|
|
40
|
+
raise typer.Exit()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.callback()
|
|
44
|
+
def _root(
|
|
45
|
+
ctx: typer.Context,
|
|
46
|
+
config_dir: Annotated[
|
|
47
|
+
Path | None,
|
|
48
|
+
typer.Option(
|
|
49
|
+
"--config-dir",
|
|
50
|
+
help="The config directory; default $BRICKLOGGER_CONFIG_DIR, "
|
|
51
|
+
"then /etc/bricklogger when it exists, then ~/.config/bricklogger.",
|
|
52
|
+
show_default=False,
|
|
53
|
+
),
|
|
54
|
+
] = None,
|
|
55
|
+
api: Annotated[
|
|
56
|
+
str | None,
|
|
57
|
+
typer.Option(
|
|
58
|
+
"--api",
|
|
59
|
+
help="The daemon's API URL, for a deliberately exposed daemon; "
|
|
60
|
+
"default the binding in daemon.yaml.",
|
|
61
|
+
show_default=False,
|
|
62
|
+
),
|
|
63
|
+
] = None,
|
|
64
|
+
token: Annotated[
|
|
65
|
+
str | None,
|
|
66
|
+
typer.Option(
|
|
67
|
+
"--token",
|
|
68
|
+
envvar="BRICKLOGGER_API_TOKEN",
|
|
69
|
+
help="The API token; default the one in daemon.yaml.",
|
|
70
|
+
show_default=False,
|
|
71
|
+
),
|
|
72
|
+
] = None,
|
|
73
|
+
version: Annotated[
|
|
74
|
+
bool,
|
|
75
|
+
typer.Option(
|
|
76
|
+
"--version",
|
|
77
|
+
callback=_show_version,
|
|
78
|
+
is_eager=True,
|
|
79
|
+
help="Show the version and exit.",
|
|
80
|
+
),
|
|
81
|
+
] = False,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Collect data from a building's automation systems based on its Brick model."""
|
|
84
|
+
ctx.obj = CliContext(
|
|
85
|
+
config_dir=resolve_config_dir(config_dir), api_url=api, token=token
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
app.command("init")(init_command)
|
|
90
|
+
app.command("validate")(validate_command)
|
|
91
|
+
app.command("status")(status_command)
|
|
92
|
+
app.command("points")(points_command)
|
|
93
|
+
app.add_typer(daemon_app, name="daemon")
|
|
94
|
+
app.add_typer(sources_app, name="sources")
|
|
95
|
+
app.add_typer(destinations_app, name="destinations")
|
|
96
|
+
app.add_typer(rules_app, name="rules")
|
|
97
|
+
app.add_typer(notify_app, name="notify")
|
|
98
|
+
app.add_typer(plugins_app, name="plugins")
|
|
99
|
+
app.add_typer(model_app, name="model")
|
|
100
|
+
app.command("query")(query_command)
|
|
101
|
+
app.command("serve")(serve_command)
|
|
102
|
+
app.add_typer(mcp_app, name="mcp")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main() -> None:
|
|
106
|
+
"""Entry point of the ``bricklogger`` console script."""
|
|
107
|
+
app()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The CLI's client for the daemon's API: the shared client, with every failure
|
|
2
|
+
turned into the CLI's error message and exit."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Self
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from bricklogger.cli.context import CliContext
|
|
11
|
+
from bricklogger.cli.output import fail
|
|
12
|
+
from bricklogger.ops.client import DaemonApi, describe_problem, format_error
|
|
13
|
+
from bricklogger.ops.errors import OperationError
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
__all__ = ["ApiClient", "describe_problem", "format_error", "reachable_client"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ApiClient(DaemonApi):
|
|
22
|
+
"""Talks to one daemon; raises a CLI exit when it cannot be reached."""
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_context(cls, context: CliContext) -> Self:
|
|
26
|
+
try:
|
|
27
|
+
return cls.from_directory(
|
|
28
|
+
context.config_dir, context.api_url, context.token
|
|
29
|
+
)
|
|
30
|
+
except OperationError as exc:
|
|
31
|
+
raise fail(exc.message) from exc
|
|
32
|
+
|
|
33
|
+
def prefixes(self) -> dict[str, str]:
|
|
34
|
+
"""The active model's prefixes, for shortening URIs; empty when unknown."""
|
|
35
|
+
try:
|
|
36
|
+
found = self.get("/v1/models").get("prefixes")
|
|
37
|
+
except typer.Exit:
|
|
38
|
+
return {}
|
|
39
|
+
return dict(found) if isinstance(found, dict) else {}
|
|
40
|
+
|
|
41
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
42
|
+
try:
|
|
43
|
+
return super()._request(method, path, **kwargs)
|
|
44
|
+
except OperationError as exc:
|
|
45
|
+
raise fail(exc.message) from exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def reachable_client(context: CliContext) -> ApiClient | None:
|
|
49
|
+
"""The client when ``--api`` names a daemon or one answers at the binding.
|
|
50
|
+
|
|
51
|
+
``None`` means: work without a daemon. With ``--api`` the daemon is
|
|
52
|
+
required, so a client comes back whether it answers or not.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
client = ApiClient.from_context(context)
|
|
56
|
+
except typer.Exit:
|
|
57
|
+
if context.api_url is not None:
|
|
58
|
+
raise
|
|
59
|
+
return None
|
|
60
|
+
if context.api_url is not None or client.reachable():
|
|
61
|
+
return client
|
|
62
|
+
return None
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""The ``config`` views — one per owner of a file — and ``validate``.
|
|
2
|
+
|
|
3
|
+
``daemon config``, ``sources config``, ``destinations config`` and ``rules
|
|
4
|
+
config`` print their file as written; ``… config edit`` opens it in ``$EDITOR``
|
|
5
|
+
and writes it validated as a whole, through the API when a daemon answers and
|
|
6
|
+
directly to the file otherwise. ``validate`` checks the whole directory with
|
|
7
|
+
the daemon's validation, or asks the daemon with ``--api``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from typing import Annotated, Any
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
import typer
|
|
18
|
+
|
|
19
|
+
from bricklogger.cli.client import ApiClient, reachable_client
|
|
20
|
+
from bricklogger.cli.context import CliContext, cli_context, operations_of
|
|
21
|
+
from bricklogger.cli.output import fail, issues_table, print_json, stderr
|
|
22
|
+
from bricklogger.config import (
|
|
23
|
+
CONFIG_FILES,
|
|
24
|
+
ConfigIssue,
|
|
25
|
+
config_file,
|
|
26
|
+
parse_text,
|
|
27
|
+
read_texts,
|
|
28
|
+
validate_configuration,
|
|
29
|
+
)
|
|
30
|
+
from bricklogger.ops.config import write_file as write_config_file
|
|
31
|
+
from bricklogger.ops.errors import OperationError
|
|
32
|
+
from bricklogger.sdk.registry import PluginError, PluginRegistry
|
|
33
|
+
|
|
34
|
+
JsonFlag = Annotated[
|
|
35
|
+
bool, typer.Option("--json", help="Emit the API's JSON instead of a table.")
|
|
36
|
+
]
|
|
37
|
+
YAML = "application/yaml"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def edit_text(text: str) -> str | None:
|
|
41
|
+
"""Open the text in the user's editor; ``None`` when it was not saved."""
|
|
42
|
+
return click.edit(text, extension=".yaml", require_save=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def registry() -> PluginRegistry:
|
|
46
|
+
"""The installed plugins, or a plain failure when one cannot be loaded."""
|
|
47
|
+
try:
|
|
48
|
+
return PluginRegistry.from_entry_points()
|
|
49
|
+
except PluginError as exc:
|
|
50
|
+
raise fail(str(exc)) from exc
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def validate_command(ctx: typer.Context, json_output: JsonFlag = False) -> None:
|
|
54
|
+
"""Validate the whole config directory, including the plugins' schemas."""
|
|
55
|
+
context = cli_context(ctx)
|
|
56
|
+
if context.api_url is not None:
|
|
57
|
+
client = ApiClient.from_context(context)
|
|
58
|
+
data = client.post("/v1/config/validate")
|
|
59
|
+
report_validation(data, json_output, ok_line=f"ok: {client.base_url}")
|
|
60
|
+
return
|
|
61
|
+
result = validate_configuration(context.config_dir, registry())
|
|
62
|
+
summary = ""
|
|
63
|
+
if result.configuration is not None:
|
|
64
|
+
config = result.configuration
|
|
65
|
+
summary = ", ".join(
|
|
66
|
+
(
|
|
67
|
+
count(len(config.sources), "source"),
|
|
68
|
+
count(len(config.destinations), "destination"),
|
|
69
|
+
count(len(config.rules), "rule"),
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
report_validation(
|
|
73
|
+
result.as_dict(),
|
|
74
|
+
json_output,
|
|
75
|
+
ok_line=f"ok: {context.config_dir} ({summary})",
|
|
76
|
+
where=str(context.config_dir),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def report_validation(
|
|
81
|
+
data: Mapping[str, Any], json_output: bool, *, ok_line: str, where: str = ""
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Print a validation answer the way every command does, and exit on errors."""
|
|
84
|
+
if json_output:
|
|
85
|
+
print_json(data)
|
|
86
|
+
raise typer.Exit(0 if data["valid"] else 1)
|
|
87
|
+
if data["errors"]:
|
|
88
|
+
stderr.print(_issues_table("Errors", data["errors"]))
|
|
89
|
+
if data["warnings"]:
|
|
90
|
+
stderr.print(_issues_table("Warnings", data["warnings"]))
|
|
91
|
+
if not data["valid"]:
|
|
92
|
+
prefix = f"{where}: " if where else ""
|
|
93
|
+
typer.echo(f"{prefix}{count(len(data['errors']), 'error')}", err=True)
|
|
94
|
+
raise typer.Exit(1)
|
|
95
|
+
typer.echo(ok_line)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def count(number: int, noun: str) -> str:
|
|
99
|
+
return f"{number} {noun}{'' if number == 1 else 's'}"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _issues_table(title: str, issues: list[dict[str, Any]]) -> Any:
|
|
103
|
+
from bricklogger.cli.output import issues_table
|
|
104
|
+
|
|
105
|
+
return issues_table(title, [ConfigIssue.from_dict(issue) for issue in issues])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def config_view(name: str) -> typer.Typer:
|
|
109
|
+
"""A ``config`` group for one file: ``show`` prints it, ``edit`` edits it."""
|
|
110
|
+
if name not in CONFIG_FILES:
|
|
111
|
+
raise ValueError(f"unknown configuration file {name!r}")
|
|
112
|
+
app = typer.Typer(
|
|
113
|
+
no_args_is_help=True,
|
|
114
|
+
help=f"{name}.yaml: show it as written, or edit it in $EDITOR.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
@app.command("show")
|
|
118
|
+
def show(ctx: typer.Context, json_output: JsonFlag = False) -> None:
|
|
119
|
+
"""Print the file as written; environment variables are not expanded."""
|
|
120
|
+
show_file(cli_context(ctx), name, json_output)
|
|
121
|
+
|
|
122
|
+
@app.command("edit")
|
|
123
|
+
def edit(ctx: typer.Context) -> None:
|
|
124
|
+
"""Edit the file in $EDITOR; validated as a whole before it is written."""
|
|
125
|
+
edit_file(cli_context(ctx), name)
|
|
126
|
+
|
|
127
|
+
return app
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def show_file(context: CliContext, name: str, json_output: bool) -> None:
|
|
131
|
+
"""One file as written — through the API when asked for, from disk otherwise."""
|
|
132
|
+
if context.api_url is not None:
|
|
133
|
+
client = ApiClient.from_context(context)
|
|
134
|
+
if json_output:
|
|
135
|
+
parsed = client.get_text(f"/v1/config/{name}", accept="application/json")
|
|
136
|
+
print_json(json.loads(parsed))
|
|
137
|
+
return
|
|
138
|
+
typer.echo(f"# {client.base_url}")
|
|
139
|
+
typer.echo(client.get_text(f"/v1/config/{name}", accept=YAML).rstrip("\n"))
|
|
140
|
+
return
|
|
141
|
+
text = read_texts(context.config_dir)[name]
|
|
142
|
+
if json_output:
|
|
143
|
+
parsed, issues = parse_text(name, text)
|
|
144
|
+
if issues:
|
|
145
|
+
raise fail(str(issues[0]))
|
|
146
|
+
print_json(parsed)
|
|
147
|
+
return
|
|
148
|
+
path = config_file(context.config_dir, name)
|
|
149
|
+
typer.echo(f"# {context.config_dir}")
|
|
150
|
+
if text is None:
|
|
151
|
+
typer.echo(f"# {path.name} does not exist and is read as empty")
|
|
152
|
+
else:
|
|
153
|
+
typer.echo(text.rstrip("\n"))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def edit_file(context: CliContext, name: str) -> None:
|
|
157
|
+
"""Edit one file: through the API when a daemon answers, on disk otherwise."""
|
|
158
|
+
client = reachable_client(context)
|
|
159
|
+
if client is not None:
|
|
160
|
+
original = client.get_text(f"/v1/config/{name}", accept=YAML)
|
|
161
|
+
else:
|
|
162
|
+
original = read_texts(context.config_dir)[name] or ""
|
|
163
|
+
edited = edit_text(original)
|
|
164
|
+
if edited is None or edited == original:
|
|
165
|
+
typer.echo("no change")
|
|
166
|
+
return
|
|
167
|
+
result = write_file(context, client, name, edited)
|
|
168
|
+
if client is not None:
|
|
169
|
+
typer.echo(f"{name}.yaml written and applied")
|
|
170
|
+
else:
|
|
171
|
+
typer.echo(f"wrote {config_file(context.config_dir, name)}")
|
|
172
|
+
for warning in result.get("warnings", []):
|
|
173
|
+
typer.echo(f"warning: {warning['message']}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def write_file(
|
|
177
|
+
context: CliContext, client: ApiClient | None, name: str, text: str
|
|
178
|
+
) -> Mapping[str, Any]:
|
|
179
|
+
"""Write one file validated as a whole, and return what the validation said.
|
|
180
|
+
|
|
181
|
+
Through the API the daemon validates and applies; without one the directory
|
|
182
|
+
is validated with the change in place of the file, and nothing is written
|
|
183
|
+
when it does not hold.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
return write_config_file(operations_of(context), client, name, text)
|
|
187
|
+
except OperationError as exc:
|
|
188
|
+
raise failure(exc) from exc
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def failure(exc: OperationError) -> typer.Exit:
|
|
192
|
+
"""The exit of a failed operation: its issues as a table, then the message."""
|
|
193
|
+
if exc.issues:
|
|
194
|
+
stderr.print(issues_table("Errors", exc.issues))
|
|
195
|
+
return fail(exc.message)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
rules_app = typer.Typer(no_args_is_help=True, help="The rule set: rules.yaml.")
|
|
199
|
+
rules_app.add_typer(config_view("rules"), name="config")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""What every command needs from the root: the config directory and API access."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from bricklogger.ops import Operations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CliContext:
|
|
15
|
+
config_dir: Path
|
|
16
|
+
api_url: str | None = None
|
|
17
|
+
token: str | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cli_context(ctx: typer.Context) -> CliContext:
|
|
21
|
+
"""The context the root callback stored, whichever subcommand is running."""
|
|
22
|
+
found = ctx.find_object(CliContext)
|
|
23
|
+
if found is None:
|
|
24
|
+
raise RuntimeError("the CLI root has not set up its context")
|
|
25
|
+
return found
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def operations_of(context: CliContext) -> Operations:
|
|
29
|
+
"""The operations the command works through, from the root options."""
|
|
30
|
+
return Operations(
|
|
31
|
+
config_dir=context.config_dir, api_url=context.api_url, token=context.token
|
|
32
|
+
)
|