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 +0 -0
- openom_cli/buildout.py +207 -0
- openom_cli/buildout_pull.py +235 -0
- openom_cli/humanize.py +57 -0
- openom_cli/main.py +1064 -0
- openom_cli/profile.py +79 -0
- openom_cli/py.typed +0 -0
- openom_cli/scaffold.py +141 -0
- openom_cli-0.1.0.dist-info/METADATA +96 -0
- openom_cli-0.1.0.dist-info/RECORD +12 -0
- openom_cli-0.1.0.dist-info/WHEEL +4 -0
- openom_cli-0.1.0.dist-info/entry_points.txt +2 -0
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
|