openom-cli 0.1.0__tar.gz → 0.1.2__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.0 → openom_cli-0.1.2}/PKG-INFO +1 -1
- {openom_cli-0.1.0 → openom_cli-0.1.2}/pyproject.toml +1 -1
- openom_cli-0.1.2/src/openom_cli/csv_map.py +171 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/src/openom_cli/main.py +1266 -1064
- {openom_cli-0.1.0 → openom_cli-0.1.2}/tests/test_cli.py +752 -662
- {openom_cli-0.1.0 → openom_cli-0.1.2}/tests/test_cli_onboarding.py +3 -1
- openom_cli-0.1.2/tests/test_csv_map.py +160 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/.gitignore +0 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/README.md +0 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/src/openom_cli/__init__.py +0 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/src/openom_cli/buildout.py +0 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/src/openom_cli/buildout_pull.py +0 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/src/openom_cli/humanize.py +0 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/src/openom_cli/profile.py +0 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/src/openom_cli/py.typed +0 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/src/openom_cli/scaffold.py +0 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/tests/fixtures/buildout-listing-sample.json +0 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/tests/test_buildout_map.py +0 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/tests/test_buildout_pull.py +0 -0
- {openom_cli-0.1.0 → openom_cli-0.1.2}/tests/test_cli_dx.py +0 -0
|
@@ -0,0 +1,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
|
+
_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()
|