openom-cli 0.1.0__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.
@@ -0,0 +1,62 @@
1
+ # --- OpenOM repo hygiene (see CLAUDE.md Rule 2) ---
2
+
3
+ # Marketing mock - NEVER committed
4
+ OpenOM - Make your deal legible to the buy-side's AI.htm
5
+ OpenOM - Make your deal legible to the buy-side's AI_files/
6
+
7
+ # Planning scratch - git-ignored by rule
8
+ docs/superpowers/
9
+ .planning/
10
+
11
+ # Eval / tooling scratch
12
+ .deepeval/
13
+
14
+ # Real OM corpus - private test fixtures, never committed (confidential)
15
+ OMs/
16
+
17
+ # --- Python (/core, /cli, /mcp) ---
18
+ __pycache__/
19
+ *.py[cod]
20
+ *.egg-info/
21
+ .eggs/
22
+ build/
23
+ dist/
24
+ .venv/
25
+ venv/
26
+ .env
27
+ .mypy_cache/
28
+ .ruff_cache/
29
+ .pytest_cache/
30
+ .coverage
31
+ htmlcov/
32
+
33
+ # --- Node / TS (/js, /extension) ---
34
+ node_modules/
35
+ *.tsbuildinfo
36
+ .npm/
37
+ extension/src/generated/
38
+ extension/test-results/
39
+ extension/playwright-report/
40
+
41
+ # --- OS / editor ---
42
+ .DS_Store
43
+ Thumbs.db
44
+ .idea/
45
+ .vscode/
46
+ coverage/
47
+ extension/*.zip
48
+
49
+ js/test-results/
50
+
51
+ # marketing mock (Rule 2 - never commit)
52
+ *.htm
53
+ *_files/
54
+ .playwright-mcp/
55
+ om-normative-spec-draft-v0.1.md
56
+ om-standard-handoff-v4-updated.md
57
+ recon.md
58
+ openOM-decision-memo.md
59
+
60
+ # audit/backlog scratch is temp - lives in .planning/ (see Rule 2)
61
+ /BACKLOG.md
62
+ /*-audit*.md
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.5
2
+ Name: openom-cli
3
+ Version: 0.1.0
4
+ Summary: openOM CLI - the `om` command over openom-core. Zero inference.
5
+ Project-URL: Homepage, https://openom.app
6
+ Project-URL: Documentation, https://openom.app/docs/
7
+ Project-URL: Repository, https://github.com/Vervelio-Labs/OpenOM
8
+ License-Expression: MIT
9
+ Keywords: cli,commercial-real-estate,cre,data-standard,json-ld,json-schema,offering-memorandum,open-standard,openom,pdf,proptech,real-estate
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: openom-core[render]<0.2,>=0.1
12
+ Requires-Dist: typer>=0.12
13
+ Provides-Extra: dev
14
+ Requires-Dist: mypy>=1.10; extra == 'dev'
15
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
16
+ Requires-Dist: pytest>=8; extra == 'dev'
17
+ Requires-Dist: ruff>=0.5; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # openom-cli
21
+
22
+ The `om` command over [`openom-core`](../core) - no UI, no inference; also the server-side path.
23
+
24
+ ```sh
25
+ # From a clone (not yet on PyPI): install core first, then cli.
26
+ pip install -e core && pip install -e cli # add [dev] to either only to contribute
27
+ # Once published: pip install openom-cli
28
+
29
+ om inspect offering.pdf
30
+ om embed offering.pdf --payload deal.json --out out.pdf --asserted-date 2026-08-16 --validate
31
+ om read out.pdf
32
+ om validate deal.json
33
+ om check out.pdf # consistency only
34
+ om --version
35
+ ```
36
+
37
+ ## Conformance (CI integrity gate)
38
+
39
+ `om conformance` reproduces the pinned spec vectors + samples with your installed openOM - run it in
40
+ CI so an environment/version change can't silently drift from the standard. It reads the repo's
41
+ `spec/` tree (pass `--spec-dir <path>/spec` from outside a checkout):
42
+
43
+ ```sh
44
+ om --quiet conformance # exit 0 = conformant, 1 = a check failed
45
+ om conformance --impl-dir ./my-output # certify a THIRD-PARTY implementation's output
46
+ ```
47
+
48
+ ## Bulk / back-catalog embed
49
+
50
+ Embed openOM data into many OMs in one run - the adoption path (seed supply at the source):
51
+
52
+ ```sh
53
+ # a folder of *.pdf, each paired with a sibling <name>.om.json payload:
54
+ om embed-batch --dir ./catalog --out-dir ./embedded --asserted-date 2026-08-22 \
55
+ --schema ../spec/om-0.1.schema.json
56
+ # --dry-run preview (validate + report, write nothing)
57
+ # --skip-existing resume a large run; --force overwrite; --jobs 4 parallel
58
+
59
+ # or a JSON manifest of {pdf, payload, out?, assertedDate?} items:
60
+ om embed-batch --manifest ./manifest.json --out-dir ./embedded --schema ../spec/om-0.1.schema.json
61
+ ```
62
+
63
+ Deterministic, non-destructive, idempotent (re-embed replaces + records `supersedes`); schema errors
64
+ skip that item (never embedded); emits a JSON summary (per-status counts) and `--report FILE`.
65
+
66
+ ### From Buildout (connector -> manifest)
67
+
68
+ Turn fetched Buildout listings into an `embed-batch` manifest. Save each `buildout_get_listing` JSON
69
+ as `<id>.json` and its OM PDF as `<id>.pdf`, then:
70
+
71
+ ```sh
72
+ om buildout-manifest --listings-dir ./listings --pdf-dir ./oms --out-dir ./staged \
73
+ --broker "Jane Broker" --brokerage "Acme NNN" --license "MI 000" \
74
+ --asserted-date 2026-08-22 --noi-type in-place
75
+ om embed-batch --manifest ./staged/manifest.json --out-dir ./embedded --schema ../spec/om-0.1.schema.json
76
+ ```
77
+
78
+ The map is deterministic (names/units normalized, absent fields omitted, `cap_rate_derived` used);
79
+ the assertion identity is yours (flags), never inferred. Review the staged payloads before embedding.
80
+
81
+ ## Watch-folder (server-side automation)
82
+
83
+ Drop `<name>.pdf` + `<name>.json` pairs into a folder and get embedded OMs out - no UI:
84
+
85
+ ```sh
86
+ om watch ./inbox --out ./outbox --asserted-date 2026-08-18 \
87
+ --schema ../spec/om-0.1.schema.json # a payload with schema errors is skipped, not embedded
88
+
89
+ om watch ./inbox --out ./outbox --asserted-date 2026-08-18 --once # drain the backlog + exit (cron/CI)
90
+ ```
91
+
92
+ Deterministic, zero inference. A pair is re-embedded when its pdf/json changes; `--once` processes
93
+ the current backlog and exits (otherwise it polls every `--interval` seconds until Ctrl-C).
94
+
95
+ Reads stdin / writes stdout with `--format`/`--quiet`; exit codes follow the §I contract.
96
+ Tests: `pytest cli -q`.
@@ -0,0 +1,77 @@
1
+ # openom-cli
2
+
3
+ The `om` command over [`openom-core`](../core) - no UI, no inference; also the server-side path.
4
+
5
+ ```sh
6
+ # From a clone (not yet on PyPI): install core first, then cli.
7
+ pip install -e core && pip install -e cli # add [dev] to either only to contribute
8
+ # Once published: pip install openom-cli
9
+
10
+ om inspect offering.pdf
11
+ om embed offering.pdf --payload deal.json --out out.pdf --asserted-date 2026-08-16 --validate
12
+ om read out.pdf
13
+ om validate deal.json
14
+ om check out.pdf # consistency only
15
+ om --version
16
+ ```
17
+
18
+ ## Conformance (CI integrity gate)
19
+
20
+ `om conformance` reproduces the pinned spec vectors + samples with your installed openOM - run it in
21
+ CI so an environment/version change can't silently drift from the standard. It reads the repo's
22
+ `spec/` tree (pass `--spec-dir <path>/spec` from outside a checkout):
23
+
24
+ ```sh
25
+ om --quiet conformance # exit 0 = conformant, 1 = a check failed
26
+ om conformance --impl-dir ./my-output # certify a THIRD-PARTY implementation's output
27
+ ```
28
+
29
+ ## Bulk / back-catalog embed
30
+
31
+ Embed openOM data into many OMs in one run - the adoption path (seed supply at the source):
32
+
33
+ ```sh
34
+ # a folder of *.pdf, each paired with a sibling <name>.om.json payload:
35
+ om embed-batch --dir ./catalog --out-dir ./embedded --asserted-date 2026-08-22 \
36
+ --schema ../spec/om-0.1.schema.json
37
+ # --dry-run preview (validate + report, write nothing)
38
+ # --skip-existing resume a large run; --force overwrite; --jobs 4 parallel
39
+
40
+ # or a JSON manifest of {pdf, payload, out?, assertedDate?} items:
41
+ om embed-batch --manifest ./manifest.json --out-dir ./embedded --schema ../spec/om-0.1.schema.json
42
+ ```
43
+
44
+ Deterministic, non-destructive, idempotent (re-embed replaces + records `supersedes`); schema errors
45
+ skip that item (never embedded); emits a JSON summary (per-status counts) and `--report FILE`.
46
+
47
+ ### From Buildout (connector -> manifest)
48
+
49
+ Turn fetched Buildout listings into an `embed-batch` manifest. Save each `buildout_get_listing` JSON
50
+ as `<id>.json` and its OM PDF as `<id>.pdf`, then:
51
+
52
+ ```sh
53
+ om buildout-manifest --listings-dir ./listings --pdf-dir ./oms --out-dir ./staged \
54
+ --broker "Jane Broker" --brokerage "Acme NNN" --license "MI 000" \
55
+ --asserted-date 2026-08-22 --noi-type in-place
56
+ om embed-batch --manifest ./staged/manifest.json --out-dir ./embedded --schema ../spec/om-0.1.schema.json
57
+ ```
58
+
59
+ The map is deterministic (names/units normalized, absent fields omitted, `cap_rate_derived` used);
60
+ the assertion identity is yours (flags), never inferred. Review the staged payloads before embedding.
61
+
62
+ ## Watch-folder (server-side automation)
63
+
64
+ Drop `<name>.pdf` + `<name>.json` pairs into a folder and get embedded OMs out - no UI:
65
+
66
+ ```sh
67
+ om watch ./inbox --out ./outbox --asserted-date 2026-08-18 \
68
+ --schema ../spec/om-0.1.schema.json # a payload with schema errors is skipped, not embedded
69
+
70
+ om watch ./inbox --out ./outbox --asserted-date 2026-08-18 --once # drain the backlog + exit (cron/CI)
71
+ ```
72
+
73
+ Deterministic, zero inference. A pair is re-embedded when its pdf/json changes; `--once` processes
74
+ the current backlog and exits (otherwise it polls every `--interval` seconds until Ctrl-C).
75
+
76
+ Reads stdin / writes stdout with `--format`/`--quiet`; exit codes follow the §I contract.
77
+ Tests: `pytest cli -q`.
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "openom-cli"
7
+ version = "0.1.0"
8
+ description = "openOM CLI - the `om` command over openom-core. Zero inference."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ keywords = [
13
+ "openom", "offering-memorandum", "commercial-real-estate", "cre", "real-estate",
14
+ "proptech", "pdf", "cli", "json-ld", "json-schema", "open-standard", "data-standard",
15
+ ]
16
+ # openom-core pinned to the same 0.x spec line (a 0.2 core may change the payload contract).
17
+ dependencies = ["openom-core[render]>=0.1,<0.2", "typer>=0.12"]
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest>=8", "pytest-cov>=5", "mypy>=1.10", "ruff>=0.5"]
21
+
22
+ [project.urls]
23
+ Homepage = "https://openom.app"
24
+ Documentation = "https://openom.app/docs/"
25
+ Repository = "https://github.com/Vervelio-Labs/OpenOM"
26
+
27
+ [project.scripts]
28
+ om = "openom_cli.main:app"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/openom_cli"]
32
+
33
+ [tool.ruff]
34
+ line-length = 100
35
+ src = ["src", "tests"]
36
+
37
+ [tool.ruff.lint]
38
+ select = ["E", "F", "I", "B", "UP", "W"]
39
+
40
+ [tool.mypy]
41
+ python_version = "3.11"
42
+ strict = true
43
+ files = ["src"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+
48
+ [tool.coverage.run]
49
+ source = ["openom_cli"]
50
+ branch = true
51
+
52
+ [tool.coverage.report]
53
+ show_missing = true
54
+ exclude_lines = ["pragma: no cover", "if __name__"]
File without changes
@@ -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
+ })