canonic 0.5.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 (129) hide show
  1. canonic/__init__.py +0 -0
  2. canonic/airgap.py +139 -0
  3. canonic/cli/__init__.py +0 -0
  4. canonic/cli/_errors.py +112 -0
  5. canonic/cli/app.py +106 -0
  6. canonic/cli/commands/__init__.py +105 -0
  7. canonic/cli/commands/apply.py +88 -0
  8. canonic/cli/commands/assertions.py +101 -0
  9. canonic/cli/commands/completion.py +10 -0
  10. canonic/cli/commands/connection.py +286 -0
  11. canonic/cli/commands/evaluate.py +108 -0
  12. canonic/cli/commands/ingest.py +289 -0
  13. canonic/cli/commands/knowledge.py +201 -0
  14. canonic/cli/commands/mcp.py +206 -0
  15. canonic/cli/commands/outcome.py +104 -0
  16. canonic/cli/commands/overview.py +47 -0
  17. canonic/cli/commands/query.py +85 -0
  18. canonic/cli/commands/report.py +297 -0
  19. canonic/cli/commands/review.py +150 -0
  20. canonic/cli/commands/setup.py +1009 -0
  21. canonic/cli/commands/sl.py +140 -0
  22. canonic/cli/commands/sql.py +49 -0
  23. canonic/cli/commands/status.py +70 -0
  24. canonic/cli/setup_state.py +74 -0
  25. canonic/compiler/__init__.py +19 -0
  26. canonic/compiler/dialect.py +195 -0
  27. canonic/compiler/joins.py +289 -0
  28. canonic/compiler/pipeline.py +2293 -0
  29. canonic/compiler/query.py +137 -0
  30. canonic/compiler/result.py +179 -0
  31. canonic/config.py +367 -0
  32. canonic/connectors/__init__.py +0 -0
  33. canonic/connectors/acquisition.py +365 -0
  34. canonic/connectors/base.py +424 -0
  35. canonic/connectors/dbt.py +493 -0
  36. canonic/connectors/duckdb.py +322 -0
  37. canonic/connectors/evidence.py +204 -0
  38. canonic/connectors/factory.py +138 -0
  39. canonic/connectors/looker.py +274 -0
  40. canonic/connectors/metabase.py +303 -0
  41. canonic/connectors/notion.py +284 -0
  42. canonic/connectors/postgres.py +513 -0
  43. canonic/connectors/readonly.py +31 -0
  44. canonic/connectors/redshift.py +564 -0
  45. canonic/connectors/relation_filter.py +43 -0
  46. canonic/connectors/sqlite.py +271 -0
  47. canonic/connectors/web.py +134 -0
  48. canonic/contract.py +3 -0
  49. canonic/contracts/__init__.py +75 -0
  50. canonic/contracts/assertions.py +225 -0
  51. canonic/contracts/bootstrap.py +76 -0
  52. canonic/contracts/finality.py +160 -0
  53. canonic/contracts/loader.py +173 -0
  54. canonic/contracts/models.py +444 -0
  55. canonic/contracts/resolver.py +461 -0
  56. canonic/contracts/validate.py +407 -0
  57. canonic/core/__init__.py +0 -0
  58. canonic/core/models.py +332 -0
  59. canonic/core/overview.py +66 -0
  60. canonic/core/service.py +837 -0
  61. canonic/credentials.py +60 -0
  62. canonic/eval/__init__.py +22 -0
  63. canonic/eval/candidates.py +82 -0
  64. canonic/eval/dataset.py +130 -0
  65. canonic/eval/datasets/draft_grain.jsonl +13 -0
  66. canonic/eval/datasets/reconcile_contradictions.jsonl +12 -0
  67. canonic/eval/harness.py +319 -0
  68. canonic/eval/models.py +81 -0
  69. canonic/eval/report.py +120 -0
  70. canonic/eval/scoring.py +70 -0
  71. canonic/exc.py +349 -0
  72. canonic/feedback/__init__.py +19 -0
  73. canonic/feedback/evidence.py +103 -0
  74. canonic/feedback/history.py +152 -0
  75. canonic/feedback/report.py +65 -0
  76. canonic/ingestion/__init__.py +89 -0
  77. canonic/ingestion/autopr.py +151 -0
  78. canonic/ingestion/builder.py +732 -0
  79. canonic/ingestion/emitter.py +431 -0
  80. canonic/ingestion/examples.py +213 -0
  81. canonic/ingestion/models.py +178 -0
  82. canonic/ingestion/pending.py +278 -0
  83. canonic/ingestion/pipeline.py +313 -0
  84. canonic/ingestion/reconciliation.py +654 -0
  85. canonic/ingestion/source.py +228 -0
  86. canonic/ingestion/validation.py +315 -0
  87. canonic/instrumentation/__init__.py +1 -0
  88. canonic/instrumentation/events.py +83 -0
  89. canonic/instrumentation/models.py +186 -0
  90. canonic/instrumentation/report.py +432 -0
  91. canonic/instrumentation/telemetry.py +66 -0
  92. canonic/knowledge/__init__.py +73 -0
  93. canonic/knowledge/drift.py +100 -0
  94. canonic/knowledge/embeddings.py +136 -0
  95. canonic/knowledge/index.py +133 -0
  96. canonic/knowledge/loader.py +196 -0
  97. canonic/knowledge/models.py +89 -0
  98. canonic/knowledge/pruning.py +123 -0
  99. canonic/knowledge/rendering.py +58 -0
  100. canonic/knowledge/resolve.py +54 -0
  101. canonic/knowledge/results.py +130 -0
  102. canonic/knowledge/retrieval.py +289 -0
  103. canonic/knowledge/scope.py +88 -0
  104. canonic/knowledge/traversal.py +146 -0
  105. canonic/knowledge/validation.py +176 -0
  106. canonic/llm_providers.py +75 -0
  107. canonic/log.py +102 -0
  108. canonic/mcp/__init__.py +0 -0
  109. canonic/mcp/daemon.py +270 -0
  110. canonic/mcp/errors.py +54 -0
  111. canonic/mcp/server.py +340 -0
  112. canonic/runtime/__init__.py +17 -0
  113. canonic/runtime/drafter.py +447 -0
  114. canonic/runtime/embeddings.py +109 -0
  115. canonic/runtime/extraction.py +138 -0
  116. canonic/runtime/generation.py +249 -0
  117. canonic/runtime/models.py +41 -0
  118. canonic/runtime/resolver.py +40 -0
  119. canonic/semantic/__init__.py +43 -0
  120. canonic/semantic/loader.py +114 -0
  121. canonic/semantic/models.py +281 -0
  122. canonic/trust/__init__.py +10 -0
  123. canonic/trust/models.py +47 -0
  124. canonic/trust/scorer.py +94 -0
  125. canonic/trust/signals.py +115 -0
  126. canonic-0.5.0.dist-info/METADATA +331 -0
  127. canonic-0.5.0.dist-info/RECORD +129 -0
  128. canonic-0.5.0.dist-info/WHEEL +4 -0
  129. canonic-0.5.0.dist-info/entry_points.txt +3 -0
canonic/__init__.py ADDED
File without changes
canonic/airgap.py ADDED
@@ -0,0 +1,139 @@
1
+ """Air-gapped egress guard (SPEC-E10 §4).
2
+
3
+ The enforced privacy differentiator: under ``runtime.air_gapped: true`` no warehouse
4
+ content or context may leave the machine/network. :class:`EgressPolicy` is the single
5
+ source of truth for *what counts as local*, shared by load-time config validation
6
+ (``canonic/config.py``) and call-time runtime enforcement (``canonic/runtime/generation.py``)
7
+ so the allowlist logic is never duplicated.
8
+
9
+ Default policy is **localhost-only** (loopback + the name ``localhost``): the tightest
10
+ guarantee. A separate on-prem inference host must be opted in explicitly via
11
+ ``runtime.allow_cidrs``. Dependency-light (stdlib only) so the config layer can import it
12
+ without pulling in litellm.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ipaddress
18
+ import socket
19
+ from typing import TYPE_CHECKING
20
+ from urllib.parse import urlsplit
21
+
22
+ from canonic.exc import AirGappedViolation
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Sequence
26
+
27
+ __all__ = ["LOCAL_REF_SCHEMES", "EgressPolicy", "guard_telemetry"]
28
+
29
+ #: Secret-reference schemes that resolve on-machine. A ``*_ref`` using any other scheme
30
+ #: (e.g. a future ``vault:``/``https:`` remote secret service) is rejected under air-gapped.
31
+ LOCAL_REF_SCHEMES: frozenset[str] = frozenset({"env", "keyring", "file"})
32
+
33
+ #: Always-allowed loopback ranges, independent of any configured allowlist.
34
+ _LOOPBACK_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (
35
+ ipaddress.ip_network("127.0.0.0/8"),
36
+ ipaddress.ip_network("::1/128"),
37
+ )
38
+
39
+
40
+ def guard_telemetry(*, air_gapped: bool, telemetry_enabled: bool) -> None:
41
+ """Raise AirGappedViolation if telemetry would be on while air-gapped (SPEC-E16 §9, S5).
42
+
43
+ The single chokepoint for the air-gapped telemetry guarantee. Load-time config validation
44
+ and any future opt-in telemetry-enable path both call this so the guard cannot be bypassed.
45
+ """
46
+ if air_gapped and telemetry_enabled:
47
+ raise AirGappedViolation(
48
+ "air-gapped: telemetry.enabled must be false when runtime.air_gapped is true"
49
+ )
50
+
51
+
52
+ class EgressPolicy:
53
+ """Allowlist guard for air-gapped egress (SPEC-E10 §4).
54
+
55
+ Loopback addresses and the literal hostname ``localhost`` are always allowed.
56
+ ``allow_cidrs`` adds explicit private/LAN ranges for an on-prem inference host. An
57
+ instance is only created when ``runtime.air_gapped`` is set — its mere existence
58
+ means enforcement is active.
59
+ """
60
+
61
+ def __init__(self, *, allow_cidrs: Sequence[str] = ()) -> None:
62
+ extra: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
63
+ for cidr in allow_cidrs:
64
+ try:
65
+ extra.append(ipaddress.ip_network(cidr, strict=False))
66
+ except ValueError as exc:
67
+ raise AirGappedViolation(
68
+ f"runtime.allow_cidrs entry {cidr!r} is not a valid CIDR: {exc}"
69
+ ) from exc
70
+ self._networks: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (
71
+ *_LOOPBACK_NETWORKS,
72
+ *extra,
73
+ )
74
+
75
+ def check_url(self, url: str, *, what: str) -> None:
76
+ """Raise :class:`AirGappedViolation` if ``url``'s host is not allowlisted.
77
+
78
+ Args:
79
+ url: The endpoint URL whose host is checked (e.g. ``llm.base_url``).
80
+ what: Names the configuration source for a clear message.
81
+ """
82
+ host = urlsplit(url).hostname
83
+ if host is None:
84
+ raise AirGappedViolation(f"air-gapped: {what} {url!r} has no resolvable host to check")
85
+ if not self.is_allowed_host(host):
86
+ raise AirGappedViolation(
87
+ f"air-gapped: {what} host {host!r} is not local or allowlisted — "
88
+ f"add it to runtime.allow_cidrs or use a local endpoint"
89
+ )
90
+
91
+ def check_ref_local(self, ref: str, *, what: str) -> None:
92
+ """Raise :class:`AirGappedViolation` if ``ref`` uses a non-local secret scheme.
93
+
94
+ Forward-proofs against a future remote secret service: only ``env:``/``keyring:``/
95
+ ``file:`` resolve on-machine (:data:`LOCAL_REF_SCHEMES`).
96
+ """
97
+ scheme = ref.partition(":")[0]
98
+ if scheme not in LOCAL_REF_SCHEMES:
99
+ raise AirGappedViolation(
100
+ f"air-gapped: {what} {ref!r} uses non-local secret scheme {scheme!r}; "
101
+ f"only {', '.join(sorted(LOCAL_REF_SCHEMES))} are allowed"
102
+ )
103
+
104
+ def is_allowed_host(self, host: str) -> bool:
105
+ """Whether ``host`` resolves only to allowlisted addresses.
106
+
107
+ An IP literal is checked directly. ``localhost`` is special-cased to avoid relying
108
+ on resolver configuration. Any other hostname is resolved via ``getaddrinfo`` and
109
+ **every** resolved address must be allowlisted — fail-closed when resolution yields
110
+ nothing or any address falls outside the allowlist.
111
+ """
112
+ if host == "localhost":
113
+ return True
114
+
115
+ literal = self._as_ip(host)
116
+ if literal is not None:
117
+ return self._is_allowed_address(literal)
118
+
119
+ try:
120
+ infos = socket.getaddrinfo(host, None)
121
+ except socket.gaierror:
122
+ return False
123
+ addresses = {str(info[4][0]) for info in infos}
124
+ if not addresses:
125
+ return False
126
+ return all(
127
+ (addr := self._as_ip(raw)) is not None and self._is_allowed_address(addr)
128
+ for raw in addresses
129
+ )
130
+
131
+ def _is_allowed_address(self, address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
132
+ return any(address in network for network in self._networks)
133
+
134
+ @staticmethod
135
+ def _as_ip(value: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
136
+ try:
137
+ return ipaddress.ip_address(value)
138
+ except ValueError:
139
+ return None
File without changes
canonic/cli/_errors.py ADDED
@@ -0,0 +1,112 @@
1
+ """Shared CLI plumbing: the global context object and structured error handling.
2
+
3
+ Adapters do transport translation only (SPEC §2.1): here we turn a raised
4
+ ``CanonicError`` into the headless exit code from the canonical registry (§6.1) and
5
+ print a structured ``{code, message}`` payload — JSON under ``--json``, Rich text
6
+ otherwise — instead of leaking a traceback.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from functools import wraps
16
+ from typing import Any
17
+
18
+ import typer
19
+ from rich.console import Console
20
+
21
+ from canonic.exc import CanonicError
22
+ from canonic.mcp.errors import error_payload
23
+
24
+ _err_console = Console(stderr=True)
25
+
26
+
27
+ @dataclass
28
+ class CliContext:
29
+ """Per-invocation state shared from the root callback to every subcommand."""
30
+
31
+ json_output: bool = False
32
+
33
+
34
+ def get_cli_context(ctx: typer.Context) -> CliContext:
35
+ """Return the ``CliContext`` for this invocation, creating a default if absent."""
36
+ if not isinstance(ctx.obj, CliContext):
37
+ ctx.obj = CliContext()
38
+ return ctx.obj
39
+
40
+
41
+ def emit_error(err: CanonicError, *, json_output: bool) -> None:
42
+ """Print a structured error to stderr in the requested format.
43
+
44
+ Reuses :func:`canonic.mcp.errors.error_payload` for the wire shape so the CLI's
45
+ ``--json`` output — including ``candidates`` — stays byte-for-byte consistent
46
+ with the MCP adapter (adapter parity, SPEC §2.1), rather than reimplementing a
47
+ second, looser candidate serialization.
48
+ """
49
+ payload = error_payload(err)
50
+ if not payload["message"]:
51
+ payload["message"] = err.__class__.__name__
52
+ # ASSERTION_FAILED carries which check diverged (SPEC-Fuller-E15 §3.3).
53
+ assertion_id = getattr(err, "assertion_id", None)
54
+ if assertion_id is not None:
55
+ payload["assertion_id"] = assertion_id
56
+ if json_output:
57
+ sys.stderr.write(json.dumps(payload) + "\n")
58
+ return
59
+
60
+ _err_console.print(f"[red]error[/red] [bold]{payload['code']}[/bold]: {payload['message']}")
61
+ candidates = err.candidates
62
+ if not candidates:
63
+ return
64
+ for i, c in enumerate(candidates, 1):
65
+ if hasattr(c, "route"):
66
+ _err_console.print(f" path {i}: {c.route}", markup=False)
67
+ _err_console.print(f" via: {c.via}", markup=False)
68
+ elif isinstance(c, (list, tuple)):
69
+ owner = getattr(err, "owner", "")
70
+ prefix = f"{owner} → " if owner else ""
71
+ _err_console.print(f" path {i}: {prefix}{' → '.join(c)}", markup=False)
72
+ elif hasattr(c, "metric"):
73
+ _err_console.print(f" candidate {i}: {c.metric}", markup=False)
74
+ else:
75
+ _err_console.print(f" candidate {i}: {c}", markup=False)
76
+ if hasattr(candidates[0], "via"):
77
+ _err_console.print(
78
+ ' hint: re-issue with "via": <chosen path\'s via list> to select a path',
79
+ markup=False,
80
+ )
81
+ elif isinstance(candidates[0], (list, tuple)):
82
+ _err_console.print(
83
+ ' hint: add "via": ["<first-hop>"] to your query to select a path',
84
+ markup=False,
85
+ )
86
+ elif isinstance(candidates[0], str):
87
+ _err_console.print(
88
+ f" hint: qualify with one of the candidates above, e.g. --dimensions {candidates[0]}",
89
+ markup=False,
90
+ )
91
+
92
+
93
+ def handle_errors[F: Callable[..., Any]](func: F) -> F:
94
+ """Wrap a command so a raised ``CanonicError`` becomes a structured exit.
95
+
96
+ The wrapped command must declare a ``ctx: typer.Context`` parameter so the
97
+ handler can read ``--json`` mode from the shared ``CliContext``.
98
+ """
99
+
100
+ @wraps(func)
101
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
102
+ ctx = kwargs.get("ctx")
103
+ if ctx is None:
104
+ ctx = next((a for a in args if isinstance(a, typer.Context)), None)
105
+ json_output = get_cli_context(ctx).json_output if ctx is not None else False
106
+ try:
107
+ return func(*args, **kwargs)
108
+ except CanonicError as err:
109
+ emit_error(err, json_output=json_output)
110
+ raise typer.Exit(err.exit_code) from err
111
+
112
+ return wrapper # type: ignore[return-value]
canonic/cli/app.py ADDED
@@ -0,0 +1,106 @@
1
+ """Root Typer application for the ``canonic`` CLI (E7 serving surface).
2
+
3
+ This adapter does transport translation only (SPEC §2.1): it parses options,
4
+ selects output format (``--json``), and dispatches to capability commands. All
5
+ real logic lives behind the core in later epics.
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from typing import Annotated
10
+
11
+ import typer
12
+
13
+ from canonic.cli._errors import get_cli_context
14
+ from canonic.cli.commands import (
15
+ apply,
16
+ assertions,
17
+ completion,
18
+ connection,
19
+ evaluate,
20
+ ingest,
21
+ knowledge,
22
+ mcp,
23
+ outcome,
24
+ overview,
25
+ query,
26
+ report,
27
+ review,
28
+ setup,
29
+ sl,
30
+ sql,
31
+ status,
32
+ )
33
+
34
+ app = typer.Typer(
35
+ name="canonic",
36
+ help="The Open Context Layer for Data Agents.",
37
+ add_completion=False,
38
+ no_args_is_help=False,
39
+ )
40
+
41
+
42
+ def _version_callback(value: bool) -> None:
43
+ if not value:
44
+ return
45
+ try:
46
+ typer.echo(version("canonic"))
47
+ except PackageNotFoundError: # pragma: no cover — only when running uninstalled
48
+ typer.echo("unknown")
49
+ raise typer.Exit(0)
50
+
51
+
52
+ @app.callback(invoke_without_command=True)
53
+ def main(
54
+ ctx: typer.Context,
55
+ json_output: Annotated[
56
+ bool,
57
+ typer.Option("--json", "-j", help="Emit raw structured JSON instead of formatted text."),
58
+ ] = False,
59
+ _version: Annotated[
60
+ bool,
61
+ typer.Option(
62
+ "--version",
63
+ help="Show the canonic version and exit.",
64
+ callback=_version_callback,
65
+ is_eager=True,
66
+ ),
67
+ ] = False,
68
+ ) -> None:
69
+ """Canonic CLI entry point: sets global flags and dispatches subcommands."""
70
+ get_cli_context(ctx).json_output = json_output
71
+
72
+ from canonic.log import _effective_log_params, configure_logging
73
+
74
+ level, file, format = _effective_log_params("WARNING", None)
75
+ configure_logging(level=level, file=file, format=format)
76
+
77
+ if ctx.invoked_subcommand is None:
78
+ if json_output:
79
+ typer.echo("interactive mode is not available with --json; run `canonic --help`.")
80
+ raise typer.Exit(2)
81
+ from canonic.cli.commands.setup import run_interactive
82
+
83
+ run_interactive()
84
+ raise typer.Exit(0)
85
+
86
+
87
+ # Command groups (multi-command).
88
+ app.add_typer(connection.app, name="connection")
89
+ app.add_typer(sl.app, name="sl")
90
+ app.add_typer(mcp.app, name="mcp")
91
+ app.add_typer(knowledge.app, name="knowledge")
92
+ app.add_typer(evaluate.app, name="eval")
93
+ app.add_typer(outcome.app, name="outcome")
94
+
95
+ # Top-level single commands.
96
+ app.command("overview")(overview.overview)
97
+ app.command("setup")(setup.setup)
98
+ app.command("ingest")(ingest.ingest)
99
+ app.command("review")(review.review)
100
+ app.command("apply")(apply.apply)
101
+ app.command("query")(query.query)
102
+ app.command("assert")(assertions.assert_)
103
+ app.command("sql")(sql.sql)
104
+ app.command("status")(status.status)
105
+ app.command("report")(report.report)
106
+ app.command("completion")(completion.completion)
@@ -0,0 +1,105 @@
1
+ """CLI subcommand groups. Each module exposes a ``typer.Typer()`` named ``app``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from canonic.cli._errors import get_cli_context
12
+ from canonic.compiler import SemanticQuery
13
+ from canonic.compiler.query import parse_filter_flag
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+ from canonic.core.service import CanonicService
19
+
20
+ _console = Console()
21
+
22
+
23
+ def not_implemented(ctx: typer.Context, feature: str) -> None:
24
+ """Print a uniform ``not implemented yet`` notice and exit 0 (no traceback).
25
+
26
+ Stub for capability commands whose logic lands in later epics (E2/E5/E6/E8/E9).
27
+ """
28
+ json_output = get_cli_context(ctx).json_output
29
+ if json_output:
30
+ typer.echo(json.dumps({"status": "not_implemented", "feature": feature}))
31
+ else:
32
+ _console.print(f"[yellow]{feature}[/yellow]: not implemented yet")
33
+ raise typer.Exit(0)
34
+
35
+
36
+ def load_service(ctx: typer.Context) -> CanonicService:
37
+ """Locate the enclosing canonic project and build its :class:`CanonicService`.
38
+
39
+ Capability commands (``query``, ``sql``) share this so project discovery and
40
+ service wiring live in one place. Exits 1 with a clear message — not a
41
+ traceback — when run outside a project.
42
+ """
43
+ from canonic.config import find_project_root
44
+ from canonic.core.service import CanonicService
45
+
46
+ root = find_project_root()
47
+ if root is None:
48
+ msg = "no canonic project found; run from inside a project directory"
49
+ if get_cli_context(ctx).json_output:
50
+ typer.echo(json.dumps({"error": msg}))
51
+ else:
52
+ _console.print(f"[red]error:[/red] {msg}")
53
+ raise typer.Exit(1)
54
+
55
+ try:
56
+ from canonic.config import load_config
57
+ from canonic.log import _effective_log_params, configure_logging
58
+
59
+ cfg = load_config(root / "canonic.yaml")
60
+ level, file, format = _effective_log_params(
61
+ cfg.logging.level, cfg.logging.file, cfg.logging.format
62
+ )
63
+ configure_logging(level=level, file=file, format=format)
64
+ except Exception:
65
+ pass # CanonicService.from_project below will fail with a clearer error if config is broken
66
+
67
+ return CanonicService.from_project(root)
68
+
69
+
70
+ def _expand_csv(values: list[str] | None) -> list[str]:
71
+ """Flatten repeated and/or comma-separated ``--flag`` occurrences into one list."""
72
+ return [v.strip() for item in values or [] for v in item.split(",") if v.strip()]
73
+
74
+
75
+ def build_semantic_query(
76
+ file: Path | None,
77
+ metrics: list[str] | None,
78
+ dimensions: list[str] | None,
79
+ filters: list[str] | None,
80
+ ) -> SemanticQuery:
81
+ """Build a :class:`SemanticQuery` from ``-f``/``--file`` or the inline flag set.
82
+
83
+ Exactly one of the two input modes must be used — ``query``/``sl compile`` share
84
+ this so both commands resolve flags to the identical object the JSON-file path
85
+ would deserialize (SPEC-E7-E8 §3, S14).
86
+ """
87
+ flags_given = bool(metrics or dimensions or filters)
88
+ if file is not None and flags_given:
89
+ raise typer.BadParameter(
90
+ "-f/--file and --metrics/--dimensions/--filter are mutually exclusive"
91
+ )
92
+ if file is None and not flags_given:
93
+ raise typer.BadParameter("either -f/--file or --metrics is required")
94
+
95
+ if file is not None:
96
+ return SemanticQuery.model_validate_json(file.read_text())
97
+
98
+ try:
99
+ parsed_filters = [parse_filter_flag(f) for f in filters or []]
100
+ except ValueError as exc:
101
+ raise typer.BadParameter(str(exc)) from exc
102
+
103
+ return SemanticQuery(
104
+ metrics=_expand_csv(metrics), dimensions=_expand_csv(dimensions), filters=parsed_filters
105
+ )
@@ -0,0 +1,88 @@
1
+ """``canonic apply`` — batch-apply pending proposals after manual diff editing (GH-150, E7 §3).
2
+
3
+ Reads ``status.yaml`` from the given run directory and applies every proposal still marked
4
+ ``pending``. Proposals whose diff file the user deleted, or that are already in a terminal
5
+ state (accepted / rejected / frozen), are silently skipped. No git interaction (E4 §6).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ from canonic.cli._errors import handle_errors
17
+ from canonic.cli.commands import _console
18
+ from canonic.config import find_project_root
19
+ from canonic.ingestion.pending import (
20
+ PendingProposalEntry,
21
+ PendingRun,
22
+ ProposalStatus,
23
+ apply_entry,
24
+ update_status,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ @handle_errors
31
+ def apply(
32
+ run_dir: Annotated[
33
+ Path,
34
+ typer.Argument(help="Path to the pending-diff run directory to apply."),
35
+ ],
36
+ ) -> None:
37
+ """Batch-apply all pending proposals from a run directory.
38
+
39
+ Skips proposals already in a terminal state or whose diff file has been deleted.
40
+ No git interaction — applied files appear as unstaged changes.
41
+ """
42
+ root = find_project_root()
43
+ if root is None:
44
+ _console.print(
45
+ "[red]error:[/red] no canonic project found — run from inside a project directory"
46
+ )
47
+ raise typer.Exit(1)
48
+
49
+ run_dir = run_dir.resolve()
50
+ if not run_dir.is_dir():
51
+ _console.print(f"[red]error:[/red] run directory not found: {run_dir}")
52
+ raise typer.Exit(1)
53
+
54
+ run = PendingRun.load(run_dir)
55
+ logger.info("apply: run_dir=%s proposals=%d", run_dir.name, len(run.proposals))
56
+
57
+ applied = 0
58
+ skipped = 0
59
+ updated: list[PendingProposalEntry] = list(run.proposals)
60
+
61
+ for proposal in run.proposals:
62
+ if proposal.status is not ProposalStatus.PENDING:
63
+ logger.debug(
64
+ "apply: skipping proposal=%s target=%s (status=%s)",
65
+ proposal.id,
66
+ proposal.target,
67
+ proposal.status.value,
68
+ )
69
+ skipped += 1
70
+ continue
71
+ if not Path(proposal.diff_file).exists():
72
+ logger.debug(
73
+ "apply: skipping proposal=%s target=%s (diff file missing)",
74
+ proposal.id,
75
+ proposal.target,
76
+ )
77
+ skipped += 1
78
+ continue
79
+
80
+ apply_entry(root, run, proposal)
81
+ logger.debug("apply: applied proposal=%s target=%s", proposal.id, proposal.target)
82
+ i = next(j for j, p in enumerate(updated) if p.id == proposal.id)
83
+ updated[i] = proposal.model_copy(update={"status": ProposalStatus.ACCEPTED})
84
+ applied += 1
85
+
86
+ update_status(run_dir, updated)
87
+ logger.info("apply complete: applied=%d skipped=%d", applied, skipped)
88
+ _console.print(f"[green]applied {applied}[/green], skipped {skipped}")
@@ -0,0 +1,101 @@
1
+ """``canonic assert`` — the accuracy harness CI gate (SPEC-Fuller-E15 §3.4, GH-110).
2
+
3
+ Runs the labeled assertion set through the compiler, compares each result to its expectation
4
+ within tolerance, and reports ``accuracy = passed / total``. This is the E16 integration that
5
+ turns ">90% accuracy" from aspirational to measured. Used as a CI gate: an accuracy regression
6
+ below ``--min-accuracy`` exits 10 (``ASSERTION_FAILED``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ from typing import TYPE_CHECKING, Annotated
14
+
15
+ import typer
16
+ from rich.console import Console
17
+
18
+ from canonic.cli._errors import get_cli_context, handle_errors
19
+ from canonic.cli.commands import load_service
20
+ from canonic.exc import AssertionFailed
21
+
22
+ if TYPE_CHECKING:
23
+ from canonic.contracts.assertions import AccuracyReport
24
+
25
+ _console = Console()
26
+
27
+
28
+ @handle_errors
29
+ def assert_(
30
+ ctx: typer.Context,
31
+ min_accuracy: Annotated[
32
+ float,
33
+ typer.Option(
34
+ "--min-accuracy",
35
+ help="Accuracy floor for the CI gate; below it exits 10 (ASSERTION_FAILED).",
36
+ min=0.0,
37
+ max=1.0,
38
+ ),
39
+ ] = 1.0,
40
+ baseline: Annotated[
41
+ bool,
42
+ typer.Option(
43
+ "--baseline",
44
+ help="Also run the schema-only baseline and report the lift (SPEC-E16 Part 2 §2).",
45
+ ),
46
+ ] = False,
47
+ ) -> None:
48
+ """Run the accuracy harness over all loaded assertions and gate on the result.
49
+
50
+ Every executable assertion in ``contracts/assertions/`` is compiled, executed read-only,
51
+ and compared to its expected value within ``tolerance``. The harness reports
52
+ ``accuracy = correct / total``; when it drops below ``--min-accuracy`` (default ``1.0`` —
53
+ every assertion must hold), the command exits 10 with the diverging checks, so a regression
54
+ fails CI. With ``--baseline``, the same assertions also run against a schema-only resolver
55
+ (no curated bindings/aliases/guardrails) so the accuracy lift from canon's context layer is
56
+ measured, not asserted; the gate still applies to the canon accuracy only.
57
+ """
58
+ service = load_service(ctx)
59
+ report = asyncio.run(service.run_accuracy_harness())
60
+ baseline_report: AccuracyReport | None = None
61
+ if baseline:
62
+ baseline_report = asyncio.run(service.run_accuracy_baseline())
63
+
64
+ if get_cli_context(ctx).json_output:
65
+ payload = report.to_dict()
66
+ if baseline_report is not None:
67
+ payload["baseline"] = baseline_report.to_dict()
68
+ payload["lift"] = report.accuracy - baseline_report.accuracy
69
+ typer.echo(json.dumps(payload))
70
+ else:
71
+ _render(report)
72
+ if baseline_report is not None:
73
+ _render_baseline(report, baseline_report)
74
+
75
+ if report.accuracy < min_accuracy:
76
+ first = report.failures[0] if report.failures else None
77
+ raise AssertionFailed(
78
+ f"accuracy {report.accuracy:.1%} below target {min_accuracy:.1%} "
79
+ f"({report.passed}/{report.total} assertions passed)",
80
+ assertion_id=first.assertion_id if first is not None else None,
81
+ )
82
+
83
+
84
+ def _render(report: AccuracyReport) -> None:
85
+ """Print a human-readable accuracy summary and any diverging assertions."""
86
+ _console.print(
87
+ f"accuracy [bold]{report.accuracy:.1%}[/bold] "
88
+ f"({report.passed}/{report.total} assertions passed)"
89
+ )
90
+ for failure in report.failures:
91
+ _console.print(f" [red]✗[/red] {failure.detail}")
92
+
93
+
94
+ def _render_baseline(report: AccuracyReport, baseline_report: AccuracyReport) -> None:
95
+ """Print the schema-only baseline accuracy and the lift over it."""
96
+ lift = report.accuracy - baseline_report.accuracy
97
+ _console.print(
98
+ f"baseline (schema-only) [bold]{baseline_report.accuracy:.1%}[/bold] "
99
+ f"({baseline_report.passed}/{baseline_report.total} assertions passed)"
100
+ )
101
+ _console.print(f"lift: [bold]{lift:+.1%}[/bold]")
@@ -0,0 +1,10 @@
1
+ """``canonic completion`` — shell completion (stub)."""
2
+
3
+ import typer
4
+
5
+ from canonic.cli.commands import not_implemented
6
+
7
+
8
+ def completion(ctx: typer.Context) -> None:
9
+ """Generate shell completion script."""
10
+ not_implemented(ctx, "completion")