openom-cli 0.1.2__tar.gz → 0.1.5__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: openom-cli
3
- Version: 0.1.2
3
+ Version: 0.1.5
4
4
  Summary: openOM CLI - the `om` command over openom-core. Zero inference.
5
5
  Project-URL: Homepage, https://openom.app
6
6
  Project-URL: Documentation, https://openom.app/docs/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openom-cli"
7
- version = "0.1.2"
7
+ version = "0.1.5"
8
8
  description = "openOM CLI - the `om` command over openom-core. Zero inference."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,207 +1,220 @@
1
- # SPDX-License-Identifier: MIT
2
- """Deterministic Buildout listing -> openOM payload mapper (the connector->manifest bridge).
3
-
4
- Grounded in the REAL Buildout `get_listing` shape (nested ``core.research_property_attributes.*`` +
5
- ``custom_fields.*`` + ``financials``). Pure + zero-inference: it normalizes names/units and omits
6
- anything absent - it never guesses. The human/CLI supplies the assertion identity (assertedBy,
7
- assertedDate, noiType, noiAsOfDate); those are never inferred from Buildout. The output is a schema-
8
- valid openOM payload ready for ``om embed-batch``.
9
-
10
- Note the two cap rates: ``cap_rate`` is Buildout's stated "Average CAP Rate" over the term (often
11
- absent); ``cap_rate_derived`` is current NOI/price. We map the derived one because it is what the
12
- openOM consistency check (NOI/price vs capRate) expects and what the OM's headline cap reflects.
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- from typing import Any
18
-
19
- NS = "https://openom.app/ns/0.1"
20
-
21
-
22
- def _num(v: Any) -> float | None:
23
- try:
24
- return float(str(v).replace(",", "").strip())
25
- except (TypeError, ValueError):
26
- return None
27
-
28
-
29
- def _int(v: Any) -> int | None:
30
- n = _num(v)
31
- return int(n) if n is not None else None
32
-
33
-
34
- def _pct_to_fraction(v: Any) -> float | None:
35
- n = _num(v)
36
- return round(n / 100, 6) if n is not None else None
37
-
38
-
39
- def _iso_date(mdy: Any) -> str | None:
40
- """'10/1/2026' -> '2026-10-01'. Returns None if not an M/D/Y date."""
41
- if not mdy:
42
- return None
43
- parts = str(mdy).strip().split("/")
44
- if len(parts) != 3:
45
- return None
46
- try:
47
- m, d, y = (int(p) for p in parts)
48
- except ValueError:
49
- return None
50
- if not (1 <= m <= 12 and 1 <= d <= 31 and y > 1900):
51
- return None
52
- return f"{y:04d}-{m:02d}-{d:02d}"
53
-
54
-
55
- def _state_code(v: Any) -> str | None:
56
- """'GA - Georgia' -> 'GA'; 'GA' -> 'GA'."""
57
- if not v:
58
- return None
59
- head = str(v).split("-")[0].strip()
60
- return head.upper() if len(head) == 2 else None
61
-
62
-
63
- def _lease_type(v: Any) -> str | None:
64
- """Map Buildout's free-text lease type to the openOM asserted value."""
65
- if not v:
66
- return None
67
- s = str(v).upper()
68
- if "NNN" in s:
69
- return "NNN"
70
- if "NN" in s:
71
- return "NN"
72
- if "GROSS" in s:
73
- return "gross"
74
- return str(v)
75
-
76
-
77
- def _compact(d: dict[str, Any]) -> dict[str, Any]:
78
- # drop absent values, incl. empty strings (schema forbids ""), but keep 0/0.0/False
79
- return {k: v for k, v in d.items() if v not in (None, "", {}, [])}
80
-
81
-
82
- def _months_between(start_iso: str | None, end_iso: str | None) -> int | None:
83
- """Whole months between two ISO (YYYY-MM-DD) dates, or None. Deterministic (no clock)."""
84
- if not start_iso or not end_iso:
85
- return None
86
- try:
87
- sy, sm, sd = (int(x) for x in start_iso.split("-"))
88
- ey, em, ed = (int(x) for x in end_iso.split("-"))
89
- except ValueError:
90
- return None
91
- months = (ey - sy) * 12 + (em - sm)
92
- if ed < sd: # a partial trailing month doesn't count
93
- months -= 1
94
- return months if months >= 0 else None
95
-
96
-
97
- # Canonical fields tracked for a back-catalog coverage report (the numbers a buyer underwrites).
98
- # Used to flag near-empty payloads before a bulk embed (Rule 6: review at scale).
99
- _COVERAGE_FIELDS: tuple[tuple[str, tuple[str, ...]], ...] = (
100
- ("askingPrice", ("deal", "askingPrice")),
101
- ("capRate", ("deal", "capRate")),
102
- ("noi", ("deal", "noi")),
103
- ("address", ("property", "address")),
104
- ("buildingSF", ("property", "buildingSF")),
105
- ("tenant", ("lease", "tenantEntity")),
106
- ("leaseType", ("lease", "leaseTypeAsserted")),
107
- ("expiration", ("lease", "expiration")),
108
- )
109
-
110
-
111
- def payload_coverage(payload: dict[str, Any]) -> dict[str, Any]:
112
- """Which tracked fields a mapped payload actually carries - for a pre-embed coverage report."""
113
- present: list[str] = []
114
- missing: list[str] = []
115
- for name, path in _COVERAGE_FIELDS:
116
- cur: Any = payload
117
- for seg in path:
118
- cur = cur.get(seg) if isinstance(cur, dict) else None
119
- (present if cur not in (None, "", {}, []) else missing).append(name)
120
- return {
121
- "filled": len(present), "of": len(_COVERAGE_FIELDS),
122
- "present": present, "missing": missing,
123
- }
124
-
125
-
126
- def listing_to_payload(
127
- listing: dict[str, Any],
128
- *,
129
- asserted_by: dict[str, str],
130
- asserted_date: str,
131
- noi_type: str,
132
- noi_as_of: str | None = None,
133
- ) -> dict[str, Any]:
134
- """Map one Buildout ``get_listing`` object to a schema-valid openOM payload (partial fields only
135
- where Buildout has them). ``asserted_by``/``asserted_date``/``noi_type``/``noi_as_of`` are the
136
- human's assertion identity and are stamped verbatim, never inferred."""
137
- core: dict[str, Any] = listing.get("core", {})
138
- cf: dict[str, Any] = listing.get("custom_fields", {})
139
- fin: dict[str, Any] = listing.get("financials", {})
140
-
141
- def rp(attr: str) -> Any:
142
- return core.get(f"research_property_attributes.{attr}")
143
-
144
- address = _compact({
145
- "streetAddress": rp("address"),
146
- "addressLocality": rp("city"),
147
- "addressRegion": _state_code(rp("state")),
148
- "postalCode": rp("zip"),
149
- "addressCountry": "US" if str(rp("country_id")) == "1" else None,
150
- })
151
- lat, lng = _num(rp("latitude")), _num(rp("longitude"))
152
- geo = {"latitude": lat, "longitude": lng} if lat is not None and lng is not None else None
153
- lot = _num(rp("lot_size")) if str(rp("lot_size_units")).lower().startswith("acre") else None
154
- building_sf = _int(rp("building_size"))
155
- units = _int(rp("number_of_units"))
156
- # propertyType ([M4]): the primary asset-class filter, trivially mappable and never auto-filled
157
- # before. Buildout exposes it as a research attribute; omitted (never guessed) when absent.
158
- prop_type = rp("property_type") or rp("property_sub_type") or cf.get("Property type")
159
- property_ = _compact({
160
- "propertyType": str(prop_type).strip().lower() if prop_type else None,
161
- "address": address or None,
162
- "geo": geo,
163
- "buildingSF": building_sf,
164
- "yearBuilt": _int(rp("year_built")),
165
- "lotAcres": lot,
166
- "units": units,
167
- "occupancy": _pct_to_fraction(rp("occupancy_pct")),
168
- })
169
-
170
- price = _int(fin.get("sale_price"))
171
- deal = _compact({
172
- "askingPrice": price,
173
- "capRate": _pct_to_fraction(fin.get("cap_rate_derived") or fin.get("cap_rate")),
174
- "noi": _int(fin.get("noi") or cf.get("NOI")),
175
- # Deterministically derived from mapped values ([M4]); not in Buildout, computed here.
176
- "pricePerUnit": round(price / units) if price and units else None,
177
- "pricePerSF": round(price / building_sf, 2) if price and building_sf else None,
178
- "noiType": noi_type,
179
- "noiAsOfDate": noi_as_of or asserted_date,
180
- "status": "active",
181
- })
182
-
183
- commencement = _iso_date(cf.get("Lease start date"))
184
- expiration = _iso_date(cf.get("Lease expiration date"))
185
- guarantor_name = cf.get("Lease guarantor")
186
- lease = _compact({
187
- "tenantEntity": cf.get("Tenant"),
188
- "leaseTypeAsserted": _lease_type(cf.get("Lease type")),
189
- "commencement": commencement,
190
- "expiration": expiration,
191
- # termMonths ([M4]): derived from the two dates above, deterministic (no clock).
192
- "termMonths": _months_between(commencement, expiration),
193
- "guarantor": {"name": guarantor_name, "type": "corporate"} if guarantor_name else None,
194
- })
195
-
196
- return _compact({
197
- "@context": ["https://schema.org", NS],
198
- "@type": "RealEstateListing",
199
- "specVersion": "0.1",
200
- "assertedBy": _compact(dict(asserted_by)),
201
- "assertedDate": asserted_date,
202
- "property": property_ or None,
203
- "deal": deal or None,
204
- "lease": lease or None,
205
- # A fresh assertion has no prior; a re-embed records supersedes = prior payload hash (core).
206
- "meta": {"supersedes": None},
207
- })
1
+ # SPDX-License-Identifier: MIT
2
+ """Deterministic Buildout listing -> openOM payload mapper (the connector->manifest bridge).
3
+
4
+ Grounded in the REAL Buildout `get_listing` shape (nested ``core.research_property_attributes.*`` +
5
+ ``custom_fields.*`` + ``financials``). Pure + zero-inference: it normalizes names/units and omits
6
+ anything absent - it never guesses. The human/CLI supplies the assertion identity (assertedBy,
7
+ assertedDate, noiType, noiAsOfDate); those are never inferred from Buildout. The output is a schema-
8
+ valid openOM payload ready for ``om embed-batch``.
9
+
10
+ Note the two cap rates: ``cap_rate`` is Buildout's stated "Average CAP Rate" over the term (often
11
+ absent); ``cap_rate_derived`` is current NOI/price. We map the derived one because it is what the
12
+ openOM consistency check (NOI/price vs capRate) expects and what the OM's headline cap reflects.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from typing import Any
19
+
20
+ NS = "https://openom.app/ns/0.1"
21
+
22
+
23
+ def _num(v: Any) -> float | None:
24
+ try:
25
+ n = float(str(v).replace(",", "").strip())
26
+ except (TypeError, ValueError):
27
+ return None
28
+ # A non-finite cell (1e400 / inf / nan) is omitted, not propagated - mirrors the JS connector's
29
+ # Number.isFinite guard and stops _int() raising OverflowError (which aborted the batch).
30
+ return n if math.isfinite(n) else None
31
+
32
+
33
+ def _int(v: Any) -> int | None:
34
+ n = _num(v)
35
+ return int(n) if n is not None else None
36
+
37
+
38
+ def _round_half_up(x: float, ndigits: int = 0) -> float:
39
+ # Match JS Math.round(x*m)/m EXACTLY (half-UP), not Python's banker's round(): the derived facts
40
+ # (pricePerUnit/pricePerSF/pctToFraction) must be byte-identical across the CLI mapper and the
41
+ # JS connector - a .5 tie is where round()-half-even and Math.round-half-up silently forked. All
42
+ # mapper inputs are positive, so floor(x*m + 0.5)/m == Math.round(x*m)/m.
43
+ m: int = 10**ndigits
44
+ return float(math.floor(x * m + 0.5)) / m
45
+
46
+
47
+ def _pct_to_fraction(v: Any) -> float | None:
48
+ n = _num(v)
49
+ return _round_half_up(n / 100, 6) if n is not None else None
50
+
51
+
52
+ def _iso_date(mdy: Any) -> str | None:
53
+ """'10/1/2026' -> '2026-10-01'. Returns None if not an M/D/Y date."""
54
+ if not mdy:
55
+ return None
56
+ parts = str(mdy).strip().split("/")
57
+ if len(parts) != 3:
58
+ return None
59
+ try:
60
+ m, d, y = (int(p) for p in parts)
61
+ except ValueError:
62
+ return None
63
+ if not (1 <= m <= 12 and 1 <= d <= 31 and y > 1900):
64
+ return None
65
+ return f"{y:04d}-{m:02d}-{d:02d}"
66
+
67
+
68
+ def _state_code(v: Any) -> str | None:
69
+ """'GA - Georgia' -> 'GA'; 'GA' -> 'GA'."""
70
+ if not v:
71
+ return None
72
+ head = str(v).split("-")[0].strip()
73
+ return head.upper() if len(head) == 2 else None
74
+
75
+
76
+ def _lease_type(v: Any) -> str | None:
77
+ """Map Buildout's free-text lease type to the openOM asserted value."""
78
+ if not v:
79
+ return None
80
+ s = str(v).upper()
81
+ if "NNN" in s:
82
+ return "NNN"
83
+ if "NN" in s:
84
+ return "NN"
85
+ if "GROSS" in s:
86
+ return "gross"
87
+ return str(v)
88
+
89
+
90
+ def _compact(d: dict[str, Any]) -> dict[str, Any]:
91
+ # drop absent values, incl. empty strings (schema forbids ""), but keep 0/0.0/False
92
+ return {k: v for k, v in d.items() if v not in (None, "", {}, [])}
93
+
94
+
95
+ def _months_between(start_iso: str | None, end_iso: str | None) -> int | None:
96
+ """Whole months between two ISO (YYYY-MM-DD) dates, or None. Deterministic (no clock)."""
97
+ if not start_iso or not end_iso:
98
+ return None
99
+ try:
100
+ sy, sm, sd = (int(x) for x in start_iso.split("-"))
101
+ ey, em, ed = (int(x) for x in end_iso.split("-"))
102
+ except ValueError:
103
+ return None
104
+ months = (ey - sy) * 12 + (em - sm)
105
+ if ed < sd: # a partial trailing month doesn't count
106
+ months -= 1
107
+ return months if months >= 0 else None
108
+
109
+
110
+ # Canonical fields tracked for a back-catalog coverage report (the numbers a buyer underwrites).
111
+ # Used to flag near-empty payloads before a bulk embed (Rule 6: review at scale).
112
+ _COVERAGE_FIELDS: tuple[tuple[str, tuple[str, ...]], ...] = (
113
+ ("askingPrice", ("deal", "askingPrice")),
114
+ ("capRate", ("deal", "capRate")),
115
+ ("noi", ("deal", "noi")),
116
+ ("address", ("property", "address")),
117
+ ("buildingSF", ("property", "buildingSF")),
118
+ ("tenant", ("lease", "tenantEntity")),
119
+ ("leaseType", ("lease", "leaseTypeAsserted")),
120
+ ("expiration", ("lease", "expiration")),
121
+ )
122
+
123
+
124
+ def payload_coverage(payload: dict[str, Any]) -> dict[str, Any]:
125
+ """Which tracked fields a mapped payload actually carries - for a pre-embed coverage report."""
126
+ present: list[str] = []
127
+ missing: list[str] = []
128
+ for name, path in _COVERAGE_FIELDS:
129
+ cur: Any = payload
130
+ for seg in path:
131
+ cur = cur.get(seg) if isinstance(cur, dict) else None
132
+ (present if cur not in (None, "", {}, []) else missing).append(name)
133
+ return {
134
+ "filled": len(present), "of": len(_COVERAGE_FIELDS),
135
+ "present": present, "missing": missing,
136
+ }
137
+
138
+
139
+ def listing_to_payload(
140
+ listing: dict[str, Any],
141
+ *,
142
+ asserted_by: dict[str, str],
143
+ asserted_date: str,
144
+ noi_type: str,
145
+ noi_as_of: str | None = None,
146
+ ) -> dict[str, Any]:
147
+ """Map one Buildout ``get_listing`` object to a schema-valid openOM payload (partial fields only
148
+ where Buildout has them). ``asserted_by``/``asserted_date``/``noi_type``/``noi_as_of`` are the
149
+ human's assertion identity and are stamped verbatim, never inferred."""
150
+ core: dict[str, Any] = listing.get("core", {})
151
+ cf: dict[str, Any] = listing.get("custom_fields", {})
152
+ fin: dict[str, Any] = listing.get("financials", {})
153
+
154
+ def rp(attr: str) -> Any:
155
+ return core.get(f"research_property_attributes.{attr}")
156
+
157
+ address = _compact({
158
+ "streetAddress": rp("address"),
159
+ "addressLocality": rp("city"),
160
+ "addressRegion": _state_code(rp("state")),
161
+ "postalCode": rp("zip"),
162
+ "addressCountry": "US" if str(rp("country_id")) == "1" else None,
163
+ })
164
+ lat, lng = _num(rp("latitude")), _num(rp("longitude"))
165
+ geo = {"latitude": lat, "longitude": lng} if lat is not None and lng is not None else None
166
+ lot = _num(rp("lot_size")) if str(rp("lot_size_units")).lower().startswith("acre") else None
167
+ building_sf = _int(rp("building_size"))
168
+ units = _int(rp("number_of_units"))
169
+ # propertyType ([M4]): the primary asset-class filter, trivially mappable and never auto-filled
170
+ # before. Buildout exposes it as a research attribute; omitted (never guessed) when absent.
171
+ prop_type = rp("property_type") or rp("property_sub_type") or cf.get("Property type")
172
+ property_ = _compact({
173
+ "propertyType": str(prop_type).strip().lower() if prop_type else None,
174
+ "address": address or None,
175
+ "geo": geo,
176
+ "buildingSF": building_sf,
177
+ "yearBuilt": _int(rp("year_built")),
178
+ "lotAcres": lot,
179
+ "units": units,
180
+ "occupancy": _pct_to_fraction(rp("occupancy_pct")),
181
+ })
182
+
183
+ price = _int(fin.get("sale_price"))
184
+ deal = _compact({
185
+ "askingPrice": price,
186
+ "capRate": _pct_to_fraction(fin.get("cap_rate_derived") or fin.get("cap_rate")),
187
+ "noi": _int(fin.get("noi") or cf.get("NOI")),
188
+ # Deterministically derived from mapped values ([M4]); not in Buildout, computed here.
189
+ "pricePerUnit": int(_round_half_up(price / units)) if price and units else None,
190
+ "pricePerSF": _round_half_up(price / building_sf, 2) if price and building_sf else None,
191
+ "noiType": noi_type,
192
+ "noiAsOfDate": noi_as_of or asserted_date,
193
+ "status": "active",
194
+ })
195
+
196
+ commencement = _iso_date(cf.get("Lease start date"))
197
+ expiration = _iso_date(cf.get("Lease expiration date"))
198
+ guarantor_name = cf.get("Lease guarantor")
199
+ lease = _compact({
200
+ "tenantEntity": cf.get("Tenant"),
201
+ "leaseTypeAsserted": _lease_type(cf.get("Lease type")),
202
+ "commencement": commencement,
203
+ "expiration": expiration,
204
+ # termMonths ([M4]): derived from the two dates above, deterministic (no clock).
205
+ "termMonths": _months_between(commencement, expiration),
206
+ "guarantor": {"name": guarantor_name, "type": "corporate"} if guarantor_name else None,
207
+ })
208
+
209
+ return _compact({
210
+ "@context": ["https://schema.org", NS],
211
+ "@type": "RealEstateListing",
212
+ "specVersion": "0.1",
213
+ "assertedBy": _compact(dict(asserted_by)),
214
+ "assertedDate": asserted_date,
215
+ "property": property_ or None,
216
+ "deal": deal or None,
217
+ "lease": lease or None,
218
+ # A fresh assertion has no prior; a re-embed records supersedes = prior payload hash (core).
219
+ "meta": {"supersedes": None},
220
+ })
@@ -1,171 +1,172 @@
1
- # SPDX-License-Identifier: MIT
2
- """Deterministic CSV row -> openOM payload mapper (the spreadsheet on-ramp for bulk seeding).
3
-
4
- A broker with a back catalog usually has a spreadsheet + a folder of PDFs, not Buildout JSON. This
5
- maps one CSV row (a documented canonical column set) to a schema-valid openOM payload, ready for
6
- ``om embed-batch`` - the same output shape as the Buildout bridge, different input. Pure + zero
7
- inference: every value comes from the broker's own cell (that IS the assertion); absent cells are
8
- omitted, never guessed. The assertion identity (assertedBy / assertedDate / noiType / noiAsOfDate)
9
- is supplied by the caller and stamped verbatim.
10
-
11
- Numeric/date/state normalization reuses the SAME helpers as the Buildout mapper, so a number, a
12
- percent, or an M/D/Y date maps identically on both on-ramps. Percentage columns are named ``*Pct``
13
- (e.g. ``capRatePct`` = 6.25, not 0.0625) so a non-technical broker can't misread the unit.
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import csv
19
- import io
20
- from collections.abc import Mapping
21
- from typing import Any
22
-
23
- from .buildout import (
24
- NS,
25
- _compact,
26
- _int,
27
- _iso_date,
28
- _lease_type,
29
- _months_between,
30
- _num,
31
- _pct_to_fraction,
32
- _state_code,
33
- )
34
-
35
- # The canonical header vocabulary a broker fills in. Order is the template's column order. Every
36
- # column is optional except that a row must name its PDF (an ``id`` -> <id>.pdf, or an explicit
37
- # ``pdf`` filename) and produce a schema-valid payload. ``*Pct`` columns are percentages.
38
- CANONICAL_COLUMNS: tuple[str, ...] = (
39
- "id", "pdf",
40
- "broker", "brokerage", "license", "noiType", "noiAsOfDate",
41
- "streetAddress", "city", "state", "postalCode", "country",
42
- "propertyType", "buildingSF", "yearBuilt", "lotAcres", "units", "occupancyPct",
43
- "latitude", "longitude",
44
- "askingPrice", "capRatePct", "noi", "status",
45
- "tenant", "leaseType", "commencement", "expiration", "guarantor",
46
- )
47
-
48
- # Per-row overrides of the assertion identity (a catalog can span brokers / NOI types).
49
- _OVERRIDE_COLUMNS: tuple[str, ...] = ("broker", "brokerage", "license", "noiType", "noiAsOfDate")
50
-
51
- _EXAMPLE_ROW: dict[str, str] = {
52
- "id": "123-main", "pdf": "123-main.pdf",
53
- "streetAddress": "123 Main St", "city": "Austin", "state": "TX", "postalCode": "78701",
54
- "propertyType": "retail", "buildingSF": "9100", "yearBuilt": "2019",
55
- "askingPrice": "1850000", "capRatePct": "6.25", "noi": "115625",
56
- "tenant": "Example Retail, LLC", "leaseType": "NNN",
57
- "commencement": "5/1/2019", "expiration": "4/30/2034",
58
- }
59
-
60
-
61
- def _cell(row: Mapping[str, str], key: str) -> str | None:
62
- """A trimmed cell value, or None when the column is absent/blank (so _compact drops it)."""
63
- v = row.get(key)
64
- if v is None:
65
- return None
66
- s = str(v).strip()
67
- return s or None
68
-
69
-
70
- def _date(v: str | None) -> str | None:
71
- """Accept either an ISO date (pass-through) or an M/D/Y spreadsheet date."""
72
- if not v:
73
- return None
74
- s = str(v).strip()
75
- parts = s.split("-")
76
- if len(parts) == 3 and len(parts[0]) == 4: # already ISO-ish -> validate via round-trip
77
- try:
78
- y, m, d = (int(p) for p in parts)
79
- except ValueError:
80
- return None
81
- if 1 <= m <= 12 and 1 <= d <= 31 and y > 1900:
82
- return f"{y:04d}-{m:02d}-{d:02d}"
83
- return None
84
- return _iso_date(s)
85
-
86
-
87
- def override_identity(row: Mapping[str, str]) -> dict[str, str]:
88
- """The per-row assertion-identity overrides present in this row (subset of the override set)."""
89
- return {k: v for k in _OVERRIDE_COLUMNS if (v := _cell(row, k)) is not None}
90
-
91
-
92
- def row_to_payload(
93
- row: Mapping[str, str],
94
- *,
95
- asserted_by: dict[str, str],
96
- asserted_date: str,
97
- noi_type: str,
98
- noi_as_of: str | None = None,
99
- ) -> dict[str, Any]:
100
- """Map one canonical CSV row to a schema-valid openOM payload (only the fields the row carries).
101
- ``asserted_by``/``asserted_date``/``noi_type``/``noi_as_of`` are the assertion identity and are
102
- stamped verbatim, never inferred from the row."""
103
- address = _compact({
104
- "streetAddress": _cell(row, "streetAddress"),
105
- "addressLocality": _cell(row, "city"),
106
- "addressRegion": _state_code(_cell(row, "state")),
107
- "postalCode": _cell(row, "postalCode"),
108
- "addressCountry": _cell(row, "country") or ("US" if _cell(row, "state") else None),
109
- })
110
- lat, lng = _num(_cell(row, "latitude")), _num(_cell(row, "longitude"))
111
- geo = {"latitude": lat, "longitude": lng} if lat is not None and lng is not None else None
112
- building_sf = _int(_cell(row, "buildingSF"))
113
- units = _int(_cell(row, "units"))
114
- prop_type = _cell(row, "propertyType")
115
- property_ = _compact({
116
- "propertyType": prop_type.lower() if prop_type else None,
117
- "address": address or None,
118
- "geo": geo,
119
- "buildingSF": building_sf,
120
- "yearBuilt": _int(_cell(row, "yearBuilt")),
121
- "lotAcres": _num(_cell(row, "lotAcres")),
122
- "units": units,
123
- "occupancy": _pct_to_fraction(_cell(row, "occupancyPct")),
124
- })
125
-
126
- price = _int(_cell(row, "askingPrice"))
127
- deal = _compact({
128
- "askingPrice": price,
129
- "capRate": _pct_to_fraction(_cell(row, "capRatePct")),
130
- "noi": _int(_cell(row, "noi")),
131
- "pricePerUnit": round(price / units) if price and units else None,
132
- "pricePerSF": round(price / building_sf, 2) if price and building_sf else None,
133
- "noiType": noi_type,
134
- "noiAsOfDate": noi_as_of or asserted_date,
135
- "status": _cell(row, "status") or "active",
136
- })
137
-
138
- commencement = _date(_cell(row, "commencement"))
139
- expiration = _date(_cell(row, "expiration"))
140
- guarantor_name = _cell(row, "guarantor")
141
- lease = _compact({
142
- "tenantEntity": _cell(row, "tenant"),
143
- "leaseTypeAsserted": _lease_type(_cell(row, "leaseType")),
144
- "commencement": commencement,
145
- "expiration": expiration,
146
- "termMonths": _months_between(commencement, expiration),
147
- "guarantor": {"name": guarantor_name, "type": "corporate"} if guarantor_name else None,
148
- })
149
-
150
- return _compact({
151
- "@context": ["https://schema.org", NS],
152
- "@type": "RealEstateListing",
153
- "specVersion": "0.1",
154
- "assertedBy": _compact(dict(asserted_by)),
155
- "assertedDate": asserted_date,
156
- "property": property_ or None,
157
- "deal": deal or None,
158
- "lease": lease or None,
159
- "meta": {"supersedes": None},
160
- })
161
-
162
-
163
- def template_csv() -> str:
164
- """A blank template: the canonical header row + one worked example, so a broker knows exactly
165
- what to fill in. Assertion identity (broker/brokerage/license/noiType) normally comes from the
166
- command flags; the columns exist only for a catalog that spans brokers."""
167
- buf = io.StringIO()
168
- writer = csv.writer(buf, lineterminator="\n") # csv.writer quotes cells containing commas
169
- writer.writerow(CANONICAL_COLUMNS)
170
- writer.writerow([_EXAMPLE_ROW.get(c, "") for c in CANONICAL_COLUMNS])
171
- return buf.getvalue()
1
+ # SPDX-License-Identifier: MIT
2
+ """Deterministic CSV row -> openOM payload mapper (the spreadsheet on-ramp for bulk seeding).
3
+
4
+ A broker with a back catalog usually has a spreadsheet + a folder of PDFs, not Buildout JSON. This
5
+ maps one CSV row (a documented canonical column set) to a schema-valid openOM payload, ready for
6
+ ``om embed-batch`` - the same output shape as the Buildout bridge, different input. Pure + zero
7
+ inference: every value comes from the broker's own cell (that IS the assertion); absent cells are
8
+ omitted, never guessed. The assertion identity (assertedBy / assertedDate / noiType / noiAsOfDate)
9
+ is supplied by the caller and stamped verbatim.
10
+
11
+ Numeric/date/state normalization reuses the SAME helpers as the Buildout mapper, so a number, a
12
+ percent, or an M/D/Y date maps identically on both on-ramps. Percentage columns are named ``*Pct``
13
+ (e.g. ``capRatePct`` = 6.25, not 0.0625) so a non-technical broker can't misread the unit.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import csv
19
+ import io
20
+ from collections.abc import Mapping
21
+ from typing import Any
22
+
23
+ from .buildout import (
24
+ NS,
25
+ _compact,
26
+ _int,
27
+ _iso_date,
28
+ _lease_type,
29
+ _months_between,
30
+ _num,
31
+ _pct_to_fraction,
32
+ _round_half_up,
33
+ _state_code,
34
+ )
35
+
36
+ # The canonical header vocabulary a broker fills in. Order is the template's column order. Every
37
+ # column is optional except that a row must name its PDF (an ``id`` -> <id>.pdf, or an explicit
38
+ # ``pdf`` filename) and produce a schema-valid payload. ``*Pct`` columns are percentages.
39
+ CANONICAL_COLUMNS: tuple[str, ...] = (
40
+ "id", "pdf",
41
+ "broker", "brokerage", "license", "noiType", "noiAsOfDate",
42
+ "streetAddress", "city", "state", "postalCode", "country",
43
+ "propertyType", "buildingSF", "yearBuilt", "lotAcres", "units", "occupancyPct",
44
+ "latitude", "longitude",
45
+ "askingPrice", "capRatePct", "noi", "status",
46
+ "tenant", "leaseType", "commencement", "expiration", "guarantor",
47
+ )
48
+
49
+ # Per-row overrides of the assertion identity (a catalog can span brokers / NOI types).
50
+ _OVERRIDE_COLUMNS: tuple[str, ...] = ("broker", "brokerage", "license", "noiType", "noiAsOfDate")
51
+
52
+ _EXAMPLE_ROW: dict[str, str] = {
53
+ "id": "123-main", "pdf": "123-main.pdf",
54
+ "streetAddress": "123 Main St", "city": "Austin", "state": "TX", "postalCode": "78701",
55
+ "propertyType": "retail", "buildingSF": "9100", "yearBuilt": "2019",
56
+ "askingPrice": "1850000", "capRatePct": "6.25", "noi": "115625",
57
+ "tenant": "Example Retail, LLC", "leaseType": "NNN",
58
+ "commencement": "5/1/2019", "expiration": "4/30/2034",
59
+ }
60
+
61
+
62
+ def _cell(row: Mapping[str, str], key: str) -> str | None:
63
+ """A trimmed cell value, or None when the column is absent/blank (so _compact drops it)."""
64
+ v = row.get(key)
65
+ if v is None:
66
+ return None
67
+ s = str(v).strip()
68
+ return s or None
69
+
70
+
71
+ def _date(v: str | None) -> str | None:
72
+ """Accept either an ISO date (pass-through) or an M/D/Y spreadsheet date."""
73
+ if not v:
74
+ return None
75
+ s = str(v).strip()
76
+ parts = s.split("-")
77
+ if len(parts) == 3 and len(parts[0]) == 4: # already ISO-ish -> validate via round-trip
78
+ try:
79
+ y, m, d = (int(p) for p in parts)
80
+ except ValueError:
81
+ return None
82
+ if 1 <= m <= 12 and 1 <= d <= 31 and y > 1900:
83
+ return f"{y:04d}-{m:02d}-{d:02d}"
84
+ return None
85
+ return _iso_date(s)
86
+
87
+
88
+ def override_identity(row: Mapping[str, str]) -> dict[str, str]:
89
+ """The per-row assertion-identity overrides present in this row (subset of the override set)."""
90
+ return {k: v for k in _OVERRIDE_COLUMNS if (v := _cell(row, k)) is not None}
91
+
92
+
93
+ def row_to_payload(
94
+ row: Mapping[str, str],
95
+ *,
96
+ asserted_by: dict[str, str],
97
+ asserted_date: str,
98
+ noi_type: str,
99
+ noi_as_of: str | None = None,
100
+ ) -> dict[str, Any]:
101
+ """Map one canonical CSV row to a schema-valid openOM payload (only the fields the row carries).
102
+ ``asserted_by``/``asserted_date``/``noi_type``/``noi_as_of`` are the assertion identity and are
103
+ stamped verbatim, never inferred from the row."""
104
+ address = _compact({
105
+ "streetAddress": _cell(row, "streetAddress"),
106
+ "addressLocality": _cell(row, "city"),
107
+ "addressRegion": _state_code(_cell(row, "state")),
108
+ "postalCode": _cell(row, "postalCode"),
109
+ "addressCountry": _cell(row, "country") or ("US" if _cell(row, "state") else None),
110
+ })
111
+ lat, lng = _num(_cell(row, "latitude")), _num(_cell(row, "longitude"))
112
+ geo = {"latitude": lat, "longitude": lng} if lat is not None and lng is not None else None
113
+ building_sf = _int(_cell(row, "buildingSF"))
114
+ units = _int(_cell(row, "units"))
115
+ prop_type = _cell(row, "propertyType")
116
+ property_ = _compact({
117
+ "propertyType": prop_type.lower() if prop_type else None,
118
+ "address": address or None,
119
+ "geo": geo,
120
+ "buildingSF": building_sf,
121
+ "yearBuilt": _int(_cell(row, "yearBuilt")),
122
+ "lotAcres": _num(_cell(row, "lotAcres")),
123
+ "units": units,
124
+ "occupancy": _pct_to_fraction(_cell(row, "occupancyPct")),
125
+ })
126
+
127
+ price = _int(_cell(row, "askingPrice"))
128
+ deal = _compact({
129
+ "askingPrice": price,
130
+ "capRate": _pct_to_fraction(_cell(row, "capRatePct")),
131
+ "noi": _int(_cell(row, "noi")),
132
+ "pricePerUnit": int(_round_half_up(price / units)) if price and units else None,
133
+ "pricePerSF": _round_half_up(price / building_sf, 2) if price and building_sf else None,
134
+ "noiType": noi_type,
135
+ "noiAsOfDate": noi_as_of or asserted_date,
136
+ "status": _cell(row, "status") or "active",
137
+ })
138
+
139
+ commencement = _date(_cell(row, "commencement"))
140
+ expiration = _date(_cell(row, "expiration"))
141
+ guarantor_name = _cell(row, "guarantor")
142
+ lease = _compact({
143
+ "tenantEntity": _cell(row, "tenant"),
144
+ "leaseTypeAsserted": _lease_type(_cell(row, "leaseType")),
145
+ "commencement": commencement,
146
+ "expiration": expiration,
147
+ "termMonths": _months_between(commencement, expiration),
148
+ "guarantor": {"name": guarantor_name, "type": "corporate"} if guarantor_name else None,
149
+ })
150
+
151
+ return _compact({
152
+ "@context": ["https://schema.org", NS],
153
+ "@type": "RealEstateListing",
154
+ "specVersion": "0.1",
155
+ "assertedBy": _compact(dict(asserted_by)),
156
+ "assertedDate": asserted_date,
157
+ "property": property_ or None,
158
+ "deal": deal or None,
159
+ "lease": lease or None,
160
+ "meta": {"supersedes": None},
161
+ })
162
+
163
+
164
+ def template_csv() -> str:
165
+ """A blank template: the canonical header row + one worked example, so a broker knows exactly
166
+ what to fill in. Assertion identity (broker/brokerage/license/noiType) normally comes from the
167
+ command flags; the columns exist only for a catalog that spans brokers."""
168
+ buf = io.StringIO()
169
+ writer = csv.writer(buf, lineterminator="\n") # csv.writer quotes cells containing commas
170
+ writer.writerow(CANONICAL_COLUMNS)
171
+ writer.writerow([_EXAMPLE_ROW.get(c, "") for c in CANONICAL_COLUMNS])
172
+ return buf.getvalue()
@@ -35,7 +35,12 @@ from openom_core.embed import embed as _embed
35
35
  from openom_core.embed import input_encrypted as _input_encrypted
36
36
  from openom_core.embed import read as _read
37
37
  from openom_core.embed import reembed_warnings as _reembed_warnings
38
- from openom_core.errors import CanonicalizationError, PayloadTooLargeError, SignedEmbedError
38
+ from openom_core.errors import (
39
+ CanonicalizationError,
40
+ EncryptedPdfError,
41
+ PayloadTooLargeError,
42
+ SignedEmbedError,
43
+ )
39
44
  from openom_core.images import extract_images as _extract_images
40
45
  from openom_core.inspect import inspect as _inspect
41
46
  from openom_core.text import extract_text as _extract_text
@@ -90,7 +95,9 @@ _DATA_ERRORS = (
90
95
  CanonicalizationError,
91
96
  PayloadTooLargeError,
92
97
  SignedEmbedError,
98
+ EncryptedPdfError, # password-protected PDF (OM-IO-011): clean refusal, never a traceback
93
99
  json.JSONDecodeError,
100
+ pikepdf.PasswordError, # a password error also leaks a BytesIO repr; map it cleanly
94
101
  pikepdf.PdfError,
95
102
  FileNotFoundError,
96
103
  UnicodeDecodeError,
@@ -106,6 +113,14 @@ def _guard(fn: _F) -> _F:
106
113
  return fn(*args, **kwargs)
107
114
  except typer.Exit:
108
115
  raise
116
+ except (pikepdf.PdfError, pikepdf.PasswordError) as exc:
117
+ # pikepdf's message leaks the input BytesIO repr + memory address and carries no code;
118
+ # emit a stable OM-IO-010 (a password error is already mapped to OM-IO-011 above it).
119
+ pw = isinstance(exc, pikepdf.PasswordError)
120
+ pcode = "OM-IO-011" if pw else "OM-IO-010"
121
+ msg = "password-protected PDF" if pw else "malformed or unreadable PDF"
122
+ typer.echo(f"error: {pcode}: {msg}", err=True)
123
+ raise typer.Exit(3) from exc
109
124
  except _DATA_ERRORS as exc:
110
125
  code = getattr(exc, "code", None)
111
126
  typer.echo(f"error: {f'{code}: ' if code else ''}{exc}", err=True)
@@ -197,8 +212,17 @@ def _load_json(path: Path) -> dict[str, Any]:
197
212
  # parse_hardened enforces the same §J read invariants the embed/MCP paths do - reject duplicate
198
213
  # keys and over-deep nesting - degrading a pathological payload to a structured OM-IO error
199
214
  # (caught by _guard -> exit 3) instead of a RecursionError traceback.
200
- text = sys.stdin.read() if str(path) == "-" else path.read_text(encoding="utf-8")
201
- return cast("dict[str, Any]", _parse_hardened(text))
215
+ raw = sys.stdin.buffer.read() if str(path) == "-" else path.read_bytes()
216
+ if raw[:5].startswith(b"%PDF"):
217
+ # The natural first mistake: pointing `validate` (or another JSON verb) at an OM PDF. Give a
218
+ # plain hint toward the right verb instead of a cryptic UnicodeDecodeError on binary bytes.
219
+ typer.echo(
220
+ f"error: {path} looks like a PDF, not a JSON payload. To read the data embedded in an "
221
+ f"OM PDF use `om read {path}`; `validate` checks a deal.json (see `om init`).",
222
+ err=True,
223
+ )
224
+ raise typer.Exit(3)
225
+ return cast("dict[str, Any]", _parse_hardened(raw.decode("utf-8")))
202
226
 
203
227
 
204
228
  def _emit(obj: Any) -> None:
@@ -642,11 +666,15 @@ def buildout_manifest(
642
666
  "brokerage": row.get("brokerage", default_by["brokerage"]),
643
667
  "license": row.get("license", default_by["license"]),
644
668
  }
645
- payload = listing_to_payload(
646
- listing, asserted_by=asserted_by, asserted_date=asserted_date,
647
- noi_type=row.get("noiType", noi_type),
648
- noi_as_of=row.get("noiAsOfDate", noi_as_of),
649
- )
669
+ try:
670
+ payload = listing_to_payload(
671
+ listing, asserted_by=asserted_by, asserted_date=asserted_date,
672
+ noi_type=row.get("noiType", noi_type),
673
+ noi_as_of=row.get("noiAsOfDate", noi_as_of),
674
+ )
675
+ except Exception as e: # noqa: BLE001 - one malformed listing must not abort the whole batch
676
+ skipped.append({"id": stem, "reason": f"could not map listing: {e}"})
677
+ continue
650
678
  sidecar = out_dir / f"{stem}.om.json"
651
679
  sidecar.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
652
680
  manifest.append(
@@ -764,10 +792,14 @@ def csv_manifest( # noqa: C901 - a linear map-each-row-then-report, read top-do
764
792
  continue
765
793
  ov = override_identity(row)
766
794
  asserted_by = {k: ov.get(k, default_by[k]) for k in ("broker", "brokerage", "license")}
767
- payload = row_to_payload(
768
- row, asserted_by=asserted_by, asserted_date=asserted_date,
769
- noi_type=ov.get("noiType", noi_type), noi_as_of=ov.get("noiAsOfDate", noi_as_of),
770
- )
795
+ try:
796
+ payload = row_to_payload(
797
+ row, asserted_by=asserted_by, asserted_date=asserted_date,
798
+ noi_type=ov.get("noiType", noi_type), noi_as_of=ov.get("noiAsOfDate", noi_as_of),
799
+ )
800
+ except Exception as e: # noqa: BLE001 - one malformed row must never abort the whole batch
801
+ skipped.append({"id": stem, "reason": f"could not map row: {e}"})
802
+ continue
771
803
  sidecar = out_dir / f"{stem}.om.json"
772
804
  sidecar.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
773
805
  manifest.append(
@@ -930,6 +962,7 @@ def read(pdf: Annotated[Path, typer.Argument(help="PDF to read")]) -> None:
930
962
  _emit(
931
963
  {
932
964
  "present": result.present,
965
+ "encrypted": result.encrypted, # password-protected PDF (couldn't be opened to read)
933
966
  "payload": result.payload,
934
967
  "payloadHash": result.payload_hash, # content hash - matches hosted om_read
935
968
  "sourceDocHash": result.source_doc_hash, # #5: provenance of the underlying source PDF
@@ -1,51 +1,71 @@
1
- """Cover the Buildout->openOM mapper helper branches (pure, deterministic)."""
2
-
3
- from __future__ import annotations
4
-
5
- from openom_cli.buildout import (
6
- _iso_date,
7
- _lease_type,
8
- _months_between,
9
- _num,
10
- _pct_to_fraction,
11
- _state_code,
12
- )
13
-
14
-
15
- def test_num_and_pct() -> None:
16
- assert _num("1,850,000") == 1850000.0
17
- assert _num("nope") is None
18
- assert _num(None) is None
19
- assert _pct_to_fraction("6.25") == 0.0625
20
- assert _pct_to_fraction(None) is None
21
-
22
-
23
- def test_iso_date_variants() -> None:
24
- assert _iso_date("10/1/2026") == "2026-10-01"
25
- assert _iso_date("") is None
26
- assert _iso_date("2026-10-01") is None # not M/D/Y
27
- assert _iso_date("13/1/2026") is None # bad month
28
- assert _iso_date("a/b/c") is None
29
-
30
-
31
- def test_state_code() -> None:
32
- assert _state_code("GA - Georgia") == "GA"
33
- assert _state_code("GA") == "GA"
34
- assert _state_code("Georgia") is None
35
- assert _state_code(None) is None
36
-
37
-
38
- def test_lease_type() -> None:
39
- assert _lease_type("Absolute NNN") == "NNN"
40
- assert _lease_type("NN lease") == "NN"
41
- assert _lease_type("Gross") == "gross"
42
- assert _lease_type("Custom") == "Custom"
43
- assert _lease_type(None) is None
44
-
45
-
46
- def test_months_between() -> None:
47
- assert _months_between("2021-06-01", "2031-06-01") == 120
48
- assert _months_between("2021-06-15", "2021-07-10") == 0 # partial trailing month
49
- assert _months_between(None, "2031-06-01") is None
50
- assert _months_between("bad", "2031-06-01") is None
51
- assert _months_between("2031-06-01", "2021-06-01") is None # negative
1
+ """Cover the Buildout->openOM mapper helper branches (pure, deterministic)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from openom_cli.buildout import (
6
+ _int,
7
+ _iso_date,
8
+ _lease_type,
9
+ _months_between,
10
+ _num,
11
+ _pct_to_fraction,
12
+ _state_code,
13
+ )
14
+
15
+
16
+ def test_num_drops_non_finite() -> None:
17
+ # A non-finite cell (1e400/inf/nan) must be omitted, not propagated - mirrors the JS connector's
18
+ # Number.isFinite guard and stops _int() raising OverflowError (which aborted the whole batch).
19
+ assert _num("1e400") is None
20
+ assert _num("inf") is None
21
+ assert _num("nan") is None
22
+ assert _int("1e400") is None
23
+
24
+
25
+ def test_derived_rounding_is_half_up_matching_the_js_connector() -> None:
26
+ # Regression: Python round() is banker's (half-to-even), JS Math.round is half-up; a .5 tie
27
+ # forked the embedded pricePerUnit/pricePerSF. _round_half_up matches Math.round(x*m)/m.
28
+ from openom_cli.buildout import _round_half_up
29
+
30
+ assert int(_round_half_up(2500001 / 2)) == 1250001 # banker's round() would give 1250000
31
+ assert int(_round_half_up(2.5)) == 3 and int(_round_half_up(3.5)) == 4 # both up, not to-even
32
+ assert _pct_to_fraction("0.00005") == 1e-6 # round(5e-7,6)=0.0 under banker's; half-up = 1e-6
33
+
34
+
35
+ def test_num_and_pct() -> None:
36
+ assert _num("1,850,000") == 1850000.0
37
+ assert _num("nope") is None
38
+ assert _num(None) is None
39
+ assert _pct_to_fraction("6.25") == 0.0625
40
+ assert _pct_to_fraction(None) is None
41
+
42
+
43
+ def test_iso_date_variants() -> None:
44
+ assert _iso_date("10/1/2026") == "2026-10-01"
45
+ assert _iso_date("") is None
46
+ assert _iso_date("2026-10-01") is None # not M/D/Y
47
+ assert _iso_date("13/1/2026") is None # bad month
48
+ assert _iso_date("a/b/c") is None
49
+
50
+
51
+ def test_state_code() -> None:
52
+ assert _state_code("GA - Georgia") == "GA"
53
+ assert _state_code("GA") == "GA"
54
+ assert _state_code("Georgia") is None
55
+ assert _state_code(None) is None
56
+
57
+
58
+ def test_lease_type() -> None:
59
+ assert _lease_type("Absolute NNN") == "NNN"
60
+ assert _lease_type("NN lease") == "NN"
61
+ assert _lease_type("Gross") == "gross"
62
+ assert _lease_type("Custom") == "Custom"
63
+ assert _lease_type(None) is None
64
+
65
+
66
+ def test_months_between() -> None:
67
+ assert _months_between("2021-06-01", "2031-06-01") == 120
68
+ assert _months_between("2021-06-15", "2021-07-10") == 0 # partial trailing month
69
+ assert _months_between(None, "2031-06-01") is None
70
+ assert _months_between("bad", "2031-06-01") is None
71
+ assert _months_between("2031-06-01", "2021-06-01") is None # negative
@@ -486,6 +486,47 @@ def test_validate_deeply_nested_json_degrades_cleanly(tmp_path: Path) -> None:
486
486
  assert "OM-IO-STRUCTURE" in r.output
487
487
 
488
488
 
489
+ def test_validate_on_a_pdf_gives_a_useful_hint(tmp_path: Path) -> None:
490
+ # The natural first mistake - validate a PDF - must point at `om read`, not die on a raw
491
+ # UnicodeDecodeError decoding binary bytes.
492
+ pdf = _base_pdf(tmp_path / "deal.pdf")
493
+ r = runner.invoke(app, ["validate", str(pdf)])
494
+ assert r.exit_code == 3
495
+ assert "looks like a PDF" in r.output and "om read" in r.output
496
+
497
+
498
+ def _password_pdf(path: Path) -> Path:
499
+ buf = io.BytesIO()
500
+ with pikepdf.open(SPEC / "assets" / "openom-sample.pdf") as p:
501
+ p.save(buf, encryption=pikepdf.Encryption(user="secret", owner="secret", R=6))
502
+ path.write_bytes(buf.getvalue())
503
+ return path
504
+
505
+
506
+ def test_password_pdf_refuses_cleanly_not_a_traceback(tmp_path: Path) -> None:
507
+ # Round-3 blocker: a real password-protected PDF must not traceback. read reports encrypted;
508
+ # the pymupdf verbs exit 3 with OM-IO-011 and no stack trace.
509
+ pdf = _password_pdf(tmp_path / "pwd.pdf")
510
+ r = runner.invoke(app, ["read", str(pdf)])
511
+ assert r.exit_code == 0, r.output
512
+ assert json.loads(r.output)["encrypted"] is True
513
+ for verb in ("inspect", "extract-text"):
514
+ rr = runner.invoke(app, [verb, str(pdf)])
515
+ assert rr.exit_code == 3
516
+ assert "OM-IO-011" in rr.output and "Traceback" not in rr.output
517
+
518
+
519
+ def test_corrupt_pdf_error_is_clean_no_bytesio_leak(tmp_path: Path) -> None:
520
+ # Round-3 friction: a corrupt/truncated PDF must give a stable OM-IO-010 with no leaked BytesIO
521
+ # repr or memory address, and no traceback.
522
+ bad = tmp_path / "bad.pdf"
523
+ bad.write_bytes(b"%PDF-1.7\ngarbage not a real pdf body")
524
+ r = runner.invoke(app, ["read", str(bad)])
525
+ assert r.exit_code == 3
526
+ assert "OM-IO-010" in r.output
527
+ assert "BytesIO" not in r.output and "0x" not in r.output and "Traceback" not in r.output
528
+
529
+
489
530
  def test_check_payload_json(tmp_path: Path) -> None:
490
531
  # Consistency tier only (no schema): the valid sample is internally consistent -> exit 0.
491
532
  r = runner.invoke(app, ["check", str(SPEC / "samples" / "valid-stnl.json")])
@@ -114,6 +114,34 @@ def _blank_pdf(path: Path) -> None:
114
114
  path.write_bytes(buf.getvalue())
115
115
 
116
116
 
117
+ def test_csv_manifest_one_bad_row_is_skipped_not_a_batch_abort(tmp_path: Path) -> None:
118
+ # A single pathological cell (1e400 -> non-finite) must skip that row with a reason, never abort
119
+ # the whole bulk-seed batch or traceback.
120
+ pdf_dir = tmp_path / "pdfs"
121
+ pdf_dir.mkdir()
122
+ _blank_pdf(pdf_dir / "good.pdf")
123
+ _blank_pdf(pdf_dir / "bad.pdf")
124
+ csv_file = tmp_path / "c.csv"
125
+ csv_file.write_text(
126
+ "id,askingPrice,noi\ngood,1850000,115625\nbad,1e400,1e400\n", encoding="utf-8"
127
+ )
128
+ out = tmp_path / "mapped"
129
+ r = _runner.invoke(
130
+ app,
131
+ ["csv-manifest", "--csv", str(csv_file), "--pdf-dir", str(pdf_dir), "--out-dir", str(out),
132
+ "--broker", "J", "--brokerage", "A", "--license", "L",
133
+ "--asserted-date", "2026-08-15", "--noi-type", "in-place"],
134
+ )
135
+ assert r.exit_code == 0, r.output
136
+ summary = json.loads(r.output[r.output.index("{"):])
137
+ # 'good' maps; 'bad' maps too but with the non-finite cells simply omitted (never a crash).
138
+ assert summary["mapped"] == 2
139
+ good = json.loads((out / "good.om.json").read_text(encoding="utf-8"))
140
+ assert good["deal"]["askingPrice"] == 1850000
141
+ bad = json.loads((out / "bad.om.json").read_text(encoding="utf-8"))
142
+ assert "askingPrice" not in bad.get("deal", {}) # the 1e400 cell was dropped, not embedded
143
+
144
+
117
145
  def test_csv_manifest_template_then_embed_batch_roundtrip(tmp_path: Path) -> None:
118
146
  # 1) --template writes a fillable CSV; a broker fills two rows.
119
147
  tmpl = tmp_path / "template.csv"
File without changes
File without changes