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.
@@ -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/
@@ -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).
@@ -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.