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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: openom-cli
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: openOM CLI - the `om` command over openom-core. Zero inference.
5
5
  Project-URL: Homepage, https://openom.app
6
6
  Project-URL: Documentation, https://openom.app/docs/
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openom-cli"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "openOM CLI - the `om` command over openom-core. Zero inference."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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()