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.
Files changed (131) hide show
  1. fauxcall/__init__.py +10 -0
  2. fauxcall/__main__.py +6 -0
  3. fauxcall/analysis/__init__.py +1 -0
  4. fauxcall/analysis/jobs.py +186 -0
  5. fauxcall/analysis/profile.py +435 -0
  6. fauxcall/analysis/relations.py +220 -0
  7. fauxcall/analysis/schema.py +1080 -0
  8. fauxcall/analysis/sqlview.py +169 -0
  9. fauxcall/analysis/timing.py +189 -0
  10. fauxcall/analysis/writes.py +238 -0
  11. fauxcall/build/__init__.py +1 -0
  12. fauxcall/build/init.py +491 -0
  13. fauxcall/build/scaffold.py +842 -0
  14. fauxcall/build/templates/fake/Dockerfile.j2 +13 -0
  15. fauxcall/build/templates/fake/README.md.j2 +58 -0
  16. fauxcall/build/templates/fake/env.example.j2 +13 -0
  17. fauxcall/build/templates/fake/fauxcall.toml.j2 +3 -0
  18. fauxcall/build/templates/fake/pyproject.toml.j2 +77 -0
  19. fauxcall/build/templates/fake/src/__init__.py.j2 +5 -0
  20. fauxcall/build/templates/fake/src/__main__.py.j2 +28 -0
  21. fauxcall/build/templates/fake/src/app.py.j2 +33 -0
  22. fauxcall/build/templates/fake/src/handlers.py.j2 +39 -0
  23. fauxcall/build/templates/fake/src/routes.py.j2 +27 -0
  24. fauxcall/build/templates/fake/src/settings.py.j2 +29 -0
  25. fauxcall/build/templates/fake/tests/conftest.py.j2 +23 -0
  26. fauxcall/build/templates/fake/tests/e2e/test_scenarios.py.j2 +22 -0
  27. fauxcall/build/templates/fake/tests/unit/test_app.py.j2 +62 -0
  28. fauxcall/build/templates/fake/tests/unit/test_recorded.py.j2 +39 -0
  29. fauxcall/build/templates/fake/tests/unit/test_routes.py.j2 +41 -0
  30. fauxcall/build/templates/workspace/README.md.j2 +29 -0
  31. fauxcall/build/templates/workspace/ci.yml.j2 +25 -0
  32. fauxcall/build/templates/workspace/compose.yaml.j2 +16 -0
  33. fauxcall/build/templates/workspace/dockerignore.j2 +14 -0
  34. fauxcall/build/templates/workspace/gitignore.j2 +13 -0
  35. fauxcall/build/templates/workspace/pyproject.toml.j2 +10 -0
  36. fauxcall/build/world.py +143 -0
  37. fauxcall/calendar.py +250 -0
  38. fauxcall/cli/__init__.py +1 -0
  39. fauxcall/cli/building.py +202 -0
  40. fauxcall/cli/common.py +172 -0
  41. fauxcall/cli/evidence.py +784 -0
  42. fauxcall/cli/main.py +81 -0
  43. fauxcall/cli/project.py +574 -0
  44. fauxcall/cli/running.py +243 -0
  45. fauxcall/clock.py +440 -0
  46. fauxcall/config.py +243 -0
  47. fauxcall/errors.py +31 -0
  48. fauxcall/jsonfmt.py +289 -0
  49. fauxcall/jsonpath.py +158 -0
  50. fauxcall/logs.py +161 -0
  51. fauxcall/mcpwire/__init__.py +4 -0
  52. fauxcall/mcpwire/client.py +430 -0
  53. fauxcall/mcpwire/protocol.py +237 -0
  54. fauxcall/model/__init__.py +125 -0
  55. fauxcall/model/api.py +262 -0
  56. fauxcall/model/auth.py +78 -0
  57. fauxcall/model/calendar.py +68 -0
  58. fauxcall/model/common.py +51 -0
  59. fauxcall/model/endpoints.py +300 -0
  60. fauxcall/model/errors.py +65 -0
  61. fauxcall/model/fields.py +198 -0
  62. fauxcall/model/jobs.py +99 -0
  63. fauxcall/model/mcp.py +104 -0
  64. fauxcall/model/plan.py +244 -0
  65. fauxcall/model/progress.py +131 -0
  66. fauxcall/model/realism.py +100 -0
  67. fauxcall/model/recording.py +123 -0
  68. fauxcall/model/semantic.py +69 -0
  69. fauxcall/model/world.py +104 -0
  70. fauxcall/paths.py +201 -0
  71. fauxcall/privacy/__init__.py +49 -0
  72. fauxcall/privacy/pii.py +831 -0
  73. fauxcall/privacy/pseudonym.py +691 -0
  74. fauxcall/privacy/redact.py +460 -0
  75. fauxcall/py.typed +0 -0
  76. fauxcall/recording/__init__.py +27 -0
  77. fauxcall/recording/mcp.py +530 -0
  78. fauxcall/recording/privacy_filter.py +266 -0
  79. fauxcall/recording/recorder.py +1126 -0
  80. fauxcall/recording/store.py +123 -0
  81. fauxcall/runtime/__init__.py +27 -0
  82. fauxcall/runtime/admin.py +214 -0
  83. fauxcall/runtime/app.py +728 -0
  84. fauxcall/runtime/auth.py +303 -0
  85. fauxcall/runtime/context.py +402 -0
  86. fauxcall/runtime/dynamic.py +67 -0
  87. fauxcall/runtime/faults.py +67 -0
  88. fauxcall/runtime/generators.py +76 -0
  89. fauxcall/runtime/handlers.py +630 -0
  90. fauxcall/runtime/inputs.py +59 -0
  91. fauxcall/runtime/jev.py +217 -0
  92. fauxcall/runtime/jobs.py +126 -0
  93. fauxcall/runtime/journal.py +160 -0
  94. fauxcall/runtime/listing.py +516 -0
  95. fauxcall/runtime/matching.py +132 -0
  96. fauxcall/runtime/mcp_server.py +490 -0
  97. fauxcall/runtime/oauth.py +239 -0
  98. fauxcall/runtime/params.py +219 -0
  99. fauxcall/runtime/policies.py +224 -0
  100. fauxcall/runtime/ratelimit.py +161 -0
  101. fauxcall/runtime/recordings.py +82 -0
  102. fauxcall/runtime/routing.py +110 -0
  103. fauxcall/runtime/series.py +96 -0
  104. fauxcall/runtime/streaming.py +284 -0
  105. fauxcall/runtime/templating.py +136 -0
  106. fauxcall/spec/__init__.py +40 -0
  107. fauxcall/spec/discover.py +954 -0
  108. fauxcall/spec/docs.py +286 -0
  109. fauxcall/spec/loader.py +309 -0
  110. fauxcall/spec/openapi.py +1036 -0
  111. fauxcall/storage.py +135 -0
  112. fauxcall/testing/__init__.py +17 -0
  113. fauxcall/testing/asgi.py +123 -0
  114. fauxcall/testing/client.py +553 -0
  115. fauxcall/testing/compare.py +515 -0
  116. fauxcall/testing/plugin.py +447 -0
  117. fauxcall/testing/report.py +195 -0
  118. fauxcall/testing/support.py +325 -0
  119. fauxcall/timefmt.py +395 -0
  120. fauxcall/types.py +32 -0
  121. fauxcall/world/__init__.py +55 -0
  122. fauxcall/world/expand.py +192 -0
  123. fauxcall/world/invariants.py +261 -0
  124. fauxcall/world/io.py +124 -0
  125. fauxcall/world/keys.py +210 -0
  126. fauxcall/world/store.py +573 -0
  127. fauxcall-0.1.0.dist-info/METADATA +256 -0
  128. fauxcall-0.1.0.dist-info/RECORD +131 -0
  129. fauxcall-0.1.0.dist-info/WHEEL +4 -0
  130. fauxcall-0.1.0.dist-info/entry_points.txt +3 -0
  131. 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,6 @@
1
+ """``python -m fauxcall``."""
2
+
3
+ from fauxcall.cli.main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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)