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.
- {openom_cli-0.1.2 → openom_cli-0.1.5}/PKG-INFO +1 -1
- {openom_cli-0.1.2 → openom_cli-0.1.5}/pyproject.toml +1 -1
- {openom_cli-0.1.2 → openom_cli-0.1.5}/src/openom_cli/buildout.py +220 -207
- {openom_cli-0.1.2 → openom_cli-0.1.5}/src/openom_cli/csv_map.py +172 -171
- {openom_cli-0.1.2 → openom_cli-0.1.5}/src/openom_cli/main.py +45 -12
- {openom_cli-0.1.2 → openom_cli-0.1.5}/tests/test_buildout_map.py +71 -51
- {openom_cli-0.1.2 → openom_cli-0.1.5}/tests/test_cli.py +41 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/tests/test_csv_map.py +28 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/.gitignore +0 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/README.md +0 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/src/openom_cli/__init__.py +0 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/src/openom_cli/buildout_pull.py +0 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/src/openom_cli/humanize.py +0 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/src/openom_cli/profile.py +0 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/src/openom_cli/py.typed +0 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/src/openom_cli/scaffold.py +0 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/tests/fixtures/buildout-listing-sample.json +0 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/tests/test_buildout_pull.py +0 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/tests/test_cli_dx.py +0 -0
- {openom_cli-0.1.2 → openom_cli-0.1.5}/tests/test_cli_onboarding.py +0 -0
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
n
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
n
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if
|
|
58
|
-
return None
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if
|
|
71
|
-
return
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
})
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
"
|
|
174
|
-
"
|
|
175
|
-
|
|
176
|
-
"
|
|
177
|
-
"
|
|
178
|
-
"
|
|
179
|
-
"
|
|
180
|
-
"
|
|
181
|
-
})
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
"
|
|
188
|
-
|
|
189
|
-
"
|
|
190
|
-
"
|
|
191
|
-
|
|
192
|
-
"
|
|
193
|
-
"
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
"
|
|
201
|
-
"
|
|
202
|
-
"
|
|
203
|
-
"
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
"
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
"
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
"
|
|
118
|
-
"
|
|
119
|
-
"
|
|
120
|
-
"
|
|
121
|
-
"
|
|
122
|
-
"
|
|
123
|
-
"
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
"
|
|
130
|
-
"
|
|
131
|
-
"
|
|
132
|
-
"
|
|
133
|
-
"
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
"
|
|
146
|
-
"
|
|
147
|
-
"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
"@
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
"
|
|
156
|
-
"
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
writer.
|
|
170
|
-
writer.writerow(
|
|
171
|
-
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
assert
|
|
20
|
-
assert
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
assert
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
assert
|
|
40
|
-
assert
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
assert
|
|
48
|
-
assert
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|