masterly 0.4.0__tar.gz → 0.5.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.4.0 → masterly-0.5.0}/PKG-INFO +117 -2
- {masterly-0.4.0 → masterly-0.5.0}/README.md +116 -1
- {masterly-0.4.0 → masterly-0.5.0}/pyproject.toml +1 -1
- {masterly-0.4.0 → masterly-0.5.0}/src/masterly/__init__.py +3 -2
- {masterly-0.4.0 → masterly-0.5.0}/src/masterly/_client.py +16 -4
- masterly-0.5.0/src/masterly/_ingest.py +878 -0
- masterly-0.5.0/tests/test_source_ignored_fields.py +163 -0
- masterly-0.5.0/tests/test_source_lifecycle.py +334 -0
- masterly-0.5.0/tests/test_source_targets.py +551 -0
- {masterly-0.4.0 → masterly-0.5.0}/uv.lock +263 -197
- masterly-0.4.0/src/masterly/_ingest.py +0 -466
- {masterly-0.4.0 → masterly-0.5.0}/.claude/PROJECT-CLAUDE.md +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/.claude/hooks/project-claude-md.sh +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/.claude/settings.json +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/.github/dependabot.yml +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/.github/workflows/ci.yml +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/.github/workflows/release.yml +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/.github/workflows/workflow-hardening.yml +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/.github/zizmor.yml +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/.gitignore +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/LICENSE +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/RELEASING.md +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/SECURITY.md +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/examples/README.md +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/examples/demo_data.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/scripts/check_release_record.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/src/masterly/_config.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/src/masterly/_extract.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/src/masterly/_jobs.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/src/masterly/_paging.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/src/masterly/_precondition.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/src/masterly/cli/__init__.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/src/masterly/cli/__main__.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/src/masterly/cli/_engine.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/src/masterly/cli/_schema.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/src/masterly/py.typed +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/tests/test_cli_migrate.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/tests/test_client.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/tests/test_config.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/tests/test_demo_data.py +0 -0
- {masterly-0.4.0 → masterly-0.5.0}/tests/test_receipts_jobs_loads.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: masterly
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Python client for Masterly — extract governed data products and ingest records from your lakehouse
|
|
5
5
|
Project-URL: Homepage, https://masterlydata.com
|
|
6
6
|
Project-URL: Documentation, https://masterlydata.com/docs/reference/python-sdk/
|
|
@@ -222,7 +222,9 @@ recomputes without it, and its history stays readable. A delete for a key the so
|
|
|
222
222
|
hold does nothing.
|
|
223
223
|
|
|
224
224
|
When a system can only hand over everything it has, send it as a **full snapshot**: every live
|
|
225
|
-
record of the
|
|
225
|
+
record of the target that the snapshot does not carry is deleted after the upserts (a source
|
|
226
|
+
that [feeds several models](#a-source-that-feeds-several-models) names the target with
|
|
227
|
+
`model=`; its other targets are untouched).
|
|
226
228
|
|
|
227
229
|
```python
|
|
228
230
|
client.sources.ingest("crm", every_customer, mode="full", batch_size=5000)
|
|
@@ -413,10 +415,84 @@ source = client.sources.get("erp")
|
|
|
413
415
|
client.sources.update("erp", display_name="SAP ERP (Sweden)", if_match=source["version"])
|
|
414
416
|
```
|
|
415
417
|
|
|
418
|
+
A field the field map does not name passes through under its own name, so a field named like a
|
|
419
|
+
model attribute lands on it. When such a field is not meant for the model, name it in
|
|
420
|
+
`ignored_fields=` (on `create`, `SourceTarget` and `targets.add`): it is dropped before the field
|
|
421
|
+
map, lands on no attribute and is not reported as schema drift. It comes back as
|
|
422
|
+
`mapping["ignored_fields"]`; to change it later, edit the mapping you read and pass it to
|
|
423
|
+
`update` (or `targets.update`). An install on an API release before this field existed drops
|
|
424
|
+
`ignored_fields` without an error and returns no such key, so read the mapping back to confirm it
|
|
425
|
+
was kept.
|
|
426
|
+
|
|
416
427
|
Everything takes an id or an exact name, so a script reads the way the domain is discussed.
|
|
417
428
|
A model's `definition` carries its attributes, keys and constraints — ingest validates every
|
|
418
429
|
record against them, so widening or tightening one changes what the pipeline accepts.
|
|
419
430
|
|
|
431
|
+
### A source that feeds several models
|
|
432
|
+
|
|
433
|
+
A source feeds one or more models — its **targets** — and each target has its own source
|
|
434
|
+
key and field map. A CRM export that carries customers and their addresses is one source
|
|
435
|
+
with two targets, registered with `targets=` instead of `target_model=`:
|
|
436
|
+
|
|
437
|
+
```python
|
|
438
|
+
from masterly import SourceTarget
|
|
439
|
+
|
|
440
|
+
client.sources.create(
|
|
441
|
+
"crm",
|
|
442
|
+
display_name="Salesforce CRM",
|
|
443
|
+
targets=[
|
|
444
|
+
SourceTarget("Customer", source_key="customer_number"),
|
|
445
|
+
SourceTarget("Address", source_key="address_id", field_map={"ADDR_ID": "address_id"}),
|
|
446
|
+
],
|
|
447
|
+
)
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
A batch lands in **one** target, so a push to such a source names it with `model=` — one call
|
|
451
|
+
per model. On a source with one target `model` may be left out, and the call sends exactly
|
|
452
|
+
what it always sent; on a source with several, an unnamed batch is refused with
|
|
453
|
+
`SOURCE_TARGET_REQUIRED` before anything is queued, `error.details["targets"]` listing them:
|
|
454
|
+
|
|
455
|
+
```python
|
|
456
|
+
client.sources.ingest("crm", customers, model="Customer")
|
|
457
|
+
client.sources.ingest("crm", addresses, model="Address")
|
|
458
|
+
client.sources.upload_csv("crm", open("addresses.csv").read(), model="Address", delimiter=";")
|
|
459
|
+
|
|
460
|
+
with client.sources.full_load("crm", model="Address") as load: # one target's snapshot
|
|
461
|
+
load.send(every_address)
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Records, quarantine and counts are per target too — a source key names one record within a
|
|
465
|
+
target, so the same key under two targets is two records:
|
|
466
|
+
|
|
467
|
+
```python
|
|
468
|
+
for row in client.sources.quarantine("crm", model="Address", status="open"):
|
|
469
|
+
print(row["model_name"], row["reason_code"], row["payload"])
|
|
470
|
+
|
|
471
|
+
stats = client.sources.stats("crm") # the whole source, plus `targets[]` per model
|
|
472
|
+
stats = client.sources.stats("crm", model="Address")
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
`client.sources.targets` lists, adds, reads, edits and removes a source's targets. A target
|
|
476
|
+
has a revision of its own, stated on its governed writes; adding one needs a model with a
|
|
477
|
+
published version, and only a target that holds no records — deleted ones included — can be
|
|
478
|
+
removed, never the last one:
|
|
479
|
+
|
|
480
|
+
```python
|
|
481
|
+
client.sources.targets.add("crm", "Address", source_key="address_id")
|
|
482
|
+
target = client.sources.targets.get("crm", "Address")
|
|
483
|
+
target["mapping"]["field_map"]["STREET"] = "street"
|
|
484
|
+
client.sources.targets.update("crm", "Address", mapping=target["mapping"], if_match=target["version"])
|
|
485
|
+
client.sources.targets.remove("crm", "Address", if_match=target["version"]) # only while empty
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
On what `client.sources.get` returns, `targets[]` carries every target; the top-level
|
|
489
|
+
`target_model`, `mapping`, `profile` and `drift` are **deprecated** and describe the first
|
|
490
|
+
target — on a one-target source, exactly what they always did. The same goes for `target_model=`
|
|
491
|
+
and `mapping=` on `client.sources.update`, which write the first target; edit any target through
|
|
492
|
+
`client.sources.targets.update`. These calls talk to routes that are not in a published release
|
|
493
|
+
of the API yet; an install on an earlier release answers them `404 NOT_FOUND`, and sends every
|
|
494
|
+
batch to its one target.
|
|
495
|
+
|
|
420
496
|
Editing a model or a source is a **governed write**: more than one person edits them, and the
|
|
421
497
|
edit is derived from a read, so `update` states the revision it replaces (ADR 0070) and a stale
|
|
422
498
|
one is refused rather than silently overwriting:
|
|
@@ -433,6 +509,45 @@ A refused write raises `ApiError`; `error.conflict` names the fields that moved.
|
|
|
433
509
|
without a typed method is reached through `client.request(...)`, which takes the same
|
|
434
510
|
`if_match`.
|
|
435
511
|
|
|
512
|
+
### Deactivate, reactivate or delete a source
|
|
513
|
+
|
|
514
|
+
A source has a `status`: `active`, or `inactive` once deactivated. An **inactive** source admits
|
|
515
|
+
nothing — a pushed batch, a CSV upload, a full load's next batch and a quarantine retry are all
|
|
516
|
+
refused with `409 SOURCE_INACTIVE` and nothing is queued. Everything it already landed stays,
|
|
517
|
+
and **its records keep contributing to golden records**: nothing is recomputed, in either
|
|
518
|
+
direction. Reactivating admits records again with nothing lost. Both are governed writes, so
|
|
519
|
+
they take the source's `version` as `if_match`:
|
|
520
|
+
|
|
521
|
+
```python
|
|
522
|
+
source = client.sources.get("erp")
|
|
523
|
+
client.sources.deactivate("erp", if_match=source["version"]) # status: "inactive"
|
|
524
|
+
|
|
525
|
+
source = client.sources.get("erp")
|
|
526
|
+
client.sources.reactivate("erp", if_match=source["version"]) # status: "active"
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
A **delete** removes the source and everything it landed — its records in every target and
|
|
530
|
+
their history, quarantine, profile, drift, run history, schedule and sealed connection — and
|
|
531
|
+
recomputes the golden records it contributed to without it; an entity left with no live
|
|
532
|
+
record clears. It is heavy work, so it runs as a job: the source is marked `deleting` at once,
|
|
533
|
+
the answer is the job receipt, and `client.jobs.wait` follows the removal to its end. Read
|
|
534
|
+
what it would touch first, and ask a person to type the source's `name` before you send it:
|
|
535
|
+
|
|
536
|
+
```python
|
|
537
|
+
impact = client.sources.deletion_impact("erp")
|
|
538
|
+
print(impact["targets"], impact["golden"]["would_change"], impact["golden"]["would_clear"])
|
|
539
|
+
if impact["deletable"] and typed_name == impact["name"]:
|
|
540
|
+
source = client.sources.get("erp")
|
|
541
|
+
receipt = client.sources.delete("erp", if_match=source["version"])
|
|
542
|
+
client.jobs.wait(receipt["job_id"])
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
The delete is refused with `409 SOURCE_IN_USE` while a data product reads the source through a
|
|
546
|
+
raw relation — `error.details["dependents"]` names each one; change or delete the product
|
|
547
|
+
first — and the model's built-in manual source can be neither deactivated nor deleted
|
|
548
|
+
(`409 SOURCE_BUILT_IN`). The four calls are session routes. They talk to routes that are not
|
|
549
|
+
in a published release yet; an install on an earlier release answers them `404 NOT_FOUND`.
|
|
550
|
+
|
|
436
551
|
## Command line: `masterly config migrate`
|
|
437
552
|
|
|
438
553
|
The configuration files in a GitOps repository follow a schema that Masterly versions. A
|
|
@@ -193,7 +193,9 @@ recomputes without it, and its history stays readable. A delete for a key the so
|
|
|
193
193
|
hold does nothing.
|
|
194
194
|
|
|
195
195
|
When a system can only hand over everything it has, send it as a **full snapshot**: every live
|
|
196
|
-
record of the
|
|
196
|
+
record of the target that the snapshot does not carry is deleted after the upserts (a source
|
|
197
|
+
that [feeds several models](#a-source-that-feeds-several-models) names the target with
|
|
198
|
+
`model=`; its other targets are untouched).
|
|
197
199
|
|
|
198
200
|
```python
|
|
199
201
|
client.sources.ingest("crm", every_customer, mode="full", batch_size=5000)
|
|
@@ -384,10 +386,84 @@ source = client.sources.get("erp")
|
|
|
384
386
|
client.sources.update("erp", display_name="SAP ERP (Sweden)", if_match=source["version"])
|
|
385
387
|
```
|
|
386
388
|
|
|
389
|
+
A field the field map does not name passes through under its own name, so a field named like a
|
|
390
|
+
model attribute lands on it. When such a field is not meant for the model, name it in
|
|
391
|
+
`ignored_fields=` (on `create`, `SourceTarget` and `targets.add`): it is dropped before the field
|
|
392
|
+
map, lands on no attribute and is not reported as schema drift. It comes back as
|
|
393
|
+
`mapping["ignored_fields"]`; to change it later, edit the mapping you read and pass it to
|
|
394
|
+
`update` (or `targets.update`). An install on an API release before this field existed drops
|
|
395
|
+
`ignored_fields` without an error and returns no such key, so read the mapping back to confirm it
|
|
396
|
+
was kept.
|
|
397
|
+
|
|
387
398
|
Everything takes an id or an exact name, so a script reads the way the domain is discussed.
|
|
388
399
|
A model's `definition` carries its attributes, keys and constraints — ingest validates every
|
|
389
400
|
record against them, so widening or tightening one changes what the pipeline accepts.
|
|
390
401
|
|
|
402
|
+
### A source that feeds several models
|
|
403
|
+
|
|
404
|
+
A source feeds one or more models — its **targets** — and each target has its own source
|
|
405
|
+
key and field map. A CRM export that carries customers and their addresses is one source
|
|
406
|
+
with two targets, registered with `targets=` instead of `target_model=`:
|
|
407
|
+
|
|
408
|
+
```python
|
|
409
|
+
from masterly import SourceTarget
|
|
410
|
+
|
|
411
|
+
client.sources.create(
|
|
412
|
+
"crm",
|
|
413
|
+
display_name="Salesforce CRM",
|
|
414
|
+
targets=[
|
|
415
|
+
SourceTarget("Customer", source_key="customer_number"),
|
|
416
|
+
SourceTarget("Address", source_key="address_id", field_map={"ADDR_ID": "address_id"}),
|
|
417
|
+
],
|
|
418
|
+
)
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
A batch lands in **one** target, so a push to such a source names it with `model=` — one call
|
|
422
|
+
per model. On a source with one target `model` may be left out, and the call sends exactly
|
|
423
|
+
what it always sent; on a source with several, an unnamed batch is refused with
|
|
424
|
+
`SOURCE_TARGET_REQUIRED` before anything is queued, `error.details["targets"]` listing them:
|
|
425
|
+
|
|
426
|
+
```python
|
|
427
|
+
client.sources.ingest("crm", customers, model="Customer")
|
|
428
|
+
client.sources.ingest("crm", addresses, model="Address")
|
|
429
|
+
client.sources.upload_csv("crm", open("addresses.csv").read(), model="Address", delimiter=";")
|
|
430
|
+
|
|
431
|
+
with client.sources.full_load("crm", model="Address") as load: # one target's snapshot
|
|
432
|
+
load.send(every_address)
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Records, quarantine and counts are per target too — a source key names one record within a
|
|
436
|
+
target, so the same key under two targets is two records:
|
|
437
|
+
|
|
438
|
+
```python
|
|
439
|
+
for row in client.sources.quarantine("crm", model="Address", status="open"):
|
|
440
|
+
print(row["model_name"], row["reason_code"], row["payload"])
|
|
441
|
+
|
|
442
|
+
stats = client.sources.stats("crm") # the whole source, plus `targets[]` per model
|
|
443
|
+
stats = client.sources.stats("crm", model="Address")
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
`client.sources.targets` lists, adds, reads, edits and removes a source's targets. A target
|
|
447
|
+
has a revision of its own, stated on its governed writes; adding one needs a model with a
|
|
448
|
+
published version, and only a target that holds no records — deleted ones included — can be
|
|
449
|
+
removed, never the last one:
|
|
450
|
+
|
|
451
|
+
```python
|
|
452
|
+
client.sources.targets.add("crm", "Address", source_key="address_id")
|
|
453
|
+
target = client.sources.targets.get("crm", "Address")
|
|
454
|
+
target["mapping"]["field_map"]["STREET"] = "street"
|
|
455
|
+
client.sources.targets.update("crm", "Address", mapping=target["mapping"], if_match=target["version"])
|
|
456
|
+
client.sources.targets.remove("crm", "Address", if_match=target["version"]) # only while empty
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
On what `client.sources.get` returns, `targets[]` carries every target; the top-level
|
|
460
|
+
`target_model`, `mapping`, `profile` and `drift` are **deprecated** and describe the first
|
|
461
|
+
target — on a one-target source, exactly what they always did. The same goes for `target_model=`
|
|
462
|
+
and `mapping=` on `client.sources.update`, which write the first target; edit any target through
|
|
463
|
+
`client.sources.targets.update`. These calls talk to routes that are not in a published release
|
|
464
|
+
of the API yet; an install on an earlier release answers them `404 NOT_FOUND`, and sends every
|
|
465
|
+
batch to its one target.
|
|
466
|
+
|
|
391
467
|
Editing a model or a source is a **governed write**: more than one person edits them, and the
|
|
392
468
|
edit is derived from a read, so `update` states the revision it replaces (ADR 0070) and a stale
|
|
393
469
|
one is refused rather than silently overwriting:
|
|
@@ -404,6 +480,45 @@ A refused write raises `ApiError`; `error.conflict` names the fields that moved.
|
|
|
404
480
|
without a typed method is reached through `client.request(...)`, which takes the same
|
|
405
481
|
`if_match`.
|
|
406
482
|
|
|
483
|
+
### Deactivate, reactivate or delete a source
|
|
484
|
+
|
|
485
|
+
A source has a `status`: `active`, or `inactive` once deactivated. An **inactive** source admits
|
|
486
|
+
nothing — a pushed batch, a CSV upload, a full load's next batch and a quarantine retry are all
|
|
487
|
+
refused with `409 SOURCE_INACTIVE` and nothing is queued. Everything it already landed stays,
|
|
488
|
+
and **its records keep contributing to golden records**: nothing is recomputed, in either
|
|
489
|
+
direction. Reactivating admits records again with nothing lost. Both are governed writes, so
|
|
490
|
+
they take the source's `version` as `if_match`:
|
|
491
|
+
|
|
492
|
+
```python
|
|
493
|
+
source = client.sources.get("erp")
|
|
494
|
+
client.sources.deactivate("erp", if_match=source["version"]) # status: "inactive"
|
|
495
|
+
|
|
496
|
+
source = client.sources.get("erp")
|
|
497
|
+
client.sources.reactivate("erp", if_match=source["version"]) # status: "active"
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
A **delete** removes the source and everything it landed — its records in every target and
|
|
501
|
+
their history, quarantine, profile, drift, run history, schedule and sealed connection — and
|
|
502
|
+
recomputes the golden records it contributed to without it; an entity left with no live
|
|
503
|
+
record clears. It is heavy work, so it runs as a job: the source is marked `deleting` at once,
|
|
504
|
+
the answer is the job receipt, and `client.jobs.wait` follows the removal to its end. Read
|
|
505
|
+
what it would touch first, and ask a person to type the source's `name` before you send it:
|
|
506
|
+
|
|
507
|
+
```python
|
|
508
|
+
impact = client.sources.deletion_impact("erp")
|
|
509
|
+
print(impact["targets"], impact["golden"]["would_change"], impact["golden"]["would_clear"])
|
|
510
|
+
if impact["deletable"] and typed_name == impact["name"]:
|
|
511
|
+
source = client.sources.get("erp")
|
|
512
|
+
receipt = client.sources.delete("erp", if_match=source["version"])
|
|
513
|
+
client.jobs.wait(receipt["job_id"])
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
The delete is refused with `409 SOURCE_IN_USE` while a data product reads the source through a
|
|
517
|
+
raw relation — `error.details["dependents"]` names each one; change or delete the product
|
|
518
|
+
first — and the model's built-in manual source can be neither deactivated nor deleted
|
|
519
|
+
(`409 SOURCE_BUILT_IN`). The four calls are session routes. They talk to routes that are not
|
|
520
|
+
in a published release yet; an install on an earlier release answers them `404 NOT_FOUND`.
|
|
521
|
+
|
|
407
522
|
## Command line: `masterly config migrate`
|
|
408
523
|
|
|
409
524
|
The configuration files in a GitOps repository follow a schema that Masterly versions. A
|
|
@@ -20,10 +20,10 @@ personas" says how to get either one.
|
|
|
20
20
|
|
|
21
21
|
from masterly._client import ApiError, Client, Persona
|
|
22
22
|
from masterly._extract import ChangeFeed, RowPages
|
|
23
|
-
from masterly._ingest import FullLoad, IngestMode, IngestReceipt, IngestReport
|
|
23
|
+
from masterly._ingest import FullLoad, IngestMode, IngestReceipt, IngestReport, SourceTarget
|
|
24
24
|
from masterly._precondition import Conflict, Precondition
|
|
25
25
|
|
|
26
|
-
__version__ = "0.
|
|
26
|
+
__version__ = "0.5.0"
|
|
27
27
|
|
|
28
28
|
__all__ = [
|
|
29
29
|
"ApiError",
|
|
@@ -37,5 +37,6 @@ __all__ = [
|
|
|
37
37
|
"Persona",
|
|
38
38
|
"Precondition",
|
|
39
39
|
"RowPages",
|
|
40
|
+
"SourceTarget",
|
|
40
41
|
"__version__",
|
|
41
42
|
]
|
|
@@ -283,7 +283,16 @@ class Client:
|
|
|
283
283
|
response = self._http.request(method, path, params=params, json=json, headers=sent or None)
|
|
284
284
|
return self._answer(response)
|
|
285
285
|
|
|
286
|
-
def _keyed_request(
|
|
286
|
+
def _keyed_request(
|
|
287
|
+
self,
|
|
288
|
+
method: str,
|
|
289
|
+
path: str,
|
|
290
|
+
*,
|
|
291
|
+
json: Any = None,
|
|
292
|
+
params: dict[str, Any] | None = None,
|
|
293
|
+
content: bytes | None = None,
|
|
294
|
+
headers: Mapping[str, str] | None = None,
|
|
295
|
+
) -> Any:
|
|
287
296
|
"""Send a write the server de-duplicates by ``Idempotency-Key``, retrying it with the
|
|
288
297
|
same key when the connection fails before an answer arrives.
|
|
289
298
|
|
|
@@ -291,12 +300,15 @@ class Client:
|
|
|
291
300
|
reached us is indistinguishable, here, from one that never got through. Re-sending with
|
|
292
301
|
the same key makes that ambiguity harmless: the server replays the answer it already
|
|
293
302
|
gave instead of applying the write a second time. An answer — any status — is never
|
|
294
|
-
retried; it raises or returns as usual.
|
|
303
|
+
retried; it raises or returns as usual. The body is ``json``, or raw ``content`` (a
|
|
304
|
+
CSV file) with its own ``Content-Type`` in ``headers``.
|
|
295
305
|
"""
|
|
296
|
-
|
|
306
|
+
sent = {**(headers or {}), _IDEMPOTENCY_KEY: str(uuid.uuid4())}
|
|
297
307
|
for backoff in (*_KEYED_RETRY_BACKOFF, None):
|
|
298
308
|
try:
|
|
299
|
-
response = self._http.request(
|
|
309
|
+
response = self._http.request(
|
|
310
|
+
method, path, params=params, json=json, content=content, headers=sent
|
|
311
|
+
)
|
|
300
312
|
except httpx.TransportError:
|
|
301
313
|
if backoff is None:
|
|
302
314
|
raise
|