smartpipe-cli 1.3.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 (126) hide show
  1. smartpipe/__init__.py +6 -0
  2. smartpipe/__main__.py +8 -0
  3. smartpipe/assets/probe.png +0 -0
  4. smartpipe/assets/probe.txt +1 -0
  5. smartpipe/assets/probe.wav +0 -0
  6. smartpipe/cli/__init__.py +5 -0
  7. smartpipe/cli/auth_cmd.py +78 -0
  8. smartpipe/cli/cache_cmd.py +60 -0
  9. smartpipe/cli/chart_cmd.py +60 -0
  10. smartpipe/cli/cite_cmd.py +26 -0
  11. smartpipe/cli/cluster_cmd.py +102 -0
  12. smartpipe/cli/completions.py +91 -0
  13. smartpipe/cli/config_cmd.py +234 -0
  14. smartpipe/cli/diff_cmd.py +100 -0
  15. smartpipe/cli/distinct_cmd.py +94 -0
  16. smartpipe/cli/doctor_cmd.py +207 -0
  17. smartpipe/cli/echo_cmd.py +44 -0
  18. smartpipe/cli/embed_cmd.py +80 -0
  19. smartpipe/cli/extend_cmd.py +138 -0
  20. smartpipe/cli/filter_cmd.py +87 -0
  21. smartpipe/cli/getschema_cmd.py +31 -0
  22. smartpipe/cli/input_options.py +113 -0
  23. smartpipe/cli/interrupts.py +92 -0
  24. smartpipe/cli/join_cmd.py +162 -0
  25. smartpipe/cli/map_cmd.py +150 -0
  26. smartpipe/cli/outliers_cmd.py +82 -0
  27. smartpipe/cli/probe_cmd.py +223 -0
  28. smartpipe/cli/reduce_cmd.py +129 -0
  29. smartpipe/cli/root.py +281 -0
  30. smartpipe/cli/run_cmd.py +136 -0
  31. smartpipe/cli/sample_cmd.py +35 -0
  32. smartpipe/cli/schema_cmd.py +75 -0
  33. smartpipe/cli/screens.py +231 -0
  34. smartpipe/cli/sem_file.py +453 -0
  35. smartpipe/cli/sort_cmd.py +33 -0
  36. smartpipe/cli/split_cmd.py +76 -0
  37. smartpipe/cli/summarize_cmd.py +37 -0
  38. smartpipe/cli/top_k_cmd.py +97 -0
  39. smartpipe/cli/usage_cmd.py +66 -0
  40. smartpipe/cli/where_cmd.py +36 -0
  41. smartpipe/config/__init__.py +5 -0
  42. smartpipe/config/credentials.py +118 -0
  43. smartpipe/config/display.py +70 -0
  44. smartpipe/config/doctor.py +58 -0
  45. smartpipe/config/paths.py +38 -0
  46. smartpipe/config/store.py +252 -0
  47. smartpipe/container.py +439 -0
  48. smartpipe/core/__init__.py +5 -0
  49. smartpipe/core/errors.py +57 -0
  50. smartpipe/core/jsontools.py +56 -0
  51. smartpipe/engine/__init__.py +9 -0
  52. smartpipe/engine/aggregate.py +234 -0
  53. smartpipe/engine/blocking.py +44 -0
  54. smartpipe/engine/chart.py +143 -0
  55. smartpipe/engine/chunking.py +161 -0
  56. smartpipe/engine/clustering.py +94 -0
  57. smartpipe/engine/predicate.py +330 -0
  58. smartpipe/engine/prompts.py +601 -0
  59. smartpipe/engine/ranking.py +62 -0
  60. smartpipe/engine/runner.py +175 -0
  61. smartpipe/engine/schema.py +208 -0
  62. smartpipe/engine/schema_dsl.py +144 -0
  63. smartpipe/engine/tally.py +53 -0
  64. smartpipe/engine/timebin.py +67 -0
  65. smartpipe/engine/units.py +43 -0
  66. smartpipe/engine/windows.py +78 -0
  67. smartpipe/io/__init__.py +9 -0
  68. smartpipe/io/diagnostics.py +148 -0
  69. smartpipe/io/inputs.py +44 -0
  70. smartpipe/io/items.py +149 -0
  71. smartpipe/io/leaderboard.py +52 -0
  72. smartpipe/io/metering.py +180 -0
  73. smartpipe/io/progress.py +140 -0
  74. smartpipe/io/readers.py +455 -0
  75. smartpipe/io/text.py +40 -0
  76. smartpipe/io/tty.py +88 -0
  77. smartpipe/io/usage.py +214 -0
  78. smartpipe/io/writers.py +340 -0
  79. smartpipe/models/__init__.py +5 -0
  80. smartpipe/models/anthropic_adapter.py +149 -0
  81. smartpipe/models/base.py +170 -0
  82. smartpipe/models/budget.py +94 -0
  83. smartpipe/models/cache.py +132 -0
  84. smartpipe/models/gemini_native.py +196 -0
  85. smartpipe/models/http_support.py +77 -0
  86. smartpipe/models/jina.py +98 -0
  87. smartpipe/models/local_embed.py +76 -0
  88. smartpipe/models/ollama.py +204 -0
  89. smartpipe/models/openai_codex.py +237 -0
  90. smartpipe/models/openai_compat.py +328 -0
  91. smartpipe/models/openai_oauth.py +366 -0
  92. smartpipe/models/resolve.py +78 -0
  93. smartpipe/models/retry.py +69 -0
  94. smartpipe/models/stt.py +80 -0
  95. smartpipe/models/windows.py +116 -0
  96. smartpipe/parsing/__init__.py +10 -0
  97. smartpipe/parsing/detect.py +178 -0
  98. smartpipe/parsing/extract.py +582 -0
  99. smartpipe/py.typed +0 -0
  100. smartpipe/verbs/__init__.py +5 -0
  101. smartpipe/verbs/chart.py +153 -0
  102. smartpipe/verbs/cluster.py +220 -0
  103. smartpipe/verbs/common.py +468 -0
  104. smartpipe/verbs/convert.py +251 -0
  105. smartpipe/verbs/diff.py +206 -0
  106. smartpipe/verbs/distinct.py +164 -0
  107. smartpipe/verbs/embed.py +166 -0
  108. smartpipe/verbs/extend.py +180 -0
  109. smartpipe/verbs/filter.py +191 -0
  110. smartpipe/verbs/getschema.py +135 -0
  111. smartpipe/verbs/join.py +413 -0
  112. smartpipe/verbs/map.py +315 -0
  113. smartpipe/verbs/outliers.py +119 -0
  114. smartpipe/verbs/reduce.py +428 -0
  115. smartpipe/verbs/sample.py +52 -0
  116. smartpipe/verbs/sortverb.py +63 -0
  117. smartpipe/verbs/split.py +333 -0
  118. smartpipe/verbs/summarize.py +60 -0
  119. smartpipe/verbs/top_k.py +318 -0
  120. smartpipe/verbs/where.py +47 -0
  121. smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
  122. smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
  123. smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
  124. smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
  125. smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
  126. smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
@@ -0,0 +1,57 @@
1
+ """Exit codes and the error taxonomy.
2
+
3
+ Contract: plan/decisions.md D12 and plan/architecture.md "Error taxonomy".
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from enum import IntEnum
9
+
10
+ __all__ = [
11
+ "ExitCode",
12
+ "ItemError",
13
+ "SempipeError",
14
+ "SetupFault",
15
+ "TooManyFailures",
16
+ "UsageFault",
17
+ ]
18
+
19
+
20
+ class ExitCode(IntEnum):
21
+ OK = 0
22
+ PARTIAL = 1
23
+ SETUP = 2
24
+ ALL_FAILED = 3
25
+ USAGE = 64
26
+ BUG = 70
27
+ INTERRUPTED = 130
28
+ PIPE_CLOSED = 141 # 128 + SIGPIPE: downstream closed the pipe (| head) — die silently
29
+
30
+
31
+ class SempipeError(Exception):
32
+ """Base of all *expected* smartpipe failures. Never raised directly."""
33
+
34
+
35
+ class UsageFault(SempipeError):
36
+ """Bad flags/arguments/grammar misuse → exit 64."""
37
+
38
+
39
+ class SetupFault(SempipeError):
40
+ """No model, unreachable endpoint, missing key/extra, broken config → exit 2.
41
+
42
+ The message may be a full multi-line screen from plan/ux.md.
43
+ """
44
+
45
+
46
+ class ItemError(SempipeError):
47
+ """One item failed; the runner turns this into a skip-and-warn, never a crash."""
48
+
49
+
50
+ class TooManyFailures(SempipeError):
51
+ """The failure policy tripped (>50 % of items failed) → exit 3."""
52
+
53
+ def __init__(self, failed: int, total: int, last_reason: str) -> None:
54
+ self.failed = failed
55
+ self.total = total
56
+ self.last_reason = last_reason
57
+ super().__init__(f"stopping — {failed} of {total} items failed the same way")
@@ -0,0 +1,56 @@
1
+ """Typed access into untrusted JSON (provider replies), without ``cast``.
2
+
3
+ Each helper narrows ``object`` to a concrete shape or returns ``None`` — the
4
+ caller decides whether a wrong shape is an ``ItemError`` or a bug.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING, TypeGuard
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Mapping, Sequence
13
+
14
+ __all__ = ["as_float_vector", "as_items", "as_record", "as_str", "record_at"]
15
+
16
+
17
+ def _is_record(value: object) -> TypeGuard[Mapping[str, object]]:
18
+ return isinstance(value, dict) # json.loads keys are str by contract
19
+
20
+
21
+ def _is_items(value: object) -> TypeGuard[Sequence[object]]:
22
+ return isinstance(value, list)
23
+
24
+
25
+ def as_record(value: object) -> Mapping[str, object] | None:
26
+ return value if _is_record(value) else None
27
+
28
+
29
+ def as_items(value: object) -> Sequence[object] | None:
30
+ return value if _is_items(value) else None
31
+
32
+
33
+ def as_str(value: object) -> str | None:
34
+ return value if isinstance(value, str) else None
35
+
36
+
37
+ def as_float_vector(value: object) -> tuple[float, ...] | None:
38
+ items = as_items(value)
39
+ if items is None:
40
+ return None
41
+ vector: list[float] = []
42
+ for element in items:
43
+ if isinstance(element, bool) or not isinstance(element, int | float):
44
+ return None
45
+ vector.append(float(element))
46
+ return tuple(vector)
47
+
48
+
49
+ def record_at(value: object, *keys: str) -> Mapping[str, object] | None:
50
+ """Walk nested objects: ``record_at(data, "message")`` → the message record."""
51
+ current = as_record(value)
52
+ for key in keys:
53
+ if current is None:
54
+ return None
55
+ current = as_record(current.get(key))
56
+ return current
@@ -0,0 +1,9 @@
1
+ """The pure functional core: prompts, schema, chunking, ranking, ordering.
2
+
3
+ Nothing in this package does I/O, reads the clock, or touches the environment —
4
+ every input is a parameter. That is what makes it exhaustively testable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ __all__: list[str] = []
@@ -0,0 +1,234 @@
1
+ """The ``summarize`` grammar and fold (D38/07) — KQL's micro-syntax, pure.
2
+
3
+ `count(), avg(total), p95(total) by region` parses once into a plan; records
4
+ fold into per-group state in one pass. Percentile aggregations keep each
5
+ group's numeric values (the one memory note); everything else streams.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING, Literal, assert_never
12
+
13
+ from smartpipe.core.errors import UsageFault
14
+
15
+ if TYPE_CHECKING:
16
+ from collections.abc import Mapping
17
+
18
+ __all__ = [
19
+ "SUMMARIZE_MENU",
20
+ "Aggregation",
21
+ "BinKey",
22
+ "GroupState",
23
+ "SummarizePlan",
24
+ "finish",
25
+ "fold",
26
+ "group_key",
27
+ "parse_summarize",
28
+ ]
29
+
30
+ SUMMARIZE_MENU = (
31
+ "error: can't parse the summarize expression\n"
32
+ " Shape: AGG[, AGG…] [by FIELD[, FIELD…]]\n"
33
+ " Aggregations: count() · sum(f) · avg(f) · min(f) · max(f)\n"
34
+ " p50(f) · p90(f) · p95(f) · p99(f) · dcount(f)\n"
35
+ " Example: smartpipe summarize 'count(), avg(total), p95(total) by region'"
36
+ )
37
+
38
+ _FUNCTIONS = ("count", "sum", "avg", "min", "max", "p50", "p90", "p95", "p99", "dcount")
39
+ _Fn = Literal["count", "sum", "avg", "min", "max", "p50", "p90", "p95", "p99", "dcount"]
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class Aggregation:
44
+ fn: _Fn
45
+ field: str | None # None only for count()
46
+ name: str # KQL's output naming: count, avg_total, p95_total, dcount_user
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class BinKey:
51
+ field: str
52
+ bucket_seconds: int
53
+ name: str # "<field>_bin"
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class SummarizePlan:
58
+ aggregations: tuple[Aggregation, ...]
59
+ by: tuple[str | BinKey, ...]
60
+
61
+ @property
62
+ def by_names(self) -> tuple[str, ...]:
63
+ return tuple(key if isinstance(key, str) else key.name for key in self.by)
64
+
65
+
66
+ def parse_summarize(text: str) -> SummarizePlan:
67
+ head, _, tail = text.partition(" by ")
68
+ by = (
69
+ tuple(_parse_by(name.strip()) for name in _split_bins(tail) if name.strip()) if tail else ()
70
+ )
71
+ if tail and not by:
72
+ raise UsageFault(SUMMARIZE_MENU + "\n ('by' needs at least one field)")
73
+ aggregations = tuple(_parse_agg(part.strip()) for part in head.split(",") if part.strip())
74
+ if not aggregations:
75
+ raise UsageFault(SUMMARIZE_MENU + "\n (name at least one aggregation)")
76
+ return SummarizePlan(aggregations, by)
77
+
78
+
79
+ def _split_bins(tail: str) -> list[str]:
80
+ """Split by-keys on commas outside parens — bin(ts, 1h) survives whole."""
81
+ parts: list[str] = []
82
+ depth = 0
83
+ current = ""
84
+ for char in tail:
85
+ if char == "(":
86
+ depth += 1
87
+ elif char == ")":
88
+ depth -= 1
89
+ if char == "," and depth == 0:
90
+ parts.append(current)
91
+ current = ""
92
+ else:
93
+ current += char
94
+ parts.append(current)
95
+ return parts
96
+
97
+
98
+ def _parse_by(token: str) -> str | BinKey:
99
+ if not token.startswith("bin(") or not token.endswith(")"):
100
+ return token
101
+ inner = token[4:-1]
102
+ field_name, comma, bucket_text = inner.partition(",")
103
+ field_name = field_name.strip()
104
+ if not comma or not field_name:
105
+ raise UsageFault(SUMMARIZE_MENU + "\n (bin needs bin(field, bucket) — e.g. bin(ts, 1h))")
106
+ from smartpipe.engine.timebin import parse_bucket
107
+
108
+ return BinKey(field_name, parse_bucket(bucket_text.strip()), f"{field_name}_bin")
109
+
110
+
111
+ def _parse_agg(token: str) -> Aggregation:
112
+ name, paren, rest = token.partition("(")
113
+ name = name.strip()
114
+ if not paren or not rest.endswith(")"):
115
+ raise UsageFault(SUMMARIZE_MENU + f"\n (stuck at: {token!r})")
116
+ if name not in _FUNCTIONS:
117
+ raise UsageFault(SUMMARIZE_MENU + f"\n ({name!r} isn't an aggregation)")
118
+ inner = rest[:-1].strip()
119
+ if name == "count":
120
+ if inner:
121
+ raise UsageFault(SUMMARIZE_MENU + "\n (count() takes no field)")
122
+ return Aggregation("count", None, "count")
123
+ if not inner:
124
+ raise UsageFault(SUMMARIZE_MENU + f"\n ({name}() needs a field)")
125
+ fn: _Fn = name # type: ignore[assignment] # narrowed by the membership check
126
+ return Aggregation(fn, inner, f"{name}_{inner}")
127
+
128
+
129
+ @dataclass(slots=True)
130
+ class GroupState:
131
+ count: int = 0
132
+ sums: dict[str, float] = field(default_factory=dict[str, float])
133
+ mins: dict[str, float] = field(default_factory=dict[str, float])
134
+ maxes: dict[str, float] = field(default_factory=dict[str, float])
135
+ numeric_counts: dict[str, int] = field(default_factory=dict[str, int])
136
+ values: dict[str, list[float]] = field(default_factory=dict[str, "list[float]"])
137
+ distinct: dict[str, set[str]] = field(default_factory=dict[str, "set[str]"])
138
+ skipped_non_numeric: dict[str, int] = field(default_factory=dict[str, int])
139
+
140
+
141
+ def fold(plan: SummarizePlan, state: GroupState, record: Mapping[str, object]) -> None:
142
+ state.count += 1
143
+ for aggregation in plan.aggregations:
144
+ if aggregation.field is None:
145
+ continue
146
+ value = record.get(aggregation.field)
147
+ if aggregation.fn == "dcount":
148
+ if value is not None:
149
+ state.distinct.setdefault(aggregation.field, set()).add(str(value))
150
+ continue
151
+ number = _numeric(value)
152
+ if number is None:
153
+ if value is not None:
154
+ state.skipped_non_numeric[aggregation.field] = (
155
+ state.skipped_non_numeric.get(aggregation.field, 0) + 1
156
+ )
157
+ continue
158
+ state.numeric_counts[aggregation.field] = state.numeric_counts.get(aggregation.field, 0) + 1
159
+ state.sums[aggregation.field] = state.sums.get(aggregation.field, 0.0) + number
160
+ held = state.mins.get(aggregation.field)
161
+ state.mins[aggregation.field] = number if held is None else min(held, number)
162
+ held = state.maxes.get(aggregation.field)
163
+ state.maxes[aggregation.field] = number if held is None else max(held, number)
164
+ if aggregation.fn in ("p50", "p90", "p95", "p99"):
165
+ state.values.setdefault(aggregation.field, []).append(number)
166
+
167
+
168
+ def finish(plan: SummarizePlan, key: tuple[object, ...], state: GroupState) -> dict[str, object]:
169
+ row: dict[str, object] = dict(zip(plan.by_names, key, strict=True))
170
+ for aggregation in plan.aggregations:
171
+ row[aggregation.name] = _value(aggregation, state)
172
+ return row
173
+
174
+
175
+ def _value(aggregation: Aggregation, state: GroupState) -> object:
176
+ field_name = aggregation.field
177
+ match aggregation.fn:
178
+ case "count":
179
+ return state.count
180
+ case "dcount":
181
+ assert field_name is not None
182
+ return len(state.distinct.get(field_name, set()))
183
+ case "sum":
184
+ assert field_name is not None
185
+ return _round(state.sums.get(field_name)) if field_name in state.sums else None
186
+ case "avg":
187
+ assert field_name is not None
188
+ seen = state.numeric_counts.get(field_name, 0)
189
+ return _round(state.sums[field_name] / seen) if seen else None
190
+ case "min":
191
+ assert field_name is not None
192
+ return _round(state.mins.get(field_name))
193
+ case "max":
194
+ assert field_name is not None
195
+ return _round(state.maxes.get(field_name))
196
+ case "p50" | "p90" | "p95" | "p99":
197
+ assert field_name is not None
198
+ values = sorted(state.values.get(field_name, ()))
199
+ if not values:
200
+ return None
201
+ quantile = int(aggregation.fn[1:]) / 100
202
+ rank = min(len(values) - 1, max(0, round(quantile * (len(values) - 1))))
203
+ return _round(values[rank])
204
+ case _ as unreachable: # pragma: no cover — pyright proves exhaustiveness
205
+ assert_never(unreachable)
206
+
207
+
208
+ def _round(value: float | None) -> float | None:
209
+ if value is None:
210
+ return None
211
+ return round(value, 4)
212
+
213
+
214
+ def _numeric(value: object) -> float | None:
215
+ if isinstance(value, bool):
216
+ return None
217
+ if isinstance(value, (int, float)):
218
+ return float(value)
219
+ return None
220
+
221
+
222
+ def group_key(plan: SummarizePlan, record: Mapping[str, object]) -> tuple[object, ...]:
223
+ """The record's group key: plain fields pass through; bin() keys become
224
+ UTC bucket labels (unparseable timestamps group under null, visibly)."""
225
+ from smartpipe.engine.timebin import bucket_label, parse_timestamp
226
+
227
+ parts: list[object] = []
228
+ for key in plan.by:
229
+ if isinstance(key, str):
230
+ parts.append(record.get(key))
231
+ continue
232
+ epoch = parse_timestamp(record.get(key.field))
233
+ parts.append(None if epoch is None else bucket_label(epoch, key.bucket_seconds))
234
+ return tuple(parts)
@@ -0,0 +1,44 @@
1
+ """Candidate selection for ``join`` (D21) — the *block* of embed-block-judge.
2
+
3
+ Pure math over an in-memory index: brute-force cosine, same envelope as
4
+ ``top_k`` (no vector database, no ANN — recorded non-goals; the corpus lives in
5
+ the pipe). ``candidates`` is the recall knob's implementation: everything the
6
+ judge never sees is a match the user never gets, so ties and ordering are pinned
7
+ and property-tested.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING
14
+
15
+ from smartpipe.engine.ranking import rank, select
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Sequence
19
+
20
+ __all__ = ["RightIndex", "build_index", "candidates"]
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class RightIndex:
25
+ """The build side, embedded once: position ↔ the caller's right-row payloads."""
26
+
27
+ vectors: tuple[tuple[float, ...], ...]
28
+
29
+ def __len__(self) -> int:
30
+ return len(self.vectors)
31
+
32
+
33
+ def build_index(vectors: Sequence[Sequence[float]]) -> RightIndex:
34
+ return RightIndex(vectors=tuple(tuple(vector) for vector in vectors))
35
+
36
+
37
+ def candidates(
38
+ query: Sequence[float], index: RightIndex, *, k: int, threshold: float | None
39
+ ) -> tuple[tuple[int, float], ...]:
40
+ """The top-``k`` right positions for one left vector, best-first, ties by
41
+ position; ``threshold`` filters before ``k`` (a floor, then a width)."""
42
+ if k < 1:
43
+ raise ValueError(f"k must be >= 1, got {k}")
44
+ return select(rank(query, index.vectors), k=k, threshold=threshold)
@@ -0,0 +1,143 @@
1
+ """Bar-chart rendering for ``smartpipe chart`` — pure, no plotting dependency.
2
+
3
+ Terminal bars from block characters; ``--save`` writes a hand-rolled SVG (text,
4
+ so it costs no dependency and converts to anything). Counts in, pictures out.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Sequence
13
+
14
+ __all__ = ["CHARTS_EXTRA_SCREEN", "render_bars", "render_svg", "render_svg_panels"]
15
+
16
+ _BLOCK = "▇"
17
+ _BAR_WIDTH = 40 # cells for the longest bar; the rest scale
18
+
19
+
20
+ def render_bars(counts: Sequence[tuple[str, int]], *, width: int = _BAR_WIDTH) -> str:
21
+ """Horizontal unicode bars, widest value = ``width`` cells, labels aligned."""
22
+ if not counts:
23
+ return "(nothing to chart)"
24
+ label_width = max(len(label) for label, _ in counts)
25
+ top = max(count for _, count in counts) or 1
26
+ lines: list[str] = []
27
+ for label, count in counts:
28
+ cells = max(1, round(count / top * width)) if count else 0
29
+ lines.append(f"{label.ljust(label_width)} {_BLOCK * cells} {count:,}")
30
+ return "\n".join(lines)
31
+
32
+
33
+ _ROW_HEIGHT = 28
34
+ _LABEL_SPACE = 180
35
+ _CHART_WIDTH = 640
36
+
37
+ CHARTS_EXTRA_SCREEN = (
38
+ "error: saving charts needs an optional dependency\n"
39
+ " svgwrite ships with smartpipe — reinstall smartpipe"
40
+ )
41
+
42
+
43
+ def render_svg(counts: Sequence[tuple[str, int]], *, title: str | None = None) -> str:
44
+ """An SVG bar chart via svgwrite (the ``[charts]`` extra) — a library, not
45
+ bespoke markup (owner's call: no hand-rolled SVG to maintain)."""
46
+ try:
47
+ import svgwrite
48
+ except ImportError as exc:
49
+ from smartpipe.core.errors import SetupFault
50
+
51
+ raise SetupFault(CHARTS_EXTRA_SCREEN) from exc
52
+
53
+ rows = list(counts) or [("(nothing)", 0)]
54
+ top = max(count for _, count in rows) or 1
55
+ header = 36 if title else 8
56
+ height = header + len(rows) * _ROW_HEIGHT + 8
57
+ drawing = svgwrite.Drawing(size=(_CHART_WIDTH, height))
58
+ drawing.add(drawing.rect(insert=(0, 0), size=(_CHART_WIDTH, height), fill="white"))
59
+ text_style = {"font_family": "system-ui, sans-serif", "font_size": "13px"}
60
+ if title:
61
+ drawing.add(
62
+ drawing.text(
63
+ title,
64
+ insert=(12, 24),
65
+ font_weight="bold",
66
+ font_size="16px",
67
+ font_family="system-ui, sans-serif",
68
+ )
69
+ )
70
+ bar_space = _CHART_WIDTH - _LABEL_SPACE - 80
71
+ for row, (label, count) in enumerate(rows):
72
+ y = header + row * _ROW_HEIGHT
73
+ bar = max(2, round(count / top * bar_space)) if count else 0
74
+ drawing.add(
75
+ drawing.text(label, insert=(_LABEL_SPACE - 8, y + 18), text_anchor="end", **text_style)
76
+ )
77
+ drawing.add(
78
+ drawing.rect(insert=(_LABEL_SPACE, y + 6), size=(bar, 16), fill="#4477aa", rx=2)
79
+ )
80
+ drawing.add(
81
+ drawing.text(f"{count:,}", insert=(_LABEL_SPACE + bar + 6, y + 18), **text_style)
82
+ )
83
+ return drawing.tostring()
84
+
85
+
86
+ def render_svg_panels(
87
+ panels: Sequence[tuple[str, Sequence[tuple[str, int]]]], *, title: str | None = None
88
+ ) -> str:
89
+ """Several bar panels stacked in one SVG (chart --facet). Same dependency
90
+ rules as render_svg: svgwrite behind the [charts] extra."""
91
+ try:
92
+ import svgwrite
93
+ except ImportError as exc:
94
+ from smartpipe.core.errors import SetupFault
95
+
96
+ raise SetupFault(CHARTS_EXTRA_SCREEN) from exc
97
+
98
+ header = 36 if title else 8
99
+ heights = [24 + max(1, len(counts)) * _ROW_HEIGHT + 8 for _name, counts in panels]
100
+ total_height = header + sum(heights)
101
+ drawing = svgwrite.Drawing(size=(_CHART_WIDTH, total_height))
102
+ drawing.add(drawing.rect(insert=(0, 0), size=(_CHART_WIDTH, total_height), fill="white"))
103
+ text_style = {"font_family": "system-ui, sans-serif", "font_size": "13px"}
104
+ if title:
105
+ drawing.add(
106
+ drawing.text(
107
+ title,
108
+ insert=(12, 24),
109
+ font_weight="bold",
110
+ font_size="16px",
111
+ font_family="system-ui, sans-serif",
112
+ )
113
+ )
114
+ offset = header
115
+ bar_space = _CHART_WIDTH - _LABEL_SPACE - 80
116
+ for (name, counts), panel_height in zip(panels, heights, strict=True):
117
+ drawing.add(
118
+ drawing.text(
119
+ name,
120
+ insert=(12, offset + 16),
121
+ font_weight="bold",
122
+ font_size="13px",
123
+ font_family="system-ui, sans-serif",
124
+ )
125
+ )
126
+ rows = list(counts) or [("(nothing)", 0)]
127
+ top = max(count for _label, count in rows) or 1
128
+ for row, (label, count) in enumerate(rows):
129
+ y = offset + 24 + row * _ROW_HEIGHT
130
+ bar = max(2, round(count / top * bar_space)) if count else 0
131
+ drawing.add(
132
+ drawing.text(
133
+ label, insert=(_LABEL_SPACE - 8, y + 18), text_anchor="end", **text_style
134
+ )
135
+ )
136
+ drawing.add(
137
+ drawing.rect(insert=(_LABEL_SPACE, y + 6), size=(bar, 16), fill="#4477aa", rx=2)
138
+ )
139
+ drawing.add(
140
+ drawing.text(f"{count:,}", insert=(_LABEL_SPACE + bar + 6, y + 18), **text_style)
141
+ )
142
+ offset += panel_height
143
+ return drawing.tostring()