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/profile.py ADDED
@@ -0,0 +1,79 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """A per-user broker profile so identity (name/brokerage/license) is set once, never retyped.
3
+
4
+ Brokers asked for a settable profile on the CLI; this is it. Stored device-locally at
5
+ ``<app-dir>/profile.json`` (``%APPDATA%\\openom`` on Windows, ``~/.config/openom`` on Linux).
6
+ ``om init`` and ``om embed`` fill a payload's ``assertedBy`` from it - payload values always win,
7
+ the profile only fills blanks, and a missing/corrupt file never breaks a command (it reads as {}).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import typer
17
+
18
+ APP_NAME = "openom"
19
+ _FIELDS = ("broker", "brokerage", "license")
20
+
21
+
22
+ def config_dir() -> Path:
23
+ return Path(typer.get_app_dir(APP_NAME))
24
+
25
+
26
+ def profile_path() -> Path:
27
+ return config_dir() / "profile.json"
28
+
29
+
30
+ def load_profile() -> dict[str, Any]:
31
+ """The saved profile, or {} if absent/unreadable. Never raises (embed must not break on it)."""
32
+ try:
33
+ data = json.loads(profile_path().read_text(encoding="utf-8"))
34
+ except (OSError, ValueError):
35
+ return {}
36
+ return data if isinstance(data, dict) else {}
37
+
38
+
39
+ def save_profile(
40
+ *, broker: str | None, brokerage: str | None, license: str | None
41
+ ) -> dict[str, Any]:
42
+ """Overlay the given (non-None) fields onto the stored profile's ``assertedBy`` and persist."""
43
+ prof = load_profile()
44
+ asserted = dict(prof.get("assertedBy") or {})
45
+ for key, value in {"broker": broker, "brokerage": brokerage, "license": license}.items():
46
+ if value is not None:
47
+ asserted[key] = value
48
+ prof["assertedBy"] = asserted
49
+ path = profile_path()
50
+ path.parent.mkdir(parents=True, exist_ok=True)
51
+ path.write_text(json.dumps(prof, indent=2, ensure_ascii=False), encoding="utf-8")
52
+ return prof
53
+
54
+
55
+ def profile_asserted_by() -> dict[str, str]:
56
+ """The saved ``assertedBy`` mapping (broker/brokerage/license), or {}."""
57
+ asserted = load_profile().get("assertedBy")
58
+ return asserted if isinstance(asserted, dict) else {}
59
+
60
+
61
+ def merge_into(payload: dict[str, Any]) -> bool:
62
+ """Fill missing/blank ``assertedBy`` broker/brokerage/license from the saved profile.
63
+
64
+ Payload values always win; the profile only fills gaps. Returns True if anything was filled,
65
+ so the caller can tell the user it happened.
66
+ """
67
+ saved = profile_asserted_by()
68
+ if not saved:
69
+ return False
70
+ asserted = payload.get("assertedBy")
71
+ asserted = dict(asserted) if isinstance(asserted, dict) else {}
72
+ filled = False
73
+ for key in _FIELDS:
74
+ if saved.get(key) and not asserted.get(key):
75
+ asserted[key] = saved[key]
76
+ filled = True
77
+ if filled:
78
+ payload["assertedBy"] = asserted
79
+ return filled
openom_cli/py.typed ADDED
File without changes
openom_cli/scaffold.py ADDED
@@ -0,0 +1,141 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Starter payloads for ``om init`` so a CLI user never hits the "no deal.json" dead-end.
3
+
4
+ Each template mirrors a real, schema-valid committed sample (spec/samples/valid-*.json) so
5
+ ``om init`` -> ``om validate`` is clean out of the box; the user then swaps the EXAMPLE values for
6
+ their deal's. Skeletons are inlined (not read from /spec) because the installed wheel may not ship
7
+ the spec directory. The values are examples, called out as such in the guidance ``om init`` prints.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ from typing import Any
14
+
15
+ TEMPLATES = ("stnl", "multifamily", "proforma")
16
+
17
+ # stnl = single-tenant net lease (the most common net-lease OM); the default.
18
+ _STNL: dict[str, Any] = {
19
+ "@context": ["https://schema.org", "https://openom.app/ns/0.1"],
20
+ "@type": "RealEstateListing",
21
+ "specVersion": "0.1",
22
+ "assertedBy": {
23
+ "broker": "Your Name", "brokerage": "Your Brokerage", "license": "Your license id",
24
+ },
25
+ "assertedDate": "REPLACED_WITH_TODAY",
26
+ "property": {
27
+ "address": {
28
+ "streetAddress": "1000 Example Rd", "addressLocality": "Sampleville",
29
+ "addressRegion": "MI", "postalCode": "48000", "addressCountry": "US",
30
+ },
31
+ "apn": "00-000-000-000", "buildingSF": 9100, "yearBuilt": 2019,
32
+ },
33
+ "deal": {
34
+ "askingPrice": 1850000, "capRate": 0.0625, "noi": 115625,
35
+ "noiType": "in-place", "noiAsOfDate": "REPLACED_WITH_TODAY", "status": "active",
36
+ },
37
+ "lease": {
38
+ "tenantEntity": "Example Retail Stores, LLC",
39
+ "guarantor": {"name": "Example Retail Corp.", "type": "corporate"},
40
+ "leaseTypeAsserted": "NNN",
41
+ "commencement": "2019-05-01", "expiration": "2034-04-30",
42
+ "rentSchedule": [
43
+ {"periodStart": "2024-05-01", "periodEnd": "2029-04-30", "annualRent": 115625,
44
+ "rentPSF": 12.70, "source": "asserted"},
45
+ ],
46
+ },
47
+ "meta": {"supersedes": None},
48
+ }
49
+
50
+ _MULTIFAMILY: dict[str, Any] = {
51
+ "@context": ["https://schema.org", "https://openom.app/ns/0.1"],
52
+ "@type": "RealEstateListing",
53
+ "specVersion": "0.1",
54
+ "assertedBy": {
55
+ "broker": "Your Name", "brokerage": "Your Brokerage", "license": "Your license id",
56
+ },
57
+ "assertedDate": "REPLACED_WITH_TODAY",
58
+ "property": {
59
+ "propertyType": "multifamily",
60
+ "address": {
61
+ "streetAddress": "200 Example Ave", "addressLocality": "Sampletown",
62
+ "addressRegion": "TX", "postalCode": "75000", "addressCountry": "US",
63
+ },
64
+ "yearBuilt": 1998, "units": 40, "occupancy": 0.95,
65
+ },
66
+ "deal": {
67
+ "askingPrice": 8000000, "capRate": 0.06, "noi": 480000, "noiType": "in-place",
68
+ "noiAsOfDate": "REPLACED_WITH_TODAY", "pricePerUnit": 200000, "status": "active",
69
+ },
70
+ "meta": {"supersedes": None},
71
+ }
72
+
73
+ _PROFORMA: dict[str, Any] = {
74
+ "@context": ["https://schema.org", "https://openom.app/ns/0.1"],
75
+ "@type": "RealEstateListing",
76
+ "specVersion": "0.1",
77
+ "assertedBy": {
78
+ "broker": "Your Name", "brokerage": "Your Brokerage", "license": "Your license id",
79
+ },
80
+ "assertedDate": "REPLACED_WITH_TODAY",
81
+ "currency": "USD",
82
+ "deal": {
83
+ "askingPrice": 5000000, "capRate": 0.07, "noi": 350000, "noiType": "pro-forma",
84
+ "noiAsOfDate": "REPLACED_WITH_TODAY", "status": "active",
85
+ },
86
+ "meta": {"supersedes": None},
87
+ }
88
+
89
+ _SKELETONS: dict[str, dict[str, Any]] = {
90
+ "stnl": _STNL, "multifamily": _MULTIFAMILY, "proforma": _PROFORMA,
91
+ }
92
+
93
+
94
+ def build_skeleton(
95
+ template: str, *, today: str, profile_asserted_by: dict[str, str] | None = None
96
+ ) -> dict[str, Any]:
97
+ """A fresh starter payload: today's date stamped in, ``assertedBy`` filled from the profile."""
98
+ if template not in _SKELETONS:
99
+ raise KeyError(template)
100
+ doc = copy.deepcopy(_SKELETONS[template])
101
+ doc["assertedDate"] = today
102
+ if isinstance(doc.get("deal"), dict) and "noiAsOfDate" in doc["deal"]:
103
+ doc["deal"]["noiAsOfDate"] = today
104
+ if profile_asserted_by:
105
+ for key in ("broker", "brokerage", "license"):
106
+ if profile_asserted_by.get(key):
107
+ doc["assertedBy"][key] = profile_asserted_by[key]
108
+ return doc
109
+
110
+
111
+ def guidance_lines(template: str, out: str, *, has_profile: bool) -> list[str]:
112
+ """The plain-English "now edit these" coaching printed to stderr after writing the file."""
113
+ who = (
114
+ " - assertedBy.broker / brokerage / license <- filled from your saved profile"
115
+ if has_profile
116
+ else " - assertedBy.broker / brokerage / license <- who is asserting this "
117
+ "(or run `om profile set` once to auto-fill)"
118
+ )
119
+ return [
120
+ f"Wrote a starter payload -> {out} (template: {template})",
121
+ "",
122
+ "These are EXAMPLE values - replace them with your deal's, then embed. Key fields:",
123
+ " - property.address, buildingSF, yearBuilt",
124
+ " - deal.askingPrice (dollars, e.g. 1850000), deal.noi (dollars)",
125
+ " - deal.capRate <- a DECIMAL fraction: 6.25% = 0.0625 (NOT 6.25)",
126
+ " - deal.noiType <- 'in-place' or 'pro-forma' (required whenever you set an NOI)",
127
+ who,
128
+ "",
129
+ "Next:",
130
+ f" om validate {out} # plain-English check before you embed",
131
+ f" om embed listing.pdf --payload {out} --out listing.openom.pdf "
132
+ f"--asserted-date {_stamp_hint()}",
133
+ "",
134
+ "Not a developer? You don't need any of this - embed in your browser (nothing leaves your "
135
+ "machine): https://openom.app/embed/",
136
+ ]
137
+
138
+
139
+ def _stamp_hint() -> str:
140
+ # A literal placeholder in the printed example command (not a real date - keeps output stable).
141
+ return "<today>"
@@ -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,12 @@
1
+ openom_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ openom_cli/buildout.py,sha256=Lm7BToLf1v0SMV6ZDQA9UgTD8kIrK43WGPNhW-5ZCAs,7940
3
+ openom_cli/buildout_pull.py,sha256=B4OyCA0XzVOqwXM0dqudTix_VFp9FVMuYoNpEqfHUmY,9225
4
+ openom_cli/humanize.py,sha256=Av3TYHEtCbwAiK3uMPXbUnp4GVrLjRM-LlwmliNZGEU,2192
5
+ openom_cli/main.py,sha256=HnGLCcKqRrUofJE85LNRppu-g2DDLNNYWTP-ncHYRUE,44955
6
+ openom_cli/profile.py,sha256=2yOGMazpjrQKzSkrA4cdaE0rBltw9mkRzh7rjG82YnA,2751
7
+ openom_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ openom_cli/scaffold.py,sha256=9Q6NE5oClFlseTrsMYhuoPnAST68Q8a-xL1CAezOthQ,5720
9
+ openom_cli-0.1.0.dist-info/METADATA,sha256=606bb22CXgrG2Gt6IRZDa-QzyL-Fm8YE0aJKA5NJ7mw,4089
10
+ openom_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
+ openom_cli-0.1.0.dist-info/entry_points.txt,sha256=yZGVpdzQKBW2Hk7MfwGKlKucdseoQe1nqG8l95yaXHQ,43
12
+ openom_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ om = openom_cli.main:app