masterly 0.1.1__tar.gz → 0.2.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.
- masterly-0.2.0/.gitignore +11 -0
- masterly-0.2.0/PKG-INFO +175 -0
- masterly-0.2.0/README.md +148 -0
- masterly-0.2.0/examples/README.md +145 -0
- masterly-0.2.0/examples/demo_data.py +1378 -0
- {masterly-0.1.1 → masterly-0.2.0}/pyproject.toml +5 -2
- masterly-0.2.0/src/masterly/__init__.py +29 -0
- masterly-0.2.0/src/masterly/_client.py +180 -0
- masterly-0.2.0/src/masterly/_config.py +209 -0
- {masterly-0.1.1 → masterly-0.2.0}/src/masterly/_extract.py +48 -12
- masterly-0.2.0/src/masterly/_ingest.py +155 -0
- masterly-0.2.0/src/masterly/_paging.py +55 -0
- masterly-0.2.0/src/masterly/_precondition.py +220 -0
- masterly-0.2.0/tests/test_client.py +361 -0
- masterly-0.2.0/tests/test_config.py +248 -0
- masterly-0.2.0/tests/test_demo_data.py +260 -0
- {masterly-0.1.1 → masterly-0.2.0}/uv.lock +1 -1
- masterly-0.1.1/.gitignore +0 -7
- masterly-0.1.1/PKG-INFO +0 -78
- masterly-0.1.1/README.md +0 -51
- masterly-0.1.1/src/masterly/__init__.py +0 -14
- masterly-0.1.1/src/masterly/_client.py +0 -89
- masterly-0.1.1/src/masterly/_ingest.py +0 -68
- masterly-0.1.1/tests/test_client.py +0 -117
- {masterly-0.1.1 → masterly-0.2.0}/.github/workflows/release.yml +0 -0
- {masterly-0.1.1 → masterly-0.2.0}/LICENSE +0 -0
- {masterly-0.1.1 → masterly-0.2.0}/src/masterly/py.typed +0 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.egg-info/
|
|
3
|
+
.venv/
|
|
4
|
+
dist/
|
|
5
|
+
.pytest_cache/
|
|
6
|
+
.mypy_cache/
|
|
7
|
+
.ruff_cache/
|
|
8
|
+
|
|
9
|
+
# Claude Code worktrees. Each one is its own git repository, so committing the path adds
|
|
10
|
+
# a gitlink rather than files — and `git add -A` picks it up silently.
|
|
11
|
+
.claude/worktrees/
|
masterly-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: masterly
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for Masterly — extract governed data products and ingest records from your lakehouse
|
|
5
|
+
Project-URL: Homepage, https://masterlydata.com
|
|
6
|
+
Project-URL: Documentation, https://masterlydata.com/docs/reference/python-sdk/
|
|
7
|
+
Project-URL: Repository, https://github.com/masterly-data/masterly-python-sdk
|
|
8
|
+
Project-URL: Changelog, https://github.com/masterly-data/masterly-python-sdk/releases
|
|
9
|
+
License: Apache-2.0
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: data products,databricks,fabric,lakehouse,master data,mdm,snowflake
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: httpx>=0.27
|
|
24
|
+
Provides-Extra: pandas
|
|
25
|
+
Requires-Dist: pandas>=2.0; extra == 'pandas'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# masterly — Python client
|
|
29
|
+
|
|
30
|
+
Extract governed data products and ingest records from Python — notebooks on Databricks,
|
|
31
|
+
Microsoft Fabric, or anywhere else your pipelines run.
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from masterly import Client
|
|
35
|
+
|
|
36
|
+
client = Client(
|
|
37
|
+
base_url="https://app.example.com", # your Masterly install
|
|
38
|
+
token="<session or service-account token>",
|
|
39
|
+
environment="env_prod_eu",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Extract: page through a data product (cursor-managed for you)
|
|
43
|
+
for row in client.products.read("Customer 360"):
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
# Or straight into a DataFrame
|
|
47
|
+
df = client.products.read("Customer 360").to_pandas()
|
|
48
|
+
|
|
49
|
+
# Follow the change feed with a resumable cursor
|
|
50
|
+
feed = client.products.changes("Customer 360", cursor=saved_cursor)
|
|
51
|
+
for change in feed:
|
|
52
|
+
...
|
|
53
|
+
save(feed.cursor) # persist for the next run
|
|
54
|
+
|
|
55
|
+
# Ingest: deliver records to a source (chunked automatically)
|
|
56
|
+
report = client.sources.ingest("crm", records)
|
|
57
|
+
print(report.records, "records in", report.batches, "batches")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Tokens: two personas
|
|
61
|
+
|
|
62
|
+
- **Consumer** (extract in a Databricks/Fabric job): use a **service-account token**
|
|
63
|
+
(minted under Users → Service accounts). It pins its Environment and consumes published
|
|
64
|
+
products — reference them **by product id** (`dp_…`), since listing products needs a
|
|
65
|
+
session. Access policies (row rules, masks) apply per consumer automatically.
|
|
66
|
+
- **Integrator** (ingest + golden reads): use a **session token**. Sources and products can
|
|
67
|
+
be referenced by name.
|
|
68
|
+
|
|
69
|
+
## Governed writes: state the revision you are replacing
|
|
70
|
+
|
|
71
|
+
Extract and ingest are ungoverned — a read is not a write, and an ingest batch is not derived
|
|
72
|
+
from a read of what it lands in. A **governed** write is one where more than one principal may
|
|
73
|
+
write the object and your request was composed from an earlier read of it: a rule set, an access
|
|
74
|
+
policy, a shared view, a record you edited by hand. Those writes state which revision they
|
|
75
|
+
replace, and the server refuses rather than letting the other editor's work disappear.
|
|
76
|
+
|
|
77
|
+
Read, edit, write — the version comes back with the object:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from masterly import ApiError
|
|
81
|
+
|
|
82
|
+
policy = client.request("GET", "/v1/access-policies/pol_1")
|
|
83
|
+
policy["rules"] = edited
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
client.request(
|
|
87
|
+
"PUT", "/v1/access-policies/pol_1", json=policy, if_match=policy["version"],
|
|
88
|
+
)
|
|
89
|
+
except ApiError as error:
|
|
90
|
+
conflict = error.conflict
|
|
91
|
+
if conflict is None:
|
|
92
|
+
raise
|
|
93
|
+
print(f"{conflict.changed_by} changed {', '.join(conflict.changed_fields)}")
|
|
94
|
+
if conflict.undisclosed_changes:
|
|
95
|
+
print(f"and {conflict.undisclosed_changes} further changes you cannot see")
|
|
96
|
+
if not conflict.may_auto_merge:
|
|
97
|
+
... # re-read and decide by hand; the change set cannot be trusted as complete
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
- `if_match` takes the object's `version` field, or a `Precondition` — `Precondition.from_etag`
|
|
101
|
+
if you kept the response header instead, `Precondition.unconditional()` for `If-Match: *`
|
|
102
|
+
("it must exist; I do not care which revision"), which is recorded in the audit trail as an
|
|
103
|
+
unconditional write.
|
|
104
|
+
- **A conflict names what moved, never what it moved to.** There is no "theirs" column in the
|
|
105
|
+
refusal, by design: it would be a read you may not be entitled to, arriving by the error path.
|
|
106
|
+
Re-read the object to compare — that runs the ordinary authorization and access-policy path.
|
|
107
|
+
- **Gate any automatic merge on `conflict.may_auto_merge`, never on a zero
|
|
108
|
+
`undisclosed_changes`.** A zero count means nothing was withheld from *you*; it is also what
|
|
109
|
+
you get when the writer recorded no field list at all — unknown, not empty.
|
|
110
|
+
- Omitting the precondition still works today and answers with a `Deprecation` header. Governed
|
|
111
|
+
operations move to `428 PRECONDITION_REQUIRED` as their grace ends, and unconditionally at
|
|
112
|
+
`/v2`.
|
|
113
|
+
|
|
114
|
+
`client.request(...)` is the escape hatch for endpoints the typed surface has not reached, on
|
|
115
|
+
the same connection: auth, Environment, timeouts, and error mapping. It also takes `headers=`
|
|
116
|
+
for anything per-request, such as an `Idempotency-Key`.
|
|
117
|
+
|
|
118
|
+
## Errors
|
|
119
|
+
|
|
120
|
+
The client is a thin, typed wrapper over the stable `/v1` REST contract — the same API
|
|
121
|
+
every other channel uses. Errors raise `masterly.ApiError` carrying the server's error
|
|
122
|
+
code, message, and `details` — the envelope's own payload, passed through as sent, which is
|
|
123
|
+
where a refusal explains itself. Re-delivery is idempotent: records upsert by their source key,
|
|
124
|
+
so running the same notebook twice never duplicates data.
|
|
125
|
+
|
|
126
|
+
`pandas` is an optional dependency: `pip install masterly[pandas]`.
|
|
127
|
+
|
|
128
|
+
## Configuration
|
|
129
|
+
|
|
130
|
+
Extract and ingest run on a configuration someone built. These build it — enough to stand
|
|
131
|
+
an Environment up from a script:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
client.workspaces.create("Demo")
|
|
135
|
+
client.domains.create("Sales", workspace="Demo")
|
|
136
|
+
client.data_models.create("Customer", domain="Sales", definition={...})
|
|
137
|
+
client.data_models.publish("Customer")
|
|
138
|
+
|
|
139
|
+
client.sources.create(
|
|
140
|
+
"erp",
|
|
141
|
+
target_model="Customer",
|
|
142
|
+
source_key="customer_number", # required: without it every record quarantines
|
|
143
|
+
field_map={"KUNNR": "customer_number", "NAME1": "name"},
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Everything takes an id or an exact name, so a script reads the way the domain is discussed.
|
|
148
|
+
A model's `definition` carries its attributes, keys and constraints — ingest validates every
|
|
149
|
+
record against them, so widening or tightening one changes what the pipeline accepts.
|
|
150
|
+
|
|
151
|
+
Editing a model is a **governed write**: more than one person edits models, and the edit is
|
|
152
|
+
derived from a read, so `update` states the revision it replaces (ADR 0070) and a stale one
|
|
153
|
+
is refused rather than silently overwriting:
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
model = client.data_models.get("Customer")
|
|
157
|
+
model["definition"]["attributes"].append({"name": "segment", "type": "string"})
|
|
158
|
+
client.data_models.update(
|
|
159
|
+
"Customer", definition=model["definition"], if_match=model["version"]
|
|
160
|
+
)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A refused write raises `ApiError`; `error.conflict` names the fields that moved. Anything
|
|
164
|
+
without a typed method is reached through `client.request(...)`, which takes the same
|
|
165
|
+
`if_match`.
|
|
166
|
+
|
|
167
|
+
## Examples
|
|
168
|
+
|
|
169
|
+
[`examples/demo_data.py`](examples/README.md) generates realistic demo master data —
|
|
170
|
+
customers, suppliers and products as three source systems would actually deliver them,
|
|
171
|
+
duplicates and defects included — and ingests it into an Environment.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
Apache-2.0 — see [LICENSE](LICENSE).
|
masterly-0.2.0/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# masterly — Python client
|
|
2
|
+
|
|
3
|
+
Extract governed data products and ingest records from Python — notebooks on Databricks,
|
|
4
|
+
Microsoft Fabric, or anywhere else your pipelines run.
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
from masterly import Client
|
|
8
|
+
|
|
9
|
+
client = Client(
|
|
10
|
+
base_url="https://app.example.com", # your Masterly install
|
|
11
|
+
token="<session or service-account token>",
|
|
12
|
+
environment="env_prod_eu",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Extract: page through a data product (cursor-managed for you)
|
|
16
|
+
for row in client.products.read("Customer 360"):
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
# Or straight into a DataFrame
|
|
20
|
+
df = client.products.read("Customer 360").to_pandas()
|
|
21
|
+
|
|
22
|
+
# Follow the change feed with a resumable cursor
|
|
23
|
+
feed = client.products.changes("Customer 360", cursor=saved_cursor)
|
|
24
|
+
for change in feed:
|
|
25
|
+
...
|
|
26
|
+
save(feed.cursor) # persist for the next run
|
|
27
|
+
|
|
28
|
+
# Ingest: deliver records to a source (chunked automatically)
|
|
29
|
+
report = client.sources.ingest("crm", records)
|
|
30
|
+
print(report.records, "records in", report.batches, "batches")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Tokens: two personas
|
|
34
|
+
|
|
35
|
+
- **Consumer** (extract in a Databricks/Fabric job): use a **service-account token**
|
|
36
|
+
(minted under Users → Service accounts). It pins its Environment and consumes published
|
|
37
|
+
products — reference them **by product id** (`dp_…`), since listing products needs a
|
|
38
|
+
session. Access policies (row rules, masks) apply per consumer automatically.
|
|
39
|
+
- **Integrator** (ingest + golden reads): use a **session token**. Sources and products can
|
|
40
|
+
be referenced by name.
|
|
41
|
+
|
|
42
|
+
## Governed writes: state the revision you are replacing
|
|
43
|
+
|
|
44
|
+
Extract and ingest are ungoverned — a read is not a write, and an ingest batch is not derived
|
|
45
|
+
from a read of what it lands in. A **governed** write is one where more than one principal may
|
|
46
|
+
write the object and your request was composed from an earlier read of it: a rule set, an access
|
|
47
|
+
policy, a shared view, a record you edited by hand. Those writes state which revision they
|
|
48
|
+
replace, and the server refuses rather than letting the other editor's work disappear.
|
|
49
|
+
|
|
50
|
+
Read, edit, write — the version comes back with the object:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from masterly import ApiError
|
|
54
|
+
|
|
55
|
+
policy = client.request("GET", "/v1/access-policies/pol_1")
|
|
56
|
+
policy["rules"] = edited
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
client.request(
|
|
60
|
+
"PUT", "/v1/access-policies/pol_1", json=policy, if_match=policy["version"],
|
|
61
|
+
)
|
|
62
|
+
except ApiError as error:
|
|
63
|
+
conflict = error.conflict
|
|
64
|
+
if conflict is None:
|
|
65
|
+
raise
|
|
66
|
+
print(f"{conflict.changed_by} changed {', '.join(conflict.changed_fields)}")
|
|
67
|
+
if conflict.undisclosed_changes:
|
|
68
|
+
print(f"and {conflict.undisclosed_changes} further changes you cannot see")
|
|
69
|
+
if not conflict.may_auto_merge:
|
|
70
|
+
... # re-read and decide by hand; the change set cannot be trusted as complete
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
- `if_match` takes the object's `version` field, or a `Precondition` — `Precondition.from_etag`
|
|
74
|
+
if you kept the response header instead, `Precondition.unconditional()` for `If-Match: *`
|
|
75
|
+
("it must exist; I do not care which revision"), which is recorded in the audit trail as an
|
|
76
|
+
unconditional write.
|
|
77
|
+
- **A conflict names what moved, never what it moved to.** There is no "theirs" column in the
|
|
78
|
+
refusal, by design: it would be a read you may not be entitled to, arriving by the error path.
|
|
79
|
+
Re-read the object to compare — that runs the ordinary authorization and access-policy path.
|
|
80
|
+
- **Gate any automatic merge on `conflict.may_auto_merge`, never on a zero
|
|
81
|
+
`undisclosed_changes`.** A zero count means nothing was withheld from *you*; it is also what
|
|
82
|
+
you get when the writer recorded no field list at all — unknown, not empty.
|
|
83
|
+
- Omitting the precondition still works today and answers with a `Deprecation` header. Governed
|
|
84
|
+
operations move to `428 PRECONDITION_REQUIRED` as their grace ends, and unconditionally at
|
|
85
|
+
`/v2`.
|
|
86
|
+
|
|
87
|
+
`client.request(...)` is the escape hatch for endpoints the typed surface has not reached, on
|
|
88
|
+
the same connection: auth, Environment, timeouts, and error mapping. It also takes `headers=`
|
|
89
|
+
for anything per-request, such as an `Idempotency-Key`.
|
|
90
|
+
|
|
91
|
+
## Errors
|
|
92
|
+
|
|
93
|
+
The client is a thin, typed wrapper over the stable `/v1` REST contract — the same API
|
|
94
|
+
every other channel uses. Errors raise `masterly.ApiError` carrying the server's error
|
|
95
|
+
code, message, and `details` — the envelope's own payload, passed through as sent, which is
|
|
96
|
+
where a refusal explains itself. Re-delivery is idempotent: records upsert by their source key,
|
|
97
|
+
so running the same notebook twice never duplicates data.
|
|
98
|
+
|
|
99
|
+
`pandas` is an optional dependency: `pip install masterly[pandas]`.
|
|
100
|
+
|
|
101
|
+
## Configuration
|
|
102
|
+
|
|
103
|
+
Extract and ingest run on a configuration someone built. These build it — enough to stand
|
|
104
|
+
an Environment up from a script:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
client.workspaces.create("Demo")
|
|
108
|
+
client.domains.create("Sales", workspace="Demo")
|
|
109
|
+
client.data_models.create("Customer", domain="Sales", definition={...})
|
|
110
|
+
client.data_models.publish("Customer")
|
|
111
|
+
|
|
112
|
+
client.sources.create(
|
|
113
|
+
"erp",
|
|
114
|
+
target_model="Customer",
|
|
115
|
+
source_key="customer_number", # required: without it every record quarantines
|
|
116
|
+
field_map={"KUNNR": "customer_number", "NAME1": "name"},
|
|
117
|
+
)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Everything takes an id or an exact name, so a script reads the way the domain is discussed.
|
|
121
|
+
A model's `definition` carries its attributes, keys and constraints — ingest validates every
|
|
122
|
+
record against them, so widening or tightening one changes what the pipeline accepts.
|
|
123
|
+
|
|
124
|
+
Editing a model is a **governed write**: more than one person edits models, and the edit is
|
|
125
|
+
derived from a read, so `update` states the revision it replaces (ADR 0070) and a stale one
|
|
126
|
+
is refused rather than silently overwriting:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
model = client.data_models.get("Customer")
|
|
130
|
+
model["definition"]["attributes"].append({"name": "segment", "type": "string"})
|
|
131
|
+
client.data_models.update(
|
|
132
|
+
"Customer", definition=model["definition"], if_match=model["version"]
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A refused write raises `ApiError`; `error.conflict` names the fields that moved. Anything
|
|
137
|
+
without a typed method is reached through `client.request(...)`, which takes the same
|
|
138
|
+
`if_match`.
|
|
139
|
+
|
|
140
|
+
## Examples
|
|
141
|
+
|
|
142
|
+
[`examples/demo_data.py`](examples/README.md) generates realistic demo master data —
|
|
143
|
+
customers, suppliers and products as three source systems would actually deliver them,
|
|
144
|
+
duplicates and defects included — and ingests it into an Environment.
|
|
145
|
+
|
|
146
|
+
## License
|
|
147
|
+
|
|
148
|
+
Apache-2.0 — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Demo data generator
|
|
2
|
+
|
|
3
|
+
[`demo_data.py`](demo_data.py) fills a Masterly Environment with realistic master data —
|
|
4
|
+
customers, suppliers and products as they would actually arrive from a CRM, an ERP and a
|
|
5
|
+
webshop. Use it to demo the product, to have something to click through while building a
|
|
6
|
+
feature, or to load-test an install.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
# bootstrap + generate + ingest against a local install on the dev identity binding
|
|
10
|
+
uv run examples/demo_data.py \
|
|
11
|
+
--base-url http://localhost:8001 \
|
|
12
|
+
--dev-login you@example.com \
|
|
13
|
+
--environment env_demo_prd_eu \
|
|
14
|
+
--wait
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
From this checkout, `uv run` picks up the project's own `masterly`. Anywhere else, bring the
|
|
18
|
+
client along: `uv run --with 'masterly>=0.2.0' demo_data.py …`, or `pip install masterly`
|
|
19
|
+
and `python demo_data.py …`. It needs **0.2.0 or later** — that is the release that carries
|
|
20
|
+
the configuration surface it bootstraps with.
|
|
21
|
+
|
|
22
|
+
The script itself is thin: `client.workspaces`, `client.domains`, `client.data_models` and
|
|
23
|
+
`client.sources` build the configuration, `client.sources.ingest` delivers the records. The
|
|
24
|
+
only raw HTTP left in it is the pair of calls that happen before a connection exists —
|
|
25
|
+
minting a token and asking which Environments you may use.
|
|
26
|
+
|
|
27
|
+
## What it creates
|
|
28
|
+
|
|
29
|
+
Against an empty Environment the script is self-sufficient: it creates a Workspace, a
|
|
30
|
+
Domain, three published Data Models, and one Source per originating system. Every step is
|
|
31
|
+
find-or-create, so running it twice changes nothing about the configuration.
|
|
32
|
+
|
|
33
|
+
Find-or-create matches on name, and a name is not proof of identity: an Environment can
|
|
34
|
+
already hold someone else's `Customer`. So before delivering into a model or source it
|
|
35
|
+
found rather than created, the script checks that it is the one it defines — same
|
|
36
|
+
attributes, same target model, same natural key — and stops with an explanation instead of
|
|
37
|
+
ingesting a batch that would quarantine wholesale. Use a clean Environment, remove the
|
|
38
|
+
stale object, or generate for the existing model on its own terms with `--model`.
|
|
39
|
+
|
|
40
|
+
| Data Model | Sources | Field names on the wire |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| Customer | `crm`, `erp`, `webshop` | Salesforce-style, SAP-style, and passthrough |
|
|
43
|
+
| Supplier | `erp-suppliers`, `procurement-portal` | SAP-style and portal-style |
|
|
44
|
+
| Product | `pim`, `erp-products` | PIM-style and SAP-style |
|
|
45
|
+
|
|
46
|
+
Each Source carries the field map that conforms its own vocabulary to the model
|
|
47
|
+
(`Account_Name`, `NAME1` and `name` all land in `name`), and declares its natural key.
|
|
48
|
+
`webshop` is deliberately left as passthrough, so both mapping styles are represented.
|
|
49
|
+
|
|
50
|
+
## What makes the data worth ingesting
|
|
51
|
+
|
|
52
|
+
The population is generated once, then delivered *through* each system — so the same
|
|
53
|
+
company arrives from the CRM, the ERP and the webshop under three different customer
|
|
54
|
+
numbers, formatted the way that system formats things:
|
|
55
|
+
|
|
56
|
+
- **Legal-form drift** — `Nordwind Logistik AB`, `NORDWIND LOGISTIK AB`, `Nordwind
|
|
57
|
+
Logistik Aktiebolag`, `Nordwind Logistik`
|
|
58
|
+
- **Address drift** — `Storgatan 12` vs `Storg. 12`, `111 22` vs `11122`
|
|
59
|
+
- **Phone formats** — `+46 8 123 45 67`, `08123 45 67`, `4681234567`
|
|
60
|
+
- **Keying slips** — transposed, dropped and doubled characters; trailing whitespace from
|
|
61
|
+
a spreadsheet paste
|
|
62
|
+
- **Coverage gaps** — the ERP holds no website or employee count, the webshop holds no
|
|
63
|
+
registration number, and optional fields go missing at random
|
|
64
|
+
- **Intra-source duplicates** — the same company entered twice in one system under two
|
|
65
|
+
keys, never byte-identical (`--duplicate-rate`)
|
|
66
|
+
- **Structural defects** — malformed e-mail, country spelled out, unknown enum value,
|
|
67
|
+
empty required field, negative amount, a number delivered as text, and a record with no
|
|
68
|
+
natural key at all (`--defect-rate`)
|
|
69
|
+
|
|
70
|
+
The defects are the ones the platform actually rejects, so they land in the source's
|
|
71
|
+
quarantine with a real reason rather than passing through unnoticed. `tests/test_demo_data.py`
|
|
72
|
+
holds the generator to that: clean records must satisfy the model's own constraints, and
|
|
73
|
+
every defect must break one.
|
|
74
|
+
|
|
75
|
+
Everything is seeded — the same `--seed` produces the same records, and re-delivery
|
|
76
|
+
upserts by source key, so running twice never doubles the mastered data. (Quarantine rows
|
|
77
|
+
do accumulate: each delivery of a bad record is its own quarantine entry.)
|
|
78
|
+
|
|
79
|
+
## Common runs
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# look at the records without an install
|
|
83
|
+
uv run examples/demo_data.py --dry-run --customers 5
|
|
84
|
+
|
|
85
|
+
# write them to a file instead
|
|
86
|
+
uv run examples/demo_data.py --dry-run --out /tmp/records.json
|
|
87
|
+
|
|
88
|
+
# a bigger population, customers only
|
|
89
|
+
uv run examples/demo_data.py --base-url … --dev-login … --models customer --customers 5000
|
|
90
|
+
|
|
91
|
+
# messier data, for the stewardship queues
|
|
92
|
+
uv run examples/demo_data.py --base-url … --dev-login … --defect-rate 0.2 --duplicate-rate 0.3
|
|
93
|
+
|
|
94
|
+
# configuration already exists — just deliver more records
|
|
95
|
+
uv run examples/demo_data.py --base-url … --token "$MASTERLY_TOKEN" --no-bootstrap
|
|
96
|
+
|
|
97
|
+
# ingest into a model of your own, generated from its live definition
|
|
98
|
+
uv run examples/demo_data.py --base-url … --token "$MASTERLY_TOKEN" \
|
|
99
|
+
--model "Customer 360" --source crm --records 500
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`--model` reads the model's definition from the install and invents values that fit it —
|
|
103
|
+
types, enum values, ranges, and a guess from each attribute's name (`*_email` gets an
|
|
104
|
+
e-mail, `*_city` a city, `*_date` a date). The natural key comes from the Source's own
|
|
105
|
+
`source_key`. Where an attribute has a format constraint the generator cannot satisfy, it
|
|
106
|
+
says so and leaves the attribute empty rather than quarantining every record.
|
|
107
|
+
|
|
108
|
+
## Authenticating
|
|
109
|
+
|
|
110
|
+
- `--dev-login EMAIL` mints a session on installs running the **dev identity binding**
|
|
111
|
+
(local development, the demo install). It sends the credential as `dev:<email>`.
|
|
112
|
+
- `--token` (or `MASTERLY_TOKEN`) takes a session token from any install — use this on
|
|
113
|
+
Stytch or OIDC bindings. `--base-url` and `--environment` also read
|
|
114
|
+
`MASTERLY_BASE_URL` / `MASTERLY_ENVIRONMENT`.
|
|
115
|
+
|
|
116
|
+
Bootstrapping needs `workspace:create`, `domain:create`, `data-model:create`,
|
|
117
|
+
`data-model:update`, `source:create` and `ingest:run` in the target Environment — the
|
|
118
|
+
**Modeler** preset role covers all six. With `--no-bootstrap` or `--model`, an
|
|
119
|
+
**Integrator** is enough.
|
|
120
|
+
|
|
121
|
+
## After the run
|
|
122
|
+
|
|
123
|
+
`--wait` polls until the pipeline stops moving and prints where the records landed:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
source accepted quarantined
|
|
127
|
+
---------------------------------------------
|
|
128
|
+
crm 36 4
|
|
129
|
+
erp 26 8
|
|
130
|
+
webshop 14 2
|
|
131
|
+
...
|
|
132
|
+
|
|
133
|
+
model source records golden entities
|
|
134
|
+
-------------------------------------------------------
|
|
135
|
+
Customer 76 76
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
One golden entity per source record means **no match rules are configured yet** — the
|
|
139
|
+
duplicates this script generates on purpose are all still sitting there unlinked. Set up
|
|
140
|
+
matching for a model — `PUT /v1/match/config/{model_name}`, or the matching editor in the
|
|
141
|
+
app — and saving the config re-resolves the model, dropping the entity count below the
|
|
142
|
+
source-record count. That is the demo worth showing.
|
|
143
|
+
|
|
144
|
+
Ingest is asynchronous. Without `--wait` the script returns as soon as the batches are
|
|
145
|
+
accepted, and the records appear a few seconds later.
|