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.
Files changed (158) hide show
  1. bricklogger/__init__.py +17 -0
  2. bricklogger/__main__.py +6 -0
  3. bricklogger/cli/__init__.py +107 -0
  4. bricklogger/cli/client.py +62 -0
  5. bricklogger/cli/config.py +199 -0
  6. bricklogger/cli/context.py +32 -0
  7. bricklogger/cli/daemon_commands.py +386 -0
  8. bricklogger/cli/guided.py +141 -0
  9. bricklogger/cli/init.py +184 -0
  10. bricklogger/cli/mcp_command.py +330 -0
  11. bricklogger/cli/model.py +422 -0
  12. bricklogger/cli/notify.py +93 -0
  13. bricklogger/cli/output.py +41 -0
  14. bricklogger/cli/plugins.py +410 -0
  15. bricklogger/cli/query.py +102 -0
  16. bricklogger/cli/roles.py +685 -0
  17. bricklogger/cli/serve.py +66 -0
  18. bricklogger/config/__init__.py +95 -0
  19. bricklogger/config/daemon.py +76 -0
  20. bricklogger/config/examples.py +110 -0
  21. bricklogger/config/instances.py +91 -0
  22. bricklogger/config/issues.py +72 -0
  23. bricklogger/config/loader.py +325 -0
  24. bricklogger/config/schema.py +425 -0
  25. bricklogger/config/validation.py +283 -0
  26. bricklogger/config/values.py +125 -0
  27. bricklogger/daemon/__init__.py +1 -0
  28. bricklogger/daemon/api.py +505 -0
  29. bricklogger/daemon/codes.py +53 -0
  30. bricklogger/daemon/core.py +1199 -0
  31. bricklogger/daemon/explorer.py +818 -0
  32. bricklogger/daemon/instances.py +432 -0
  33. bricklogger/daemon/jobs.py +133 -0
  34. bricklogger/daemon/logging.py +93 -0
  35. bricklogger/daemon/metadata.py +79 -0
  36. bricklogger/daemon/notify.py +790 -0
  37. bricklogger/daemon/overlay.py +171 -0
  38. bricklogger/daemon/plan.py +184 -0
  39. bricklogger/daemon/plugins.py +212 -0
  40. bricklogger/daemon/rules.py +306 -0
  41. bricklogger/daemon/run.py +62 -0
  42. bricklogger/daemon/sink.py +115 -0
  43. bricklogger/daemon/sparql.py +92 -0
  44. bricklogger/daemon/spool.py +164 -0
  45. bricklogger/daemon/state.py +650 -0
  46. bricklogger/docs/README.md +24 -0
  47. bricklogger/docs/architecture.md +808 -0
  48. bricklogger/docs/docker.md +376 -0
  49. bricklogger/docs/features/api.md +316 -0
  50. bricklogger/docs/features/cli.md +409 -0
  51. bricklogger/docs/features/configuration.md +285 -0
  52. bricklogger/docs/features/daemon.md +287 -0
  53. bricklogger/docs/features/destinations.md +101 -0
  54. bricklogger/docs/features/mcp.md +233 -0
  55. bricklogger/docs/features/notifications.md +191 -0
  56. bricklogger/docs/features/plugins.md +594 -0
  57. bricklogger/docs/features/sources.md +404 -0
  58. bricklogger/docs/features/web.md +248 -0
  59. bricklogger/docs/getting-started.md +254 -0
  60. bricklogger/docs/vision.md +82 -0
  61. bricklogger/mcp/__init__.py +7 -0
  62. bricklogger/mcp/http.py +95 -0
  63. bricklogger/mcp/instructions.py +47 -0
  64. bricklogger/mcp/server.py +609 -0
  65. bricklogger/model/Brick.ttl +57114 -0
  66. bricklogger/model/LICENSE-Brick.txt +32 -0
  67. bricklogger/model/__init__.py +109 -0
  68. bricklogger/model/diff.py +77 -0
  69. bricklogger/model/inference.py +257 -0
  70. bricklogger/model/ontology.py +22 -0
  71. bricklogger/model/prefixes.py +91 -0
  72. bricklogger/model/shapes.ttl +31 -0
  73. bricklogger/model/upload.py +121 -0
  74. bricklogger/model/versions.py +145 -0
  75. bricklogger/model/working_graph.py +93 -0
  76. bricklogger/ops/__init__.py +28 -0
  77. bricklogger/ops/catalogue.py +135 -0
  78. bricklogger/ops/client.py +216 -0
  79. bricklogger/ops/config.py +239 -0
  80. bricklogger/ops/context.py +61 -0
  81. bricklogger/ops/docs.py +57 -0
  82. bricklogger/ops/environment.py +588 -0
  83. bricklogger/ops/errors.py +53 -0
  84. bricklogger/ops/plugin_volume.py +257 -0
  85. bricklogger/ops/status.py +104 -0
  86. bricklogger/ops/tools.py +46 -0
  87. bricklogger/ops/tree.py +84 -0
  88. bricklogger/plugins/__init__.py +1 -0
  89. bricklogger/plugins/bacnet_ip/__init__.py +1 -0
  90. bricklogger/plugins/bacnet_ip/config.py +215 -0
  91. bricklogger/plugins/bacnet_ip/declaration.py +80 -0
  92. bricklogger/plugins/bacnet_ip/references.py +313 -0
  93. bricklogger/plugins/bacnet_ip/source.py +705 -0
  94. bricklogger/plugins/bacnet_ip/tools.py +594 -0
  95. bricklogger/plugins/bacnet_ip/values.py +126 -0
  96. bricklogger/plugins/timescaledb/__init__.py +1 -0
  97. bricklogger/plugins/timescaledb/config.py +26 -0
  98. bricklogger/plugins/timescaledb/declaration.py +17 -0
  99. bricklogger/plugins/timescaledb/destination.py +316 -0
  100. bricklogger/py.typed +0 -0
  101. bricklogger/sdk/__init__.py +73 -0
  102. bricklogger/sdk/bacnet.py +112 -0
  103. bricklogger/sdk/contract.py +299 -0
  104. bricklogger/sdk/declaration.py +169 -0
  105. bricklogger/sdk/registry.py +198 -0
  106. bricklogger/sdk/testing.py +269 -0
  107. bricklogger/web/__init__.py +1 -0
  108. bricklogger/web/app.py +967 -0
  109. bricklogger/web/client.py +131 -0
  110. bricklogger/web/forms.py +158 -0
  111. bricklogger/web/static/css/app.css +155 -0
  112. bricklogger/web/static/css/explorer.css +149 -0
  113. bricklogger/web/static/fonts/ibm-plex-mono-400.woff2 +0 -0
  114. bricklogger/web/static/fonts/ibm-plex-mono-500.woff2 +0 -0
  115. bricklogger/web/static/fonts/ibm-plex-mono-600.woff2 +0 -0
  116. bricklogger/web/static/fonts/ibm-plex-sans-var.woff2 +0 -0
  117. bricklogger/web/static/js/explorer.js +1185 -0
  118. bricklogger/web/static/vendor/LICENSES.md +12 -0
  119. bricklogger/web/static/vendor/codemirror-yaml.min.js +8 -0
  120. bricklogger/web/static/vendor/codemirror.min.css +8 -0
  121. bricklogger/web/static/vendor/codemirror.min.js +8 -0
  122. bricklogger/web/static/vendor/cytoscape.min.js +31 -0
  123. bricklogger/web/static/vendor/htmx.min.js +1 -0
  124. bricklogger/web/static/vendor/yasgui.min.css +1 -0
  125. bricklogger/web/static/vendor/yasgui.min.js +3 -0
  126. bricklogger/web/templates/base.html +62 -0
  127. bricklogger/web/templates/config.html +77 -0
  128. bricklogger/web/templates/daemon.html +6 -0
  129. bricklogger/web/templates/explorer.html +124 -0
  130. bricklogger/web/templates/instances.html +44 -0
  131. bricklogger/web/templates/login.html +24 -0
  132. bricklogger/web/templates/model.html +30 -0
  133. bricklogger/web/templates/notifications.html +7 -0
  134. bricklogger/web/templates/overview.html +7 -0
  135. bricklogger/web/templates/partials/config_result.html +13 -0
  136. bricklogger/web/templates/partials/daemon.html +20 -0
  137. bricklogger/web/templates/partials/diff.html +14 -0
  138. bricklogger/web/templates/partials/health.html +1 -0
  139. bricklogger/web/templates/partials/instances.html +69 -0
  140. bricklogger/web/templates/partials/job.html +31 -0
  141. bricklogger/web/templates/partials/model_versions.html +37 -0
  142. bricklogger/web/templates/partials/notifications.html +20 -0
  143. bricklogger/web/templates/partials/notify_test.html +9 -0
  144. bricklogger/web/templates/partials/overview.html +47 -0
  145. bricklogger/web/templates/partials/plugins.html +53 -0
  146. bricklogger/web/templates/partials/points.html +36 -0
  147. bricklogger/web/templates/partials/statusline.html +13 -0
  148. bricklogger/web/templates/partials/tool_result.html +26 -0
  149. bricklogger/web/templates/partials/unreachable.html +4 -0
  150. bricklogger/web/templates/plugins.html +6 -0
  151. bricklogger/web/templates/points.html +37 -0
  152. bricklogger/web/templates/query.html +24 -0
  153. bricklogger/web/texts.py +363 -0
  154. bricklogger-0.2.0.dist-info/METADATA +208 -0
  155. bricklogger-0.2.0.dist-info/RECORD +158 -0
  156. bricklogger-0.2.0.dist-info/WHEEL +4 -0
  157. bricklogger-0.2.0.dist-info/entry_points.txt +9 -0
  158. bricklogger-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -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"
@@ -0,0 +1,6 @@
1
+ """``python -m bricklogger``: the same as the ``bricklogger`` command."""
2
+
3
+ from bricklogger.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ )