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.
- openom_cli-0.1.0/.gitignore +62 -0
- openom_cli-0.1.0/PKG-INFO +96 -0
- openom_cli-0.1.0/README.md +77 -0
- openom_cli-0.1.0/pyproject.toml +54 -0
- openom_cli-0.1.0/src/openom_cli/__init__.py +0 -0
- openom_cli-0.1.0/src/openom_cli/buildout.py +207 -0
- openom_cli-0.1.0/src/openom_cli/buildout_pull.py +235 -0
- openom_cli-0.1.0/src/openom_cli/humanize.py +57 -0
- openom_cli-0.1.0/src/openom_cli/main.py +1064 -0
- openom_cli-0.1.0/src/openom_cli/profile.py +79 -0
- openom_cli-0.1.0/src/openom_cli/py.typed +0 -0
- openom_cli-0.1.0/src/openom_cli/scaffold.py +141 -0
- openom_cli-0.1.0/tests/fixtures/buildout-listing-sample.json +36 -0
- openom_cli-0.1.0/tests/test_buildout_map.py +51 -0
- openom_cli-0.1.0/tests/test_buildout_pull.py +93 -0
- openom_cli-0.1.0/tests/test_cli.py +662 -0
- openom_cli-0.1.0/tests/test_cli_dx.py +85 -0
- openom_cli-0.1.0/tests/test_cli_onboarding.py +241 -0
|
@@ -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
|
+
})
|