openom-cli 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.
openom_cli/__init__.py ADDED
File without changes
openom_cli/buildout.py ADDED
@@ -0,0 +1,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
+ 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
+ })
@@ -0,0 +1,235 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Pull a Buildout back-catalog into local files for ``om buildout-manifest`` (#B3).
3
+
4
+ Closes the acquisition gap: instead of hand-extracting one ``get_listing`` JSON per listing and
5
+ downloading every OM PDF by hand, this fetches them in one authenticated pass. Deterministic and
6
+ zero-inference (a data fetch). The pure orchestrator ``pull`` is transport-injected so it is fully
7
+ unit-testable with a fake; the real MCP Streamable-HTTP transport (``mcp_http_call_tool``) mirrors
8
+ the extension's ``buildout-http.ts`` (initialize -> initialized -> tools/call, JSON or SSE).
9
+
10
+ The real live endpoint run is environment-gated (a Buildout MCP endpoint + token), like the
11
+ extension's real-Prompt-API check - stated, not faked. The transport wire-format and SSE parser ARE
12
+ unit-tested here.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import re
19
+ import urllib.request
20
+ from collections.abc import Callable
21
+ from concurrent.futures import ThreadPoolExecutor
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ # (tool_name, arguments) -> the tool's result object (the listing dict).
26
+ CallTool = Callable[[str, dict[str, Any]], dict[str, Any]]
27
+ # url -> raw PDF bytes.
28
+ FetchBytes = Callable[[str], bytes]
29
+
30
+ _PDF_URL = re.compile(r"https?://\S+?\.pdf(?:\?\S*)?", re.IGNORECASE)
31
+
32
+
33
+ def om_url_of(listing: dict[str, Any]) -> str | None:
34
+ """Best-effort find the OM PDF URL inside a listing object, else None.
35
+
36
+ Checks the obvious explicit places, then falls back to the first ``…\\.pdf`` URL anywhere in the
37
+ record. Never guesses a non-PDF link. A None result means "no OM PDF found" - the caller records
38
+ it and moves on (the listing JSON is still written)."""
39
+ for key in ("om_url", "offering_memorandum_url", "document_url"):
40
+ v = listing.get(key)
41
+ if isinstance(v, str) and v.lower().split("?")[0].endswith(".pdf"):
42
+ return v
43
+ docs = listing.get("documents")
44
+ if isinstance(docs, list):
45
+ for d in docs:
46
+ url = d.get("url") if isinstance(d, dict) else None
47
+ if isinstance(url, str) and url.lower().split("?")[0].endswith(".pdf"):
48
+ return url
49
+ m = _PDF_URL.search(json.dumps(listing))
50
+ return m.group(0) if m else None
51
+
52
+
53
+ def ids_from_search_result(result: Any) -> list[str]:
54
+ """Extract listing ids from a search tool result (best-effort over common shapes).
55
+
56
+ Handles a bare list of ids, a list of listing objects (``id``/``listing_id``), or a wrapper
57
+ ``{listings|results|data: [...]}``. Ids are stringified + de-duplicated, order preserved."""
58
+ rows: Any = result
59
+ if isinstance(result, dict):
60
+ for key in ("listings", "results", "data", "items"):
61
+ if isinstance(result.get(key), list):
62
+ rows = result[key]
63
+ break
64
+ out: list[str] = []
65
+ seen: set[str] = set()
66
+ for row in rows if isinstance(rows, list) else []:
67
+ rid: Any = None
68
+ if isinstance(row, str | int):
69
+ rid = row
70
+ elif isinstance(row, dict):
71
+ rid = row.get("id") or row.get("listing_id") or row.get("ref")
72
+ if rid is None:
73
+ continue
74
+ s = str(rid)
75
+ if s not in seen:
76
+ seen.add(s)
77
+ out.append(s)
78
+ return out
79
+
80
+
81
+ def _pull_one(
82
+ lid: str,
83
+ *,
84
+ get_listing: CallTool,
85
+ fetch_pdf: FetchBytes,
86
+ out_listings_dir: Path,
87
+ out_pdf_dir: Path,
88
+ listing_tool: str,
89
+ om_url: Callable[[dict[str, Any]], str | None],
90
+ skip_existing: bool,
91
+ ) -> dict[str, str]:
92
+ pdf_path = out_pdf_dir / f"{lid}.pdf"
93
+ if skip_existing and pdf_path.exists() and (out_listings_dir / f"{lid}.json").exists():
94
+ return {"id": lid, "status": "exists"}
95
+ try:
96
+ listing = get_listing(listing_tool, {"ref": lid})
97
+ except Exception as e: # noqa: BLE001 - report per-listing, keep going
98
+ return {"id": lid, "status": "listing-error", "detail": str(e)}
99
+ (out_listings_dir / f"{lid}.json").write_text(
100
+ json.dumps(listing, indent=2, ensure_ascii=False), encoding="utf-8"
101
+ )
102
+ url = om_url(listing)
103
+ if not url:
104
+ return {"id": lid, "status": "no-om"}
105
+ try:
106
+ pdf_path.write_bytes(fetch_pdf(url))
107
+ return {"id": lid, "status": "ok"}
108
+ except Exception as e: # noqa: BLE001
109
+ return {"id": lid, "status": "pdf-error", "detail": str(e)}
110
+
111
+
112
+ def pull(
113
+ ids: list[str],
114
+ *,
115
+ get_listing: CallTool,
116
+ fetch_pdf: FetchBytes,
117
+ out_listings_dir: Path,
118
+ out_pdf_dir: Path,
119
+ listing_tool: str = "get_listing",
120
+ om_url: Callable[[dict[str, Any]], str | None] = om_url_of,
121
+ skip_existing: bool = False,
122
+ jobs: int = 1,
123
+ ) -> dict[str, Any]:
124
+ """Fetch each listing id -> write ``<id>.json`` and download its OM PDF -> ``<id>.pdf``.
125
+
126
+ Returns ``{pulled, of, counts, results}`` (results in input order). Pure except the injected
127
+ effects (get_listing / fetch_pdf / filesystem), so it is deterministic + testable. A per-listing
128
+ error is captured (never aborts the run); ``skip_existing`` avoids re-pulling already-downloaded
129
+ OMs (resume); ``jobs>1`` downloads concurrently (I/O-bound) while keeping input order."""
130
+ out_listings_dir.mkdir(parents=True, exist_ok=True)
131
+ out_pdf_dir.mkdir(parents=True, exist_ok=True)
132
+
133
+ def do(lid: str) -> dict[str, str]:
134
+ return _pull_one(
135
+ lid, get_listing=get_listing, fetch_pdf=fetch_pdf,
136
+ out_listings_dir=out_listings_dir, out_pdf_dir=out_pdf_dir,
137
+ listing_tool=listing_tool, om_url=om_url, skip_existing=skip_existing,
138
+ )
139
+
140
+ if jobs > 1 and len(ids) > 1:
141
+ with ThreadPoolExecutor(max_workers=jobs) as ex:
142
+ results = list(ex.map(do, ids)) # ex.map preserves input order
143
+ else:
144
+ results = [do(lid) for lid in ids]
145
+
146
+ counts: dict[str, int] = {}
147
+ for r in results:
148
+ counts[r["status"]] = counts.get(r["status"], 0) + 1
149
+ pulled = counts.get("ok", 0)
150
+ return {"pulled": pulled, "of": len(ids), "counts": counts, "results": results}
151
+
152
+
153
+ # --- real MCP Streamable-HTTP transport (network; parser unit-tested, live call env-gated) ---
154
+ def parse_rpc(content_type: str, body: str) -> dict[str, Any]:
155
+ """Parse an MCP HTTP response body that is application/json OR an SSE (text/event-stream)."""
156
+ if "text/event-stream" in content_type:
157
+ data = [
158
+ ln[5:].strip()
159
+ for ln in body.splitlines()
160
+ if ln.startswith("data:") and ln[5:].strip()
161
+ ]
162
+ if not data:
163
+ raise ValueError("empty SSE response")
164
+ parsed: dict[str, Any] = json.loads(data[-1])
165
+ return parsed
166
+ body_parsed: dict[str, Any] = json.loads(body)
167
+ return body_parsed
168
+
169
+
170
+ def listing_from_result(rpc: dict[str, Any]) -> dict[str, Any]:
171
+ """Pull the listing out of a tools/call result (structuredContent, or a JSON text block)."""
172
+ if "error" in rpc and rpc["error"]:
173
+ err = rpc["error"]
174
+ raise RuntimeError(f"Buildout MCP error {err.get('code')}: {err.get('message')}")
175
+ result = rpc.get("result") or {}
176
+ sc = result.get("structuredContent")
177
+ if isinstance(sc, dict):
178
+ return sc
179
+ for block in result.get("content") or []:
180
+ if isinstance(block, dict) and block.get("type") == "text" and block.get("text"):
181
+ text_parsed: dict[str, Any] = json.loads(block["text"])
182
+ return text_parsed
183
+ raise RuntimeError("Buildout MCP returned no listing content")
184
+
185
+
186
+ def mcp_http_call_tool(
187
+ endpoint: str,
188
+ token: str | None,
189
+ tool: str,
190
+ arguments: dict[str, Any],
191
+ *,
192
+ opener: Callable[[urllib.request.Request], Any] = urllib.request.urlopen,
193
+ ) -> dict[str, Any]:
194
+ """One tools/call over MCP Streamable HTTP: initialize -> initialized -> tools/call. Network."""
195
+
196
+ def post(session_id: str | None, payload: dict[str, Any]) -> Any:
197
+ headers = {
198
+ "Content-Type": "application/json",
199
+ "Accept": "application/json, text/event-stream",
200
+ }
201
+ if token:
202
+ headers["Authorization"] = f"Bearer {token}"
203
+ if session_id:
204
+ headers["Mcp-Session-Id"] = session_id
205
+ req = urllib.request.Request(
206
+ endpoint, data=json.dumps(payload).encode(), headers=headers, method="POST"
207
+ )
208
+ return opener(req)
209
+
210
+ init = post(
211
+ None,
212
+ {
213
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
214
+ "params": {
215
+ "protocolVersion": "2024-11-05", "capabilities": {},
216
+ "clientInfo": {"name": "openom-cli", "version": "0.1"},
217
+ },
218
+ },
219
+ )
220
+ session_id = init.headers.get("mcp-session-id")
221
+ post(session_id, {"jsonrpc": "2.0", "method": "notifications/initialized"})
222
+ resp = post(
223
+ session_id,
224
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/call",
225
+ "params": {"name": tool, "arguments": arguments}},
226
+ )
227
+ body = resp.read().decode()
228
+ return listing_from_result(parse_rpc(resp.headers.get("content-type") or "", body))
229
+
230
+
231
+ def http_fetch_pdf(url: str, *, opener: Callable[[str], Any] = urllib.request.urlopen) -> bytes:
232
+ """Download PDF bytes from an https URL (network)."""
233
+ with opener(url) as r:
234
+ data: bytes = r.read()
235
+ return data
openom_cli/humanize.py ADDED
@@ -0,0 +1,57 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Plain-English rendering of validation findings for the terminal, so "all validation errors, haha"
3
+ never happens again.
4
+
5
+ Lives in the CLI, NOT the deterministic core (the core stays a pure library emitting stable codes).
6
+ The machine-readable JSON still goes to stdout unchanged; these strings are the friendly stderr
7
+ coaching. Every line leads with what to do and keeps the raw code in a trailing parenthetical.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ _FOOTER = (
15
+ "Fix these in your payload and re-run. Starting from scratch? `om init` writes a valid "
16
+ "template. Not a developer? https://openom.app/embed/ builds the payload for you."
17
+ )
18
+
19
+
20
+ def _word(w: str) -> str:
21
+ # keep acronyms (PSF, SF, NOI, APN) as-is; lowercase ordinary words (Rate -> rate)
22
+ return w if w.isupper() else w.lower()
23
+
24
+
25
+ def humanize_path(path: str) -> str:
26
+ """'/deal/capRate' -> 'deal > cap rate' (acronyms like PSF/NOI/SF kept uppercase)."""
27
+ parts = [p for p in path.strip("/").split("/") if p]
28
+ out: list[str] = []
29
+ for p in parts:
30
+ if p.isdigit():
31
+ out.append(f"#{int(p) + 1}")
32
+ else:
33
+ words = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", p).split(" ")
34
+ out.append(" ".join(_word(w) for w in words))
35
+ return " > ".join(out) or "(payload root)"
36
+
37
+
38
+ def humanize_finding(code: str, path: str, message: str) -> str:
39
+ """One plain-English line for a finding, keyed on the codes a broker actually hits."""
40
+ label = humanize_path(path)
41
+ if code == "OMV-E001" and path == "/deal/capRate":
42
+ return (
43
+ "Cap rate must be a decimal fraction between 0 and 1 - enter 6.25% as 0.0625 "
44
+ "(not 6.25). Fix deal.capRate. (OMV-E001)"
45
+ )
46
+ if code == "OMV-E002":
47
+ return (
48
+ "You set an NOI, so you must also say whether it's 'in-place' or 'pro-forma' "
49
+ "(deal.noiType) and its as-of date (deal.noiAsOfDate). Add both. (OMV-E002)"
50
+ )
51
+ if "currency" in path.lower():
52
+ return f"{label}: currency must be a 3-letter ISO 4217 code like USD. ({code})"
53
+ return f"{label}: {message} ({code})"
54
+
55
+
56
+ def footer() -> str:
57
+ return _FOOTER