fauxcall 0.1.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.
- fauxcall/__init__.py +10 -0
- fauxcall/__main__.py +6 -0
- fauxcall/analysis/__init__.py +1 -0
- fauxcall/analysis/jobs.py +186 -0
- fauxcall/analysis/profile.py +435 -0
- fauxcall/analysis/relations.py +220 -0
- fauxcall/analysis/schema.py +1080 -0
- fauxcall/analysis/sqlview.py +169 -0
- fauxcall/analysis/timing.py +189 -0
- fauxcall/analysis/writes.py +238 -0
- fauxcall/build/__init__.py +1 -0
- fauxcall/build/init.py +491 -0
- fauxcall/build/scaffold.py +842 -0
- fauxcall/build/templates/fake/Dockerfile.j2 +13 -0
- fauxcall/build/templates/fake/README.md.j2 +58 -0
- fauxcall/build/templates/fake/env.example.j2 +13 -0
- fauxcall/build/templates/fake/fauxcall.toml.j2 +3 -0
- fauxcall/build/templates/fake/pyproject.toml.j2 +77 -0
- fauxcall/build/templates/fake/src/__init__.py.j2 +5 -0
- fauxcall/build/templates/fake/src/__main__.py.j2 +28 -0
- fauxcall/build/templates/fake/src/app.py.j2 +33 -0
- fauxcall/build/templates/fake/src/handlers.py.j2 +39 -0
- fauxcall/build/templates/fake/src/routes.py.j2 +27 -0
- fauxcall/build/templates/fake/src/settings.py.j2 +29 -0
- fauxcall/build/templates/fake/tests/conftest.py.j2 +23 -0
- fauxcall/build/templates/fake/tests/e2e/test_scenarios.py.j2 +22 -0
- fauxcall/build/templates/fake/tests/unit/test_app.py.j2 +62 -0
- fauxcall/build/templates/fake/tests/unit/test_recorded.py.j2 +39 -0
- fauxcall/build/templates/fake/tests/unit/test_routes.py.j2 +41 -0
- fauxcall/build/templates/workspace/README.md.j2 +29 -0
- fauxcall/build/templates/workspace/ci.yml.j2 +25 -0
- fauxcall/build/templates/workspace/compose.yaml.j2 +16 -0
- fauxcall/build/templates/workspace/dockerignore.j2 +14 -0
- fauxcall/build/templates/workspace/gitignore.j2 +13 -0
- fauxcall/build/templates/workspace/pyproject.toml.j2 +10 -0
- fauxcall/build/world.py +143 -0
- fauxcall/calendar.py +250 -0
- fauxcall/cli/__init__.py +1 -0
- fauxcall/cli/building.py +202 -0
- fauxcall/cli/common.py +172 -0
- fauxcall/cli/evidence.py +784 -0
- fauxcall/cli/main.py +81 -0
- fauxcall/cli/project.py +574 -0
- fauxcall/cli/running.py +243 -0
- fauxcall/clock.py +440 -0
- fauxcall/config.py +243 -0
- fauxcall/errors.py +31 -0
- fauxcall/jsonfmt.py +289 -0
- fauxcall/jsonpath.py +158 -0
- fauxcall/logs.py +161 -0
- fauxcall/mcpwire/__init__.py +4 -0
- fauxcall/mcpwire/client.py +430 -0
- fauxcall/mcpwire/protocol.py +237 -0
- fauxcall/model/__init__.py +125 -0
- fauxcall/model/api.py +262 -0
- fauxcall/model/auth.py +78 -0
- fauxcall/model/calendar.py +68 -0
- fauxcall/model/common.py +51 -0
- fauxcall/model/endpoints.py +300 -0
- fauxcall/model/errors.py +65 -0
- fauxcall/model/fields.py +198 -0
- fauxcall/model/jobs.py +99 -0
- fauxcall/model/mcp.py +104 -0
- fauxcall/model/plan.py +244 -0
- fauxcall/model/progress.py +131 -0
- fauxcall/model/realism.py +100 -0
- fauxcall/model/recording.py +123 -0
- fauxcall/model/semantic.py +69 -0
- fauxcall/model/world.py +104 -0
- fauxcall/paths.py +201 -0
- fauxcall/privacy/__init__.py +49 -0
- fauxcall/privacy/pii.py +831 -0
- fauxcall/privacy/pseudonym.py +691 -0
- fauxcall/privacy/redact.py +460 -0
- fauxcall/py.typed +0 -0
- fauxcall/recording/__init__.py +27 -0
- fauxcall/recording/mcp.py +530 -0
- fauxcall/recording/privacy_filter.py +266 -0
- fauxcall/recording/recorder.py +1126 -0
- fauxcall/recording/store.py +123 -0
- fauxcall/runtime/__init__.py +27 -0
- fauxcall/runtime/admin.py +214 -0
- fauxcall/runtime/app.py +728 -0
- fauxcall/runtime/auth.py +303 -0
- fauxcall/runtime/context.py +402 -0
- fauxcall/runtime/dynamic.py +67 -0
- fauxcall/runtime/faults.py +67 -0
- fauxcall/runtime/generators.py +76 -0
- fauxcall/runtime/handlers.py +630 -0
- fauxcall/runtime/inputs.py +59 -0
- fauxcall/runtime/jev.py +217 -0
- fauxcall/runtime/jobs.py +126 -0
- fauxcall/runtime/journal.py +160 -0
- fauxcall/runtime/listing.py +516 -0
- fauxcall/runtime/matching.py +132 -0
- fauxcall/runtime/mcp_server.py +490 -0
- fauxcall/runtime/oauth.py +239 -0
- fauxcall/runtime/params.py +219 -0
- fauxcall/runtime/policies.py +224 -0
- fauxcall/runtime/ratelimit.py +161 -0
- fauxcall/runtime/recordings.py +82 -0
- fauxcall/runtime/routing.py +110 -0
- fauxcall/runtime/series.py +96 -0
- fauxcall/runtime/streaming.py +284 -0
- fauxcall/runtime/templating.py +136 -0
- fauxcall/spec/__init__.py +40 -0
- fauxcall/spec/discover.py +954 -0
- fauxcall/spec/docs.py +286 -0
- fauxcall/spec/loader.py +309 -0
- fauxcall/spec/openapi.py +1036 -0
- fauxcall/storage.py +135 -0
- fauxcall/testing/__init__.py +17 -0
- fauxcall/testing/asgi.py +123 -0
- fauxcall/testing/client.py +553 -0
- fauxcall/testing/compare.py +515 -0
- fauxcall/testing/plugin.py +447 -0
- fauxcall/testing/report.py +195 -0
- fauxcall/testing/support.py +325 -0
- fauxcall/timefmt.py +395 -0
- fauxcall/types.py +32 -0
- fauxcall/world/__init__.py +55 -0
- fauxcall/world/expand.py +192 -0
- fauxcall/world/invariants.py +261 -0
- fauxcall/world/io.py +124 -0
- fauxcall/world/keys.py +210 -0
- fauxcall/world/store.py +573 -0
- fauxcall-0.1.0.dist-info/METADATA +256 -0
- fauxcall-0.1.0.dist-info/RECORD +131 -0
- fauxcall-0.1.0.dist-info/WHEEL +4 -0
- fauxcall-0.1.0.dist-info/entry_points.txt +3 -0
- fauxcall-0.1.0.dist-info/licenses/LICENSE +202 -0
fauxcall/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""FauxCall: record real APIs and MCP servers, then serve high-fidelity fakes for agent testing."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("fauxcall")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - only when running from a raw source tree
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
fauxcall/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Analysis of recordings: schemas, field profiles, timing, relations."""
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Measure async job timings from polling recordings.
|
|
2
|
+
|
|
3
|
+
The recorder polls a job's status until it is terminal (``purpose: job_poll``, tagged
|
|
4
|
+
``poll:<call>``). Each state was observed between two polls, so its duration is bracketed: at least
|
|
5
|
+
the time between its first and last sighting, at most the time between the sightings of the states
|
|
6
|
+
around it. The midpoint estimate per job feeds a :class:`~fauxcall.model.jobs.DurationModel`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import statistics
|
|
12
|
+
from collections import Counter, defaultdict
|
|
13
|
+
from typing import TYPE_CHECKING, Final
|
|
14
|
+
|
|
15
|
+
from pydantic import Field
|
|
16
|
+
|
|
17
|
+
from fauxcall import jsonpath
|
|
18
|
+
from fauxcall.model.common import Model
|
|
19
|
+
from fauxcall.model.jobs import DurationModel, JobOutcome
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Collection, Sequence
|
|
23
|
+
|
|
24
|
+
from fauxcall.model.recording import Exchange
|
|
25
|
+
|
|
26
|
+
NS: Final = 1_000_000_000
|
|
27
|
+
POLL_TAG: Final = "poll:"
|
|
28
|
+
FIXED_SPREAD: Final = 0.1
|
|
29
|
+
"""Durations whose standard deviation is below this share of their mean become ``fixed``."""
|
|
30
|
+
_PROBABILITY_DIGITS: Final = 4
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class StateTiming(Model):
|
|
34
|
+
"""Observed duration bounds of one state of one job."""
|
|
35
|
+
|
|
36
|
+
state: str
|
|
37
|
+
min_s: float
|
|
38
|
+
max_s: float | None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def estimate_s(self) -> float:
|
|
42
|
+
"""Best single estimate (midpoint of the bounds)."""
|
|
43
|
+
return self.min_s if self.max_s is None else (self.min_s + self.max_s) / 2
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class JobTimeline(Model):
|
|
47
|
+
"""One job's observed progression."""
|
|
48
|
+
|
|
49
|
+
job: str
|
|
50
|
+
observations: list[tuple[float, str]] = Field(description="(seconds since first poll, state)")
|
|
51
|
+
states: list[StateTiming]
|
|
52
|
+
terminal: str | None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class JobTimingSummary(Model):
|
|
56
|
+
"""Suggested durations and outcome probabilities across jobs."""
|
|
57
|
+
|
|
58
|
+
states: list[str]
|
|
59
|
+
durations: dict[str, DurationModel]
|
|
60
|
+
outcomes: list[JobOutcome]
|
|
61
|
+
jobs: int
|
|
62
|
+
note: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def timelines(
|
|
66
|
+
exchanges: Sequence[Exchange], *, state_path: str, terminal: Sequence[str]
|
|
67
|
+
) -> list[JobTimeline]:
|
|
68
|
+
"""Group poll exchanges by their ``poll:<call>`` tag and derive state timings.
|
|
69
|
+
|
|
70
|
+
Exchanges without a ``poll:`` tag or without a response are ignored. A terminal state ends a
|
|
71
|
+
job, so when the same plan ran again (same tag) the next non-terminal sighting starts a new
|
|
72
|
+
job, named ``<call>#2``, ``<call>#3`` …
|
|
73
|
+
"""
|
|
74
|
+
finals = {state.strip() for state in terminal}
|
|
75
|
+
grouped: defaultdict[str, list[tuple[int, str, str | None]]] = defaultdict(list)
|
|
76
|
+
for exchange in exchanges:
|
|
77
|
+
tag = next((t for t in exchange.tags if t.startswith(POLL_TAG)), None)
|
|
78
|
+
if tag is None or exchange.response is None:
|
|
79
|
+
continue
|
|
80
|
+
state = jsonpath.get_one(exchange.response.body.parsed(), state_path)
|
|
81
|
+
grouped[tag].append(
|
|
82
|
+
(exchange.started_ns, exchange.id, None if state is None else str(state))
|
|
83
|
+
)
|
|
84
|
+
result: list[JobTimeline] = []
|
|
85
|
+
for tag, polls in grouped.items():
|
|
86
|
+
polls.sort(key=lambda poll: (poll[0], poll[1]))
|
|
87
|
+
job = tag.removeprefix(POLL_TAG)
|
|
88
|
+
for number, observations in enumerate(_runs(polls, finals), start=1):
|
|
89
|
+
name = job if number == 1 else f"{job}#{number}"
|
|
90
|
+
result.append(_timeline(name, observations, finals))
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _runs(
|
|
95
|
+
polls: Sequence[tuple[int, str, str | None]], terminal: Collection[str]
|
|
96
|
+
) -> list[list[tuple[float, str]]]:
|
|
97
|
+
"""Split time-ordered polls into jobs; observations are seconds since each job's first poll."""
|
|
98
|
+
runs: list[list[tuple[float, str]]] = []
|
|
99
|
+
current: list[tuple[float, str]] = []
|
|
100
|
+
origin = polls[0][0]
|
|
101
|
+
finished = False
|
|
102
|
+
for started_ns, _, state in polls:
|
|
103
|
+
if state is None:
|
|
104
|
+
continue
|
|
105
|
+
if finished and state not in terminal:
|
|
106
|
+
runs.append(current)
|
|
107
|
+
current, origin, finished = [], started_ns, False
|
|
108
|
+
current.append(((started_ns - origin) / NS, state))
|
|
109
|
+
finished = finished or state in terminal
|
|
110
|
+
runs.append(current)
|
|
111
|
+
return runs
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _timeline(
|
|
115
|
+
job: str, observations: list[tuple[float, str]], terminal: Collection[str]
|
|
116
|
+
) -> JobTimeline:
|
|
117
|
+
spans: list[tuple[str, float, float]] = []
|
|
118
|
+
for at, state in observations:
|
|
119
|
+
if spans and spans[-1][0] == state:
|
|
120
|
+
spans[-1] = (state, spans[-1][1], at)
|
|
121
|
+
else:
|
|
122
|
+
spans.append((state, at, at))
|
|
123
|
+
states: list[StateTiming] = []
|
|
124
|
+
for index, (state, first, last) in enumerate(spans):
|
|
125
|
+
if state in terminal:
|
|
126
|
+
continue
|
|
127
|
+
previous_last = spans[index - 1][2] if index > 0 else None
|
|
128
|
+
next_first = spans[index + 1][1] if index + 1 < len(spans) else None
|
|
129
|
+
low = last - first
|
|
130
|
+
high = (
|
|
131
|
+
None
|
|
132
|
+
if next_first is None
|
|
133
|
+
else next_first - (previous_last if previous_last is not None else first)
|
|
134
|
+
)
|
|
135
|
+
states.append(
|
|
136
|
+
StateTiming(
|
|
137
|
+
state=state, min_s=round(low, 3), max_s=None if high is None else round(high, 3)
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
final = spans[-1][0] if spans and spans[-1][0] in terminal else None
|
|
141
|
+
return JobTimeline(job=job, observations=observations, states=states, terminal=final)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _duration(values: Sequence[float]) -> DurationModel:
|
|
145
|
+
"""``fixed`` when the samples agree (one sample, or a small spread), else ``empirical``."""
|
|
146
|
+
mean = statistics.fmean(values)
|
|
147
|
+
if len(values) == 1 or statistics.pstdev(values) < FIXED_SPREAD * max(mean, 0.01):
|
|
148
|
+
return DurationModel(kind="fixed", seconds=round(mean, 3))
|
|
149
|
+
return DurationModel(kind="empirical", samples=list(values))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _outcomes(found: Sequence[JobTimeline]) -> list[JobOutcome]:
|
|
153
|
+
"""Terminal states by frequency; probabilities rounded but summing to 1."""
|
|
154
|
+
counts = Counter(t.terminal for t in found if t.terminal is not None)
|
|
155
|
+
total = sum(counts.values())
|
|
156
|
+
outcomes = [
|
|
157
|
+
JobOutcome(state=state, probability=round(count / total, _PROBABILITY_DIGITS))
|
|
158
|
+
for state, count in counts.most_common()
|
|
159
|
+
]
|
|
160
|
+
if outcomes:
|
|
161
|
+
drift = 1.0 - sum(o.probability for o in outcomes)
|
|
162
|
+
outcomes[0] = JobOutcome(
|
|
163
|
+
state=outcomes[0].state,
|
|
164
|
+
probability=round(outcomes[0].probability + drift, _PROBABILITY_DIGITS),
|
|
165
|
+
)
|
|
166
|
+
return outcomes
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def summarize(found: Sequence[JobTimeline]) -> JobTimingSummary:
|
|
170
|
+
"""Suggest a duration model per state and outcome probabilities."""
|
|
171
|
+
estimates: defaultdict[str, list[float]] = defaultdict(list)
|
|
172
|
+
order: list[str] = []
|
|
173
|
+
for timeline in found:
|
|
174
|
+
for timing in timeline.states:
|
|
175
|
+
if timing.state not in order:
|
|
176
|
+
order.append(timing.state)
|
|
177
|
+
if timing.max_s is not None:
|
|
178
|
+
estimates[timing.state].append(round(timing.estimate_s, 3))
|
|
179
|
+
durations = {state: _duration(values) for state, values in estimates.items()}
|
|
180
|
+
note = (
|
|
181
|
+
f"{len(found)} jobs observed; bounds come from polling, so durations are accurate to "
|
|
182
|
+
"about one poll interval — poll faster for tighter estimates."
|
|
183
|
+
)
|
|
184
|
+
return JobTimingSummary(
|
|
185
|
+
states=order, durations=durations, outcomes=_outcomes(found), jobs=len(found), note=note
|
|
186
|
+
)
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Profile every field of recorded documents so an agent can choose field policies.
|
|
2
|
+
|
|
3
|
+
A :class:`FieldProfile` describes one generalized path (``$.data[*].price``): how often it occurs,
|
|
4
|
+
which JSON types and how many distinct values it takes, and whether it looks like a timestamp (with
|
|
5
|
+
its exact spelling), an identifier, a numeric string or a constant. That is the evidence behind
|
|
6
|
+
field policies: static, time-tracking, IDs, enums, volatile numbers and so on.
|
|
7
|
+
:func:`profile_table` renders profiles as a compact markdown table for an AI agent.
|
|
8
|
+
|
|
9
|
+
Paths are spelled exactly like :func:`fauxcall.jsonpath.leaf_paths` spells them. Empty arrays and
|
|
10
|
+
objects get a path of their own so their presence stays visible.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import functools
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
from typing import TYPE_CHECKING, Final
|
|
19
|
+
|
|
20
|
+
from pydantic import Field
|
|
21
|
+
|
|
22
|
+
from fauxcall import jsonfmt
|
|
23
|
+
from fauxcall.analysis.schema import has_string_format, json_type, string_format
|
|
24
|
+
from fauxcall.jsonfmt import JsonFloat
|
|
25
|
+
from fauxcall.jsonpath import format_path
|
|
26
|
+
from fauxcall.model.common import Model, RawJson
|
|
27
|
+
from fauxcall.timefmt import TimeFormat, infer_time_format
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Iterator, Sequence
|
|
31
|
+
|
|
32
|
+
from fauxcall.types import JsonValue
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FieldProfile(Model):
|
|
36
|
+
"""How one field (a generalized JSON path) behaves across a set of documents."""
|
|
37
|
+
|
|
38
|
+
path: str = Field(description="Generalized JSON path; list elements are '[*]'.")
|
|
39
|
+
documents: int = Field(default=0, description="Input documents containing the path.")
|
|
40
|
+
occurrences: int = Field(default=0, description="Values at the path (lists add several).")
|
|
41
|
+
nulls: int = 0
|
|
42
|
+
types: dict[str, int] = Field(default_factory=dict, description="Occurrences per JSON type.")
|
|
43
|
+
distinct: int = Field(default=0, description="Distinct non-null values (JSON equality).")
|
|
44
|
+
examples: list[RawJson] = Field(
|
|
45
|
+
default_factory=list, description="The first distinct non-null values, as spelled."
|
|
46
|
+
)
|
|
47
|
+
constant: bool = Field(
|
|
48
|
+
default=False, description="Exactly one distinct value over at least 2 non-null values."
|
|
49
|
+
)
|
|
50
|
+
time_format: TimeFormat | None = Field(
|
|
51
|
+
default=None, description="A timestamp spelling every non-null value fits."
|
|
52
|
+
)
|
|
53
|
+
string_format: str | None = Field(
|
|
54
|
+
default=None, description="uuid, email, uri, ipv4 or ipv6, shared by every string."
|
|
55
|
+
)
|
|
56
|
+
minimum: float | None = Field(default=None, description="Numbers (and numeric strings).")
|
|
57
|
+
maximum: float | None = None
|
|
58
|
+
min_length: int | None = Field(default=None, description="Strings, in characters.")
|
|
59
|
+
max_length: int | None = None
|
|
60
|
+
decimals: int | None = Field(
|
|
61
|
+
default=None,
|
|
62
|
+
description="Most digits after the decimal point (floats and numeric strings only).",
|
|
63
|
+
)
|
|
64
|
+
numeric_string: bool = Field(
|
|
65
|
+
default=False, description="Every string spells a decimal number, e.g. '82940.02'."
|
|
66
|
+
)
|
|
67
|
+
looks_like_id: bool = False
|
|
68
|
+
cardinality: float = Field(default=0.0, description="distinct / occurrences.")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_NUMERIC_STRING: Final = re.compile(r"-?(?:0|[1-9]\d*)(?:\.(\d+))?")
|
|
72
|
+
_ID_NAME: Final = re.compile(
|
|
73
|
+
r"(?i:id|ids|uuid|guid|key|code|symbol)" # the whole key
|
|
74
|
+
r"|.*(?i:[_.-](?:id|ids|uuid|guid))" # snake_case / kebab-case suffix
|
|
75
|
+
r"|.*[a-z0-9](?:Id|ID|Ids|IDs|Uuid|UUID)" # camelCase suffix
|
|
76
|
+
)
|
|
77
|
+
_EXPONENT: Final = re.compile(r"[eE]")
|
|
78
|
+
_MIN_UNIQUE_INTEGERS: Final = 2
|
|
79
|
+
|
|
80
|
+
type _Key = tuple[str, bool | int | float | str]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def looks_like_id_name(key: str | None) -> bool:
|
|
84
|
+
"""Name heuristic: ``id``, ``*_id``, ``*Id``, ``*ID``, ``uuid``, ``key``, ``code``, ``symbol``.
|
|
85
|
+
|
|
86
|
+
Plural forms (``ids``, ``tag_ids``, ``tagIds``) and ``guid`` count too.
|
|
87
|
+
"""
|
|
88
|
+
return key is not None and _ID_NAME.fullmatch(key) is not None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _decimals(value: float) -> int:
|
|
92
|
+
text = value.lexeme if isinstance(value, JsonFloat) else float.__repr__(value)
|
|
93
|
+
mantissa = _EXPONENT.split(text, maxsplit=1)[0]
|
|
94
|
+
return len(mantissa.partition(".")[2])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _as_float(value: float) -> float:
|
|
98
|
+
try:
|
|
99
|
+
return float(value)
|
|
100
|
+
except OverflowError: # integers beyond the float range
|
|
101
|
+
return math.inf if value > 0 else -math.inf
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _time_format(values: list[JsonValue], *, named_id: bool) -> TimeFormat | None:
|
|
105
|
+
if not values:
|
|
106
|
+
return None
|
|
107
|
+
found = infer_time_format(values) # huge integers are handled (no OverflowError)
|
|
108
|
+
if found is not None and found.kind == "epoch" and named_id:
|
|
109
|
+
return None # trade_id=1104620943 is not a Unix time
|
|
110
|
+
return found
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class _Field:
|
|
114
|
+
"""Single-pass accumulator for one path."""
|
|
115
|
+
|
|
116
|
+
__slots__ = (
|
|
117
|
+
"common_format",
|
|
118
|
+
"distinct",
|
|
119
|
+
"documents",
|
|
120
|
+
"floats",
|
|
121
|
+
"high",
|
|
122
|
+
"integers",
|
|
123
|
+
"last_document",
|
|
124
|
+
"low",
|
|
125
|
+
"max_length",
|
|
126
|
+
"min_length",
|
|
127
|
+
"name",
|
|
128
|
+
"nulls",
|
|
129
|
+
"number_decimals",
|
|
130
|
+
"numeric_high",
|
|
131
|
+
"numeric_low",
|
|
132
|
+
"numeric_strings",
|
|
133
|
+
"occurrences",
|
|
134
|
+
"string_decimals",
|
|
135
|
+
"strings",
|
|
136
|
+
"types",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def __init__(self, name: str | None) -> None:
|
|
140
|
+
self.name = name
|
|
141
|
+
self.documents = 0
|
|
142
|
+
self.last_document = -1
|
|
143
|
+
self.occurrences = 0
|
|
144
|
+
self.nulls = 0
|
|
145
|
+
self.types: dict[str, int] = {}
|
|
146
|
+
self.distinct: dict[_Key, JsonValue] = {}
|
|
147
|
+
self.integers = 0
|
|
148
|
+
self.floats = 0
|
|
149
|
+
self.number_decimals = 0
|
|
150
|
+
self.low: float | None = None
|
|
151
|
+
self.high: float | None = None
|
|
152
|
+
self.strings = 0
|
|
153
|
+
self.min_length: int | None = None
|
|
154
|
+
self.max_length: int | None = None
|
|
155
|
+
self.numeric_strings = True
|
|
156
|
+
self.string_decimals = 0
|
|
157
|
+
self.numeric_low: float | None = None
|
|
158
|
+
self.numeric_high: float | None = None
|
|
159
|
+
self.common_format: str | None = None
|
|
160
|
+
|
|
161
|
+
def observe(self, value: JsonValue, document: int) -> None:
|
|
162
|
+
if document != self.last_document:
|
|
163
|
+
self.documents += 1
|
|
164
|
+
self.last_document = document
|
|
165
|
+
self.occurrences += 1
|
|
166
|
+
kind = json_type(value)
|
|
167
|
+
self.types[kind] = self.types.get(kind, 0) + 1
|
|
168
|
+
if value is None:
|
|
169
|
+
self.nulls += 1
|
|
170
|
+
return
|
|
171
|
+
if isinstance(value, (dict, list)):
|
|
172
|
+
key: _Key = (kind, jsonfmt.canonical_dumps(value))
|
|
173
|
+
else:
|
|
174
|
+
key = ("number" if kind == "integer" else kind, value)
|
|
175
|
+
self.distinct.setdefault(key, value)
|
|
176
|
+
if isinstance(value, str):
|
|
177
|
+
self._string(value)
|
|
178
|
+
elif isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
179
|
+
self._number(value)
|
|
180
|
+
|
|
181
|
+
def _number(self, value: float) -> None:
|
|
182
|
+
if isinstance(value, int):
|
|
183
|
+
self.integers += 1
|
|
184
|
+
else:
|
|
185
|
+
self.floats += 1
|
|
186
|
+
self.number_decimals = max(self.number_decimals, _decimals(value))
|
|
187
|
+
self.low = value if self.low is None else min(self.low, value)
|
|
188
|
+
self.high = value if self.high is None else max(self.high, value)
|
|
189
|
+
|
|
190
|
+
def _string(self, text: str) -> None:
|
|
191
|
+
self.strings += 1
|
|
192
|
+
length = len(text)
|
|
193
|
+
self.min_length = length if self.min_length is None else min(self.min_length, length)
|
|
194
|
+
self.max_length = length if self.max_length is None else max(self.max_length, length)
|
|
195
|
+
if self.numeric_strings:
|
|
196
|
+
match = _NUMERIC_STRING.fullmatch(text)
|
|
197
|
+
if match is None:
|
|
198
|
+
self.numeric_strings = False
|
|
199
|
+
else:
|
|
200
|
+
self.string_decimals = max(self.string_decimals, len(match.group(1) or ""))
|
|
201
|
+
number = float(text)
|
|
202
|
+
low, high = self.numeric_low, self.numeric_high
|
|
203
|
+
self.numeric_low = number if low is None else min(low, number)
|
|
204
|
+
self.numeric_high = number if high is None else max(high, number)
|
|
205
|
+
if self.strings == 1:
|
|
206
|
+
self.common_format = string_format(text)
|
|
207
|
+
elif self.common_format is not None and not has_string_format(text, self.common_format):
|
|
208
|
+
self.common_format = None
|
|
209
|
+
|
|
210
|
+
def _bounds(self, *, numeric_string: bool) -> tuple[float | None, float | None]:
|
|
211
|
+
lows = [
|
|
212
|
+
x for x in (self.low, self.numeric_low if numeric_string else None) if x is not None
|
|
213
|
+
]
|
|
214
|
+
highs = [
|
|
215
|
+
x for x in (self.high, self.numeric_high if numeric_string else None) if x is not None
|
|
216
|
+
]
|
|
217
|
+
if not lows or not highs:
|
|
218
|
+
return None, None
|
|
219
|
+
return _as_float(min(lows)), _as_float(max(highs))
|
|
220
|
+
|
|
221
|
+
def profile(self, path: str, max_examples: int) -> FieldProfile:
|
|
222
|
+
values = list(self.distinct.values())
|
|
223
|
+
non_null = self.occurrences - self.nulls
|
|
224
|
+
named_id = looks_like_id_name(self.name)
|
|
225
|
+
time_format = _time_format(values, named_id=named_id)
|
|
226
|
+
numeric_string = self.strings > 0 and self.numeric_strings
|
|
227
|
+
places = [self.number_decimals] if self.floats else []
|
|
228
|
+
if numeric_string:
|
|
229
|
+
places.append(self.string_decimals)
|
|
230
|
+
common_format = self.common_format if self.strings else None
|
|
231
|
+
unique_integers = (
|
|
232
|
+
self.integers == non_null >= _MIN_UNIQUE_INTEGERS
|
|
233
|
+
and len(values) == non_null
|
|
234
|
+
and time_format is None
|
|
235
|
+
)
|
|
236
|
+
minimum, maximum = self._bounds(numeric_string=numeric_string)
|
|
237
|
+
return FieldProfile(
|
|
238
|
+
path=path,
|
|
239
|
+
documents=self.documents,
|
|
240
|
+
occurrences=self.occurrences,
|
|
241
|
+
nulls=self.nulls,
|
|
242
|
+
types=dict(self.types),
|
|
243
|
+
distinct=len(values),
|
|
244
|
+
examples=values[:max_examples],
|
|
245
|
+
constant=len(values) == 1 and non_null >= _MIN_UNIQUE_INTEGERS,
|
|
246
|
+
time_format=time_format,
|
|
247
|
+
string_format=common_format,
|
|
248
|
+
minimum=minimum,
|
|
249
|
+
maximum=maximum,
|
|
250
|
+
min_length=self.min_length,
|
|
251
|
+
max_length=self.max_length,
|
|
252
|
+
decimals=max(places) if places else None,
|
|
253
|
+
numeric_string=numeric_string,
|
|
254
|
+
looks_like_id=named_id or common_format == "uuid" or unique_integers,
|
|
255
|
+
cardinality=len(values) / self.occurrences if self.occurrences else 0.0,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@functools.lru_cache(maxsize=4096)
|
|
260
|
+
def _segment(key: str) -> str:
|
|
261
|
+
"""One object key spelled as :func:`fauxcall.jsonpath.format_path` spells it."""
|
|
262
|
+
return format_path((key,))[1:]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _walk(document: JsonValue) -> Iterator[tuple[str, str | None, JsonValue]]:
|
|
266
|
+
"""Yield ``(generalized path, last object key, value)`` for leaves and empty containers."""
|
|
267
|
+
stack: list[tuple[str, str | None, JsonValue]] = [("$", None, document)]
|
|
268
|
+
while stack:
|
|
269
|
+
path, name, node = stack.pop()
|
|
270
|
+
if isinstance(node, dict) and node:
|
|
271
|
+
stack.extend(
|
|
272
|
+
(path + _segment(key), key, child) for key, child in reversed(node.items())
|
|
273
|
+
)
|
|
274
|
+
elif isinstance(node, list) and node:
|
|
275
|
+
inner = path + "[*]"
|
|
276
|
+
stack.extend((inner, name, child) for child in reversed(node))
|
|
277
|
+
else:
|
|
278
|
+
yield path, name, node
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def profile_documents(
|
|
282
|
+
documents: Sequence[JsonValue], *, max_examples: int = 5
|
|
283
|
+
) -> list[FieldProfile]:
|
|
284
|
+
"""Profile every leaf path (plus empty arrays/objects) of ``documents``, in first-seen order.
|
|
285
|
+
|
|
286
|
+
List indices are generalized to ``[*]``. ``distinct``, ``examples`` and ``constant`` use JSON
|
|
287
|
+
equality over non-null values (``1`` equals ``1.0``; ``true`` does not equal ``1``).
|
|
288
|
+
``time_format`` comes from :func:`fauxcall.timefmt.infer_time_format` over every non-null value
|
|
289
|
+
(epoch guesses are dropped for fields whose name looks like an identifier). ``looks_like_id``
|
|
290
|
+
combines name heuristics, uuid strings and integers that never repeat (at least two).
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
ValueError: ``max_examples`` is negative.
|
|
294
|
+
TypeError: a document is not JSON.
|
|
295
|
+
"""
|
|
296
|
+
if max_examples < 0:
|
|
297
|
+
raise ValueError("max_examples must not be negative")
|
|
298
|
+
fields: dict[str, _Field] = {}
|
|
299
|
+
for index, document in enumerate(documents):
|
|
300
|
+
for path, name, value in _walk(document):
|
|
301
|
+
accumulator = fields.get(path)
|
|
302
|
+
if accumulator is None:
|
|
303
|
+
accumulator = fields[path] = _Field(name)
|
|
304
|
+
accumulator.observe(value, index)
|
|
305
|
+
return [accumulator.profile(path, max_examples) for path, accumulator in fields.items()]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ---------------------------------------------------------------------------
|
|
309
|
+
# Markdown rendering
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
_EXAMPLES_WIDTH: Final = 40
|
|
313
|
+
_FRACTION_MODES: Final[dict[str, str]] = {"fixed": "", "trim": " trim", "omit_if_zero": " omit0"}
|
|
314
|
+
_ALMOST_ALL: Final = 99
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def time_format_summary(fmt: TimeFormat) -> str:
|
|
318
|
+
"""A short spelling summary: ``iso 7f Z``, ``iso 6f +HH:MM``, ``date``, ``epoch ms`` etc."""
|
|
319
|
+
if fmt.kind == "epoch":
|
|
320
|
+
parts = ["epoch", fmt.epoch_unit]
|
|
321
|
+
if fmt.epoch_decimals:
|
|
322
|
+
parts.append(f"{fmt.epoch_decimals}dp")
|
|
323
|
+
if fmt.as_string:
|
|
324
|
+
parts.append("string")
|
|
325
|
+
return " ".join(parts)
|
|
326
|
+
if fmt.kind == "strftime":
|
|
327
|
+
return f"strftime {fmt.pattern}"
|
|
328
|
+
if fmt.kind in {"date", "http_date"}:
|
|
329
|
+
return fmt.kind.replace("_", "-")
|
|
330
|
+
parts = [fmt.kind]
|
|
331
|
+
if fmt.kind == "iso" and fmt.separator == " ":
|
|
332
|
+
parts.append("space")
|
|
333
|
+
if not fmt.seconds:
|
|
334
|
+
parts.append("no-seconds")
|
|
335
|
+
if fmt.fraction_digits:
|
|
336
|
+
parts.append(f"{fmt.fraction_digits}f{_FRACTION_MODES[fmt.fraction_mode]}")
|
|
337
|
+
if fmt.kind == "iso":
|
|
338
|
+
parts.append("naive" if fmt.tz_style == "none" else fmt.tz_style)
|
|
339
|
+
return " ".join(parts)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _cell(text: str) -> str:
|
|
343
|
+
return text.replace("|", "\\|").replace("\r", " ").replace("\n", " ")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _types_cell(profile: FieldProfile) -> str:
|
|
347
|
+
if len(profile.types) == 1:
|
|
348
|
+
return next(iter(profile.types))
|
|
349
|
+
return ", ".join(f"{name} {count}" for name, count in profile.types.items())
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _nulls_cell(profile: FieldProfile) -> str:
|
|
353
|
+
if not profile.nulls or not profile.occurrences:
|
|
354
|
+
return "0%"
|
|
355
|
+
if profile.nulls == profile.occurrences:
|
|
356
|
+
return "100%"
|
|
357
|
+
percent = 100 * profile.nulls / profile.occurrences
|
|
358
|
+
if percent < 1:
|
|
359
|
+
return "<1%"
|
|
360
|
+
return ">99%" if percent > _ALMOST_ALL else f"{percent:.0f}%"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _examples_cell(profile: FieldProfile) -> str:
|
|
364
|
+
cell = ""
|
|
365
|
+
for example in profile.examples:
|
|
366
|
+
text = jsonfmt.dumps(example)
|
|
367
|
+
joined = f"{cell}, {text}" if cell else text
|
|
368
|
+
if len(joined) > _EXAMPLES_WIDTH:
|
|
369
|
+
return f"{cell}, …" if cell else text[: _EXAMPLES_WIDTH - 1] + "…"
|
|
370
|
+
cell = joined
|
|
371
|
+
return cell
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _number(value: float) -> str:
|
|
375
|
+
return f"{value:.15g}"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _notes_cell(profile: FieldProfile, total_documents: int) -> str:
|
|
379
|
+
notes: list[str] = []
|
|
380
|
+
if profile.time_format is not None:
|
|
381
|
+
notes.append(time_format_summary(profile.time_format))
|
|
382
|
+
if profile.string_format is not None:
|
|
383
|
+
notes.append(profile.string_format)
|
|
384
|
+
if profile.numeric_string:
|
|
385
|
+
notes.append(f"numeric-string {profile.decimals}dp")
|
|
386
|
+
elif profile.decimals is not None:
|
|
387
|
+
notes.append(f"{profile.decimals}dp")
|
|
388
|
+
low, high = profile.minimum, profile.maximum
|
|
389
|
+
if low is not None and high is not None and low != high:
|
|
390
|
+
notes.append(f"range {_number(low)}..{_number(high)}")
|
|
391
|
+
if profile.looks_like_id:
|
|
392
|
+
notes.append("id?")
|
|
393
|
+
if profile.constant:
|
|
394
|
+
notes.append("constant")
|
|
395
|
+
if profile.documents < total_documents:
|
|
396
|
+
notes.append(f"in {profile.documents}/{total_documents} docs")
|
|
397
|
+
return ", ".join(notes)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def profile_table(profiles: Sequence[FieldProfile], *, max_rows: int = 200) -> str:
|
|
401
|
+
"""Render profiles as a compact markdown table for an AI agent choosing field policies.
|
|
402
|
+
|
|
403
|
+
Columns: path | types | null% | distinct | examples (about 40 characters) | notes. Notes
|
|
404
|
+
summarize the evidence: the timestamp spelling (``iso 7f Z``), string format,
|
|
405
|
+
``numeric-string 2dp`` / ``4dp``, the numeric range, ``id?``, ``constant`` and
|
|
406
|
+
``in 3/5 docs`` for fields missing from some documents. Rows beyond ``max_rows`` are counted
|
|
407
|
+
in a final line instead of being shown.
|
|
408
|
+
|
|
409
|
+
Raises:
|
|
410
|
+
ValueError: ``max_rows`` is negative.
|
|
411
|
+
"""
|
|
412
|
+
if max_rows < 0:
|
|
413
|
+
raise ValueError("max_rows must not be negative")
|
|
414
|
+
if not profiles:
|
|
415
|
+
return "(no fields)"
|
|
416
|
+
total_documents = max(profile.documents for profile in profiles)
|
|
417
|
+
lines = [
|
|
418
|
+
"| path | types | null% | distinct | examples | notes |",
|
|
419
|
+
"|---|---|---|---|---|---|",
|
|
420
|
+
]
|
|
421
|
+
for profile in profiles[:max_rows]:
|
|
422
|
+
cells = [
|
|
423
|
+
profile.path,
|
|
424
|
+
_types_cell(profile),
|
|
425
|
+
_nulls_cell(profile),
|
|
426
|
+
str(profile.distinct),
|
|
427
|
+
_examples_cell(profile),
|
|
428
|
+
_notes_cell(profile, total_documents),
|
|
429
|
+
]
|
|
430
|
+
lines.append("| " + " | ".join(_cell(cell) for cell in cells) + " |")
|
|
431
|
+
hidden = len(profiles) - max_rows
|
|
432
|
+
if hidden > 0:
|
|
433
|
+
noun = "field" if hidden == 1 else "fields"
|
|
434
|
+
lines.append(f"\n({hidden} more {noun} not shown; max_rows={max_rows})")
|
|
435
|
+
return "\n".join(lines)
|