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.
- canonic/__init__.py +0 -0
- canonic/airgap.py +139 -0
- canonic/cli/__init__.py +0 -0
- canonic/cli/_errors.py +112 -0
- canonic/cli/app.py +106 -0
- canonic/cli/commands/__init__.py +105 -0
- canonic/cli/commands/apply.py +88 -0
- canonic/cli/commands/assertions.py +101 -0
- canonic/cli/commands/completion.py +10 -0
- canonic/cli/commands/connection.py +286 -0
- canonic/cli/commands/evaluate.py +108 -0
- canonic/cli/commands/ingest.py +289 -0
- canonic/cli/commands/knowledge.py +201 -0
- canonic/cli/commands/mcp.py +206 -0
- canonic/cli/commands/outcome.py +104 -0
- canonic/cli/commands/overview.py +47 -0
- canonic/cli/commands/query.py +85 -0
- canonic/cli/commands/report.py +297 -0
- canonic/cli/commands/review.py +150 -0
- canonic/cli/commands/setup.py +1009 -0
- canonic/cli/commands/sl.py +140 -0
- canonic/cli/commands/sql.py +49 -0
- canonic/cli/commands/status.py +70 -0
- canonic/cli/setup_state.py +74 -0
- canonic/compiler/__init__.py +19 -0
- canonic/compiler/dialect.py +195 -0
- canonic/compiler/joins.py +289 -0
- canonic/compiler/pipeline.py +2293 -0
- canonic/compiler/query.py +137 -0
- canonic/compiler/result.py +179 -0
- canonic/config.py +367 -0
- canonic/connectors/__init__.py +0 -0
- canonic/connectors/acquisition.py +365 -0
- canonic/connectors/base.py +424 -0
- canonic/connectors/dbt.py +493 -0
- canonic/connectors/duckdb.py +322 -0
- canonic/connectors/evidence.py +204 -0
- canonic/connectors/factory.py +138 -0
- canonic/connectors/looker.py +274 -0
- canonic/connectors/metabase.py +303 -0
- canonic/connectors/notion.py +284 -0
- canonic/connectors/postgres.py +513 -0
- canonic/connectors/readonly.py +31 -0
- canonic/connectors/redshift.py +564 -0
- canonic/connectors/relation_filter.py +43 -0
- canonic/connectors/sqlite.py +271 -0
- canonic/connectors/web.py +134 -0
- canonic/contract.py +3 -0
- canonic/contracts/__init__.py +75 -0
- canonic/contracts/assertions.py +225 -0
- canonic/contracts/bootstrap.py +76 -0
- canonic/contracts/finality.py +160 -0
- canonic/contracts/loader.py +173 -0
- canonic/contracts/models.py +444 -0
- canonic/contracts/resolver.py +461 -0
- canonic/contracts/validate.py +407 -0
- canonic/core/__init__.py +0 -0
- canonic/core/models.py +332 -0
- canonic/core/overview.py +66 -0
- canonic/core/service.py +837 -0
- canonic/credentials.py +60 -0
- canonic/eval/__init__.py +22 -0
- canonic/eval/candidates.py +82 -0
- canonic/eval/dataset.py +130 -0
- canonic/eval/datasets/draft_grain.jsonl +13 -0
- canonic/eval/datasets/reconcile_contradictions.jsonl +12 -0
- canonic/eval/harness.py +319 -0
- canonic/eval/models.py +81 -0
- canonic/eval/report.py +120 -0
- canonic/eval/scoring.py +70 -0
- canonic/exc.py +349 -0
- canonic/feedback/__init__.py +19 -0
- canonic/feedback/evidence.py +103 -0
- canonic/feedback/history.py +152 -0
- canonic/feedback/report.py +65 -0
- canonic/ingestion/__init__.py +89 -0
- canonic/ingestion/autopr.py +151 -0
- canonic/ingestion/builder.py +732 -0
- canonic/ingestion/emitter.py +431 -0
- canonic/ingestion/examples.py +213 -0
- canonic/ingestion/models.py +178 -0
- canonic/ingestion/pending.py +278 -0
- canonic/ingestion/pipeline.py +313 -0
- canonic/ingestion/reconciliation.py +654 -0
- canonic/ingestion/source.py +228 -0
- canonic/ingestion/validation.py +315 -0
- canonic/instrumentation/__init__.py +1 -0
- canonic/instrumentation/events.py +83 -0
- canonic/instrumentation/models.py +186 -0
- canonic/instrumentation/report.py +432 -0
- canonic/instrumentation/telemetry.py +66 -0
- canonic/knowledge/__init__.py +73 -0
- canonic/knowledge/drift.py +100 -0
- canonic/knowledge/embeddings.py +136 -0
- canonic/knowledge/index.py +133 -0
- canonic/knowledge/loader.py +196 -0
- canonic/knowledge/models.py +89 -0
- canonic/knowledge/pruning.py +123 -0
- canonic/knowledge/rendering.py +58 -0
- canonic/knowledge/resolve.py +54 -0
- canonic/knowledge/results.py +130 -0
- canonic/knowledge/retrieval.py +289 -0
- canonic/knowledge/scope.py +88 -0
- canonic/knowledge/traversal.py +146 -0
- canonic/knowledge/validation.py +176 -0
- canonic/llm_providers.py +75 -0
- canonic/log.py +102 -0
- canonic/mcp/__init__.py +0 -0
- canonic/mcp/daemon.py +270 -0
- canonic/mcp/errors.py +54 -0
- canonic/mcp/server.py +340 -0
- canonic/runtime/__init__.py +17 -0
- canonic/runtime/drafter.py +447 -0
- canonic/runtime/embeddings.py +109 -0
- canonic/runtime/extraction.py +138 -0
- canonic/runtime/generation.py +249 -0
- canonic/runtime/models.py +41 -0
- canonic/runtime/resolver.py +40 -0
- canonic/semantic/__init__.py +43 -0
- canonic/semantic/loader.py +114 -0
- canonic/semantic/models.py +281 -0
- canonic/trust/__init__.py +10 -0
- canonic/trust/models.py +47 -0
- canonic/trust/scorer.py +94 -0
- canonic/trust/signals.py +115 -0
- canonic-0.5.0.dist-info/METADATA +331 -0
- canonic-0.5.0.dist-info/RECORD +129 -0
- canonic-0.5.0.dist-info/WHEEL +4 -0
- 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
|
canonic/cli/__init__.py
ADDED
|
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]")
|