zipher-sdk 0.4.6__tar.gz → 0.4.7__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.
- {zipher_sdk-0.4.6/zipher_sdk.egg-info → zipher_sdk-0.4.7}/PKG-INFO +45 -1
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/README.md +45 -1
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/setup.py +1 -1
- zipher_sdk-0.4.7/tests/unit/conftest.py +12 -0
- zipher_sdk-0.4.7/tests/unit/test_client.py +47 -0
- zipher_sdk-0.4.7/tests/unit/test_telemetry.py +298 -0
- zipher_sdk-0.4.7/zipher/__init__.py +5 -0
- zipher_sdk-0.4.7/zipher/_telemetry.py +109 -0
- zipher_sdk-0.4.7/zipher/_version.py +13 -0
- zipher_sdk-0.4.7/zipher/acl/__init__.py +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/client.py +11 -2
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/models.py +3 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7/zipher_sdk.egg-info}/PKG-INFO +45 -1
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher_sdk.egg-info/SOURCES.txt +6 -0
- zipher_sdk-0.4.6/zipher/__init__.py +0 -2
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/LICENSE +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/setup.cfg +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/tests/__init__.py +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/tests/test_acl/__init__.py +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/tests/test_acl/sample_job.py +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/tests/test_acl/test_acl.py +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/tests/test_client.py +0 -0
- {zipher_sdk-0.4.6/zipher/acl → zipher_sdk-0.4.7/tests/unit}/__init__.py +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/acl/models.py +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/acl/zipher_installer.py +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/cli.py +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/exceptions.py +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/utils.py +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher_sdk.egg-info/dependency_links.txt +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher_sdk.egg-info/entry_points.txt +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher_sdk.egg-info/requires.txt +0 -0
- {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher_sdk.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zipher-sdk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.7
|
|
4
4
|
Summary: Python SDK for programmatic access to the Zipher API
|
|
5
5
|
Author: Zipher Inc.
|
|
6
6
|
Author-email: yoav@zipher.cloud
|
|
@@ -29,6 +29,7 @@ The Zipher SDK is a Python library for interacting with Zipher's APIs.
|
|
|
29
29
|
- [Package Installation](#package-installation)
|
|
30
30
|
- [Granting access to Zipher](#providing-zipher-with-access-to-databricks-workspace)
|
|
31
31
|
- [Usage](#sdk-usage)
|
|
32
|
+
- [Telemetry](#telemetry)
|
|
32
33
|
|
|
33
34
|
## Package Installation
|
|
34
35
|
|
|
@@ -223,3 +224,46 @@ optimized_tasks = client.get_optimized_tasks(job_id="my-job-id", tasks=tasks_to_
|
|
|
223
224
|
|
|
224
225
|
# Continue with sending the optimized tasks to Databricks via the Databricks python SDK, Airflow operator, etc.
|
|
225
226
|
```
|
|
227
|
+
|
|
228
|
+
## Telemetry
|
|
229
|
+
|
|
230
|
+
Every request the SDK sends to Zipher's APIs carries your API key and two headers:
|
|
231
|
+
|
|
232
|
+
- `User-Agent: zipher-sdk/<version>` identifies the SDK. It is always sent and is not part of the opt-out
|
|
233
|
+
telemetry below.
|
|
234
|
+
- `X-Zipher-Client-Info` is the opt-out telemetry: a compact JSON object describing the environment the SDK runs in,
|
|
235
|
+
for example
|
|
236
|
+
`{"airflow":"2.10.1","arch":"x86_64","databricks_sdk":"0.102.0","os":"linux","pydantic":"2.9.0","python":"3.11.4","python_impl":"CPython","requests":"2.32.3","sdk":"0.4.7"}`.
|
|
237
|
+
We collect it to know which Python, Airflow and databricks-sdk versions our customers run, so we know what to
|
|
238
|
+
support.
|
|
239
|
+
|
|
240
|
+
You can stop the SDK from sending `X-Zipher-Client-Info` (and from collecting it) by passing
|
|
241
|
+
`send_client_info=False` in the client config:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from zipher import Client, Config
|
|
245
|
+
|
|
246
|
+
client = Client(customer_id="...", config=Config(send_client_info=False))
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
or by setting `DO_NOT_TRACK=1` (`true` and `yes` also work) in the environment.
|
|
250
|
+
|
|
251
|
+
Fields of `X-Zipher-Client-Info` (a field is left out when it does not apply, e.g. Airflow is not installed, and set to `"error"` when it applies
|
|
252
|
+
but could not be read):
|
|
253
|
+
|
|
254
|
+
| Field | Contents |
|
|
255
|
+
| --- | --- |
|
|
256
|
+
| `sdk` | Version of `zipher-sdk` (`unknown` when running from a source checkout) |
|
|
257
|
+
| `python` | Python version, e.g. `3.11.4` |
|
|
258
|
+
| `python_impl` | Python implementation, e.g. `CPython` |
|
|
259
|
+
| `os` | Operating system name, e.g. `linux`, `darwin`, `windows` |
|
|
260
|
+
| `arch` | CPU architecture, e.g. `x86_64`, `arm64` |
|
|
261
|
+
| `databricks_sdk` | Installed `databricks-sdk` version |
|
|
262
|
+
| `requests` | Installed `requests` version |
|
|
263
|
+
| `pydantic` | Installed `pydantic` version |
|
|
264
|
+
| `airflow` | Installed `apache-airflow` version, when Airflow is installed |
|
|
265
|
+
| `dbr` | Databricks Runtime version (the `DATABRICKS_RUNTIME_VERSION` environment variable), when running on Databricks |
|
|
266
|
+
|
|
267
|
+
Package versions are read from the installed package metadata; the packages themselves are not imported.
|
|
268
|
+
No hostnames, usernames, workspace IDs, job contents or credentials are sent. Values are limited to 64 ASCII
|
|
269
|
+
characters.
|
|
@@ -6,6 +6,7 @@ The Zipher SDK is a Python library for interacting with Zipher's APIs.
|
|
|
6
6
|
- [Package Installation](#package-installation)
|
|
7
7
|
- [Granting access to Zipher](#providing-zipher-with-access-to-databricks-workspace)
|
|
8
8
|
- [Usage](#sdk-usage)
|
|
9
|
+
- [Telemetry](#telemetry)
|
|
9
10
|
|
|
10
11
|
## Package Installation
|
|
11
12
|
|
|
@@ -199,4 +200,47 @@ tasks_to_optimize = [
|
|
|
199
200
|
optimized_tasks = client.get_optimized_tasks(job_id="my-job-id", tasks=tasks_to_optimize)
|
|
200
201
|
|
|
201
202
|
# Continue with sending the optimized tasks to Databricks via the Databricks python SDK, Airflow operator, etc.
|
|
202
|
-
```
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Telemetry
|
|
206
|
+
|
|
207
|
+
Every request the SDK sends to Zipher's APIs carries your API key and two headers:
|
|
208
|
+
|
|
209
|
+
- `User-Agent: zipher-sdk/<version>` identifies the SDK. It is always sent and is not part of the opt-out
|
|
210
|
+
telemetry below.
|
|
211
|
+
- `X-Zipher-Client-Info` is the opt-out telemetry: a compact JSON object describing the environment the SDK runs in,
|
|
212
|
+
for example
|
|
213
|
+
`{"airflow":"2.10.1","arch":"x86_64","databricks_sdk":"0.102.0","os":"linux","pydantic":"2.9.0","python":"3.11.4","python_impl":"CPython","requests":"2.32.3","sdk":"0.4.7"}`.
|
|
214
|
+
We collect it to know which Python, Airflow and databricks-sdk versions our customers run, so we know what to
|
|
215
|
+
support.
|
|
216
|
+
|
|
217
|
+
You can stop the SDK from sending `X-Zipher-Client-Info` (and from collecting it) by passing
|
|
218
|
+
`send_client_info=False` in the client config:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
from zipher import Client, Config
|
|
222
|
+
|
|
223
|
+
client = Client(customer_id="...", config=Config(send_client_info=False))
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
or by setting `DO_NOT_TRACK=1` (`true` and `yes` also work) in the environment.
|
|
227
|
+
|
|
228
|
+
Fields of `X-Zipher-Client-Info` (a field is left out when it does not apply, e.g. Airflow is not installed, and set to `"error"` when it applies
|
|
229
|
+
but could not be read):
|
|
230
|
+
|
|
231
|
+
| Field | Contents |
|
|
232
|
+
| --- | --- |
|
|
233
|
+
| `sdk` | Version of `zipher-sdk` (`unknown` when running from a source checkout) |
|
|
234
|
+
| `python` | Python version, e.g. `3.11.4` |
|
|
235
|
+
| `python_impl` | Python implementation, e.g. `CPython` |
|
|
236
|
+
| `os` | Operating system name, e.g. `linux`, `darwin`, `windows` |
|
|
237
|
+
| `arch` | CPU architecture, e.g. `x86_64`, `arm64` |
|
|
238
|
+
| `databricks_sdk` | Installed `databricks-sdk` version |
|
|
239
|
+
| `requests` | Installed `requests` version |
|
|
240
|
+
| `pydantic` | Installed `pydantic` version |
|
|
241
|
+
| `airflow` | Installed `apache-airflow` version, when Airflow is installed |
|
|
242
|
+
| `dbr` | Databricks Runtime version (the `DATABRICKS_RUNTIME_VERSION` environment variable), when running on Databricks |
|
|
243
|
+
|
|
244
|
+
Package versions are read from the installed package metadata; the packages themselves are not imported.
|
|
245
|
+
No hostnames, usernames, workspace IDs, job contents or credentials are sent. Values are limited to 64 ASCII
|
|
246
|
+
characters.
|
|
@@ -8,7 +8,7 @@ with open('README.md', encoding='utf-8') as f:
|
|
|
8
8
|
|
|
9
9
|
setup(
|
|
10
10
|
name='zipher-sdk',
|
|
11
|
-
version='0.4.
|
|
11
|
+
version='0.4.7',
|
|
12
12
|
description='Python SDK for programmatic access to the Zipher API',
|
|
13
13
|
long_description=long_description,
|
|
14
14
|
long_description_content_type='text/markdown',
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture(autouse=True)
|
|
7
|
+
def hermetic_environment(monkeypatch):
|
|
8
|
+
"""Neither the developer's shell nor the CI environment may influence a unit test."""
|
|
9
|
+
for name in list(os.environ):
|
|
10
|
+
if name.startswith(("DATABRICKS_", "ZIPHER_")):
|
|
11
|
+
monkeypatch.delenv(name)
|
|
12
|
+
monkeypatch.delenv("DO_NOT_TRACK", raising=False)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import responses
|
|
3
|
+
|
|
4
|
+
from zipher import _version
|
|
5
|
+
from zipher.client import Client
|
|
6
|
+
from zipher.models import Config
|
|
7
|
+
|
|
8
|
+
CONFIG_URL = "https://api.zipher.cloud/default/get_clusterspec"
|
|
9
|
+
TASKS_URL = "https://api.zipher.cloud/default/get_optimized_tasks"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def client():
|
|
14
|
+
return Client(customer_id="customer-1", zipher_api_key="key-123")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestUserAgent:
|
|
18
|
+
@pytest.mark.parametrize("send_client_info", [True, False])
|
|
19
|
+
@pytest.mark.parametrize("endpoint", ["config", "tasks"])
|
|
20
|
+
@responses.activate
|
|
21
|
+
def test_is_always_sent_and_identifies_the_sdk(self, endpoint, send_client_info):
|
|
22
|
+
client = Client(
|
|
23
|
+
customer_id="customer-1",
|
|
24
|
+
zipher_api_key="key-123",
|
|
25
|
+
config=Config(send_client_info=send_client_info),
|
|
26
|
+
)
|
|
27
|
+
if endpoint == "config":
|
|
28
|
+
responses.get(CONFIG_URL, json={})
|
|
29
|
+
client.get_optimized_config(job_id="1")
|
|
30
|
+
else:
|
|
31
|
+
responses.get(TASKS_URL, json=[])
|
|
32
|
+
client.get_optimized_tasks(job_id="1", tasks=[])
|
|
33
|
+
|
|
34
|
+
headers = responses.calls[0].request.headers
|
|
35
|
+
|
|
36
|
+
assert headers["User-Agent"] == f"zipher-sdk/{_version.SDK_VERSION}"
|
|
37
|
+
|
|
38
|
+
@responses.activate
|
|
39
|
+
def test_is_sent_when_do_not_track_is_set(self, client, monkeypatch):
|
|
40
|
+
monkeypatch.setenv("DO_NOT_TRACK", "1")
|
|
41
|
+
responses.get(CONFIG_URL, json={})
|
|
42
|
+
|
|
43
|
+
client.get_optimized_config(job_id="1")
|
|
44
|
+
|
|
45
|
+
headers = responses.calls[0].request.headers
|
|
46
|
+
assert headers["User-Agent"] == f"zipher-sdk/{_version.SDK_VERSION}"
|
|
47
|
+
assert "X-Zipher-Client-Info" not in headers
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import importlib.metadata
|
|
2
|
+
import json
|
|
3
|
+
import platform
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import responses
|
|
7
|
+
|
|
8
|
+
import zipher
|
|
9
|
+
from zipher import _telemetry, _version
|
|
10
|
+
from zipher.client import Client
|
|
11
|
+
from zipher.models import Config
|
|
12
|
+
|
|
13
|
+
CONFIG_URL = "https://api.zipher.cloud/default/get_clusterspec"
|
|
14
|
+
TASKS_URL = "https://api.zipher.cloud/default/get_optimized_tasks"
|
|
15
|
+
INFO_HEADER = "X-Zipher-Client-Info"
|
|
16
|
+
|
|
17
|
+
DISTRIBUTION_FIELDS = {
|
|
18
|
+
"databricks_sdk": "databricks-sdk",
|
|
19
|
+
"requests": "requests",
|
|
20
|
+
"pydantic": "pydantic",
|
|
21
|
+
"airflow": "apache-airflow",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture(autouse=True)
|
|
26
|
+
def fresh_client_info():
|
|
27
|
+
"""The client info is cached per process: no test may see another test's result."""
|
|
28
|
+
_telemetry._collect_client_info.cache_clear()
|
|
29
|
+
yield
|
|
30
|
+
_telemetry._collect_client_info.cache_clear()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def installed(monkeypatch):
|
|
35
|
+
"""Make importlib.metadata.version answer from a dict.
|
|
36
|
+
|
|
37
|
+
Unknown distributions are missing, or, with ``passthrough``, answered by the real lookup.
|
|
38
|
+
"""
|
|
39
|
+
real_version = importlib.metadata.version
|
|
40
|
+
|
|
41
|
+
def install(versions, error=importlib.metadata.PackageNotFoundError, passthrough=False):
|
|
42
|
+
def fake_version(name):
|
|
43
|
+
if name in versions:
|
|
44
|
+
return versions[name]
|
|
45
|
+
if passthrough:
|
|
46
|
+
return real_version(name)
|
|
47
|
+
raise error(name)
|
|
48
|
+
|
|
49
|
+
monkeypatch.setattr(importlib.metadata, "version", fake_version)
|
|
50
|
+
|
|
51
|
+
return install
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.fixture
|
|
55
|
+
def metadata_lookups(monkeypatch):
|
|
56
|
+
"""Record every distribution looked up, i.e. every client-info collection.
|
|
57
|
+
|
|
58
|
+
The cache is kept cold by fresh_client_info, so a collection always shows up here.
|
|
59
|
+
"""
|
|
60
|
+
calls = []
|
|
61
|
+
real_version = importlib.metadata.version
|
|
62
|
+
|
|
63
|
+
def counting_version(name):
|
|
64
|
+
calls.append(name)
|
|
65
|
+
return real_version(name)
|
|
66
|
+
|
|
67
|
+
monkeypatch.setattr(importlib.metadata, "version", counting_version)
|
|
68
|
+
return calls
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def real_distribution_version(distribution):
|
|
72
|
+
try:
|
|
73
|
+
return importlib.metadata.version(distribution)
|
|
74
|
+
except importlib.metadata.PackageNotFoundError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def info_sent(headers=None):
|
|
79
|
+
return json.loads((headers or _telemetry.client_info_headers())[INFO_HEADER])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class TestSdkVersion:
|
|
83
|
+
def test_is_the_installed_distribution_version(self):
|
|
84
|
+
assert importlib.metadata.version("zipher-sdk") == _version.SDK_VERSION
|
|
85
|
+
assert zipher.__version__ == _version.SDK_VERSION
|
|
86
|
+
|
|
87
|
+
def test_falls_back_to_unknown_when_the_distribution_is_not_installed(self, installed):
|
|
88
|
+
installed({})
|
|
89
|
+
|
|
90
|
+
assert _version._sdk_version() == "unknown"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestClientInfoFields:
|
|
94
|
+
def test_all_fields_come_from_their_sources(self, monkeypatch, installed):
|
|
95
|
+
expected = {
|
|
96
|
+
"sdk": _version.SDK_VERSION,
|
|
97
|
+
"python": platform.python_version(),
|
|
98
|
+
"python_impl": platform.python_implementation(),
|
|
99
|
+
"os": platform.system().lower(),
|
|
100
|
+
"arch": platform.machine(),
|
|
101
|
+
"airflow": "2.10.1", # deterministic: whether Airflow is installed varies by env
|
|
102
|
+
"dbr": "15.4",
|
|
103
|
+
}
|
|
104
|
+
for field, distribution in DISTRIBUTION_FIELDS.items():
|
|
105
|
+
version = real_distribution_version(distribution)
|
|
106
|
+
if version and field != "airflow":
|
|
107
|
+
expected[field] = version
|
|
108
|
+
installed({"apache-airflow": "2.10.1"}, passthrough=True)
|
|
109
|
+
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", "15.4")
|
|
110
|
+
|
|
111
|
+
assert _telemetry.get_client_info() == expected
|
|
112
|
+
|
|
113
|
+
def test_distribution_field_is_omitted_when_not_installed_not_marked_as_error(self, installed):
|
|
114
|
+
# Airflow is the only optional distribution; the others are hard dependencies.
|
|
115
|
+
installed({"apache-airflow": "1.2.3"})
|
|
116
|
+
assert _telemetry.get_client_info()["airflow"] == "1.2.3"
|
|
117
|
+
|
|
118
|
+
_telemetry._collect_client_info.cache_clear()
|
|
119
|
+
installed({})
|
|
120
|
+
info = _telemetry.get_client_info()
|
|
121
|
+
assert "airflow" not in info
|
|
122
|
+
assert "error" not in info.values()
|
|
123
|
+
assert "python" in info # the other fields are unaffected
|
|
124
|
+
|
|
125
|
+
def test_versions_are_never_found_by_importing_the_package(self, installed, monkeypatch):
|
|
126
|
+
installed({"apache-airflow": "2.10.1"})
|
|
127
|
+
monkeypatch.setitem(__import__("sys").modules, "airflow", None) # import would raise
|
|
128
|
+
|
|
129
|
+
assert _telemetry.get_client_info()["airflow"] == "2.10.1"
|
|
130
|
+
|
|
131
|
+
def test_dbr_is_present_only_when_the_runtime_env_var_is_set(self, monkeypatch):
|
|
132
|
+
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", "14.3")
|
|
133
|
+
assert _telemetry.get_client_info()["dbr"] == "14.3"
|
|
134
|
+
|
|
135
|
+
_telemetry._collect_client_info.cache_clear()
|
|
136
|
+
monkeypatch.delenv("DATABRICKS_RUNTIME_VERSION")
|
|
137
|
+
assert "dbr" not in _telemetry.get_client_info()
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize("value", ["", " \n"])
|
|
140
|
+
def test_dbr_is_omitted_when_the_env_var_is_empty_or_blank(self, monkeypatch, value):
|
|
141
|
+
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", value)
|
|
142
|
+
|
|
143
|
+
assert "dbr" not in _telemetry.get_client_info()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestRobustness:
|
|
147
|
+
@pytest.mark.parametrize("error", [OSError, ValueError])
|
|
148
|
+
def test_unreadable_metadata_marks_only_that_field_as_error(self, monkeypatch, error):
|
|
149
|
+
real_version = importlib.metadata.version
|
|
150
|
+
|
|
151
|
+
def flaky(name):
|
|
152
|
+
if name == "pydantic":
|
|
153
|
+
raise error("broken metadata")
|
|
154
|
+
return real_version(name)
|
|
155
|
+
|
|
156
|
+
monkeypatch.setattr(importlib.metadata, "version", flaky)
|
|
157
|
+
|
|
158
|
+
info = _telemetry.get_client_info()
|
|
159
|
+
|
|
160
|
+
assert info["pydantic"] == "error"
|
|
161
|
+
assert info["requests"] == real_version("requests")
|
|
162
|
+
assert info["python"] == platform.python_version()
|
|
163
|
+
|
|
164
|
+
def test_hostile_values_still_make_a_valid_header(self, monkeypatch):
|
|
165
|
+
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", "15.4\r\nX-Injected: yesé中" + "z" * 500)
|
|
166
|
+
|
|
167
|
+
header = _telemetry.client_info_headers()[INFO_HEADER]
|
|
168
|
+
|
|
169
|
+
assert header.isascii() and "\r" not in header and "\n" not in header
|
|
170
|
+
dbr = json.loads(header)["dbr"]
|
|
171
|
+
assert "\r" not in dbr and "\n" not in dbr # JSON escaping alone would hide these
|
|
172
|
+
assert dbr.isascii() # ensure_ascii alone would hide non-ASCII
|
|
173
|
+
assert len(dbr) <= _telemetry.MAX_VALUE_LENGTH
|
|
174
|
+
assert dbr.startswith("15.4")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TestHeaders:
|
|
178
|
+
def test_client_info_is_computed_once_per_process(self, metadata_lookups):
|
|
179
|
+
_telemetry.client_info_headers()
|
|
180
|
+
first_collection = list(metadata_lookups)
|
|
181
|
+
_telemetry.client_info_headers()
|
|
182
|
+
_telemetry.get_client_info()
|
|
183
|
+
|
|
184
|
+
assert sorted(first_collection) == sorted(DISTRIBUTION_FIELDS.values())
|
|
185
|
+
assert metadata_lookups == first_collection
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.fixture
|
|
189
|
+
def client():
|
|
190
|
+
return Client(customer_id="customer-1", zipher_api_key="key-123")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def call_api(client, endpoint):
|
|
194
|
+
if endpoint == "config":
|
|
195
|
+
responses.get(CONFIG_URL, json={})
|
|
196
|
+
client.get_optimized_config(job_id="1")
|
|
197
|
+
else:
|
|
198
|
+
responses.get(TASKS_URL, json=[])
|
|
199
|
+
client.get_optimized_tasks(job_id="1", tasks=[])
|
|
200
|
+
return responses.calls[0].request.headers
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TestClientSendsHeaders:
|
|
204
|
+
@pytest.mark.parametrize("endpoint", ["config", "tasks"])
|
|
205
|
+
@responses.activate
|
|
206
|
+
def test_both_endpoints_send_client_info_and_api_key(
|
|
207
|
+
self, client, monkeypatch, installed, endpoint
|
|
208
|
+
):
|
|
209
|
+
installed({"databricks-sdk": "0.102.0", "requests": "2.32.3"})
|
|
210
|
+
monkeypatch.setenv("DATABRICKS_RUNTIME_VERSION", "15.4")
|
|
211
|
+
|
|
212
|
+
headers = call_api(client, endpoint)
|
|
213
|
+
|
|
214
|
+
assert headers["x-api-key"] == "key-123"
|
|
215
|
+
info = info_sent(headers)
|
|
216
|
+
assert info["sdk"] == _version.SDK_VERSION
|
|
217
|
+
assert info["python"] == platform.python_version()
|
|
218
|
+
assert info["os"] == platform.system().lower()
|
|
219
|
+
assert info["databricks_sdk"] == "0.102.0"
|
|
220
|
+
assert info["dbr"] == "15.4"
|
|
221
|
+
assert "airflow" not in info and "pydantic" not in info
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
OPTED_OUT = ["1", "true", "TRUE", "yes", " 1 "]
|
|
225
|
+
NOT_OPTED_OUT = [None, "", "0", "false", "no", "garbage"]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def set_do_not_track(monkeypatch, value):
|
|
229
|
+
if value is None:
|
|
230
|
+
monkeypatch.delenv("DO_NOT_TRACK", raising=False)
|
|
231
|
+
else:
|
|
232
|
+
monkeypatch.setenv("DO_NOT_TRACK", value)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TestOptOutViaConfig:
|
|
236
|
+
@pytest.mark.parametrize("endpoint", ["config", "tasks"])
|
|
237
|
+
@responses.activate
|
|
238
|
+
def test_both_endpoints_send_api_key_but_no_client_info(self, monkeypatch, endpoint):
|
|
239
|
+
client = Client(
|
|
240
|
+
customer_id="customer-1",
|
|
241
|
+
zipher_api_key="key-123",
|
|
242
|
+
config=Config(send_client_info=False),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
headers = call_api(client, endpoint)
|
|
246
|
+
|
|
247
|
+
assert headers["x-api-key"] == "key-123"
|
|
248
|
+
assert INFO_HEADER not in headers
|
|
249
|
+
|
|
250
|
+
def test_headers_omit_client_info_and_do_not_collect_it(self, metadata_lookups):
|
|
251
|
+
assert _telemetry.client_info_headers(enabled=False) == {}
|
|
252
|
+
assert metadata_lookups == []
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class TestDoNotTrack:
|
|
256
|
+
@pytest.mark.parametrize("value", OPTED_OUT + NOT_OPTED_OUT)
|
|
257
|
+
@responses.activate
|
|
258
|
+
def test_real_client_request(self, client, monkeypatch, value):
|
|
259
|
+
set_do_not_track(monkeypatch, value)
|
|
260
|
+
|
|
261
|
+
headers = call_api(client, "config")
|
|
262
|
+
|
|
263
|
+
assert headers["x-api-key"] == "key-123"
|
|
264
|
+
assert (INFO_HEADER in headers) == (value not in OPTED_OUT)
|
|
265
|
+
|
|
266
|
+
@responses.activate
|
|
267
|
+
def test_is_read_on_every_call_even_with_a_warm_cache(self, client, monkeypatch):
|
|
268
|
+
responses.get(CONFIG_URL, json={})
|
|
269
|
+
|
|
270
|
+
client.get_optimized_config(job_id="1")
|
|
271
|
+
monkeypatch.setenv("DO_NOT_TRACK", "1")
|
|
272
|
+
client.get_optimized_config(job_id="1")
|
|
273
|
+
monkeypatch.delenv("DO_NOT_TRACK")
|
|
274
|
+
client.get_optimized_config(job_id="1")
|
|
275
|
+
|
|
276
|
+
first, second, third = (call.request.headers for call in responses.calls)
|
|
277
|
+
assert INFO_HEADER in first
|
|
278
|
+
assert INFO_HEADER not in second
|
|
279
|
+
assert INFO_HEADER in third
|
|
280
|
+
|
|
281
|
+
def test_does_not_collect_client_info_when_set(self, monkeypatch, metadata_lookups):
|
|
282
|
+
monkeypatch.setenv("DO_NOT_TRACK", "1")
|
|
283
|
+
|
|
284
|
+
_telemetry.client_info_headers()
|
|
285
|
+
|
|
286
|
+
assert metadata_lookups == []
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class TestClientInfoIsolation:
|
|
290
|
+
def test_mutating_the_returned_info_does_not_change_later_requests(self):
|
|
291
|
+
info = _telemetry.get_client_info()
|
|
292
|
+
info["sdk"] = "tampered"
|
|
293
|
+
info["extra"] = "x"
|
|
294
|
+
|
|
295
|
+
sent = info_sent()
|
|
296
|
+
|
|
297
|
+
assert sent["sdk"] == _version.SDK_VERSION
|
|
298
|
+
assert "extra" not in sent
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The X-Zipher-Client-Info header: what the SDK tells Zipher about the environment it runs in.
|
|
2
|
+
|
|
3
|
+
Everything here is best effort and must never fail or slow down a request. Each field is
|
|
4
|
+
collected inside its own guard: a field that does not apply (Airflow not installed, not running
|
|
5
|
+
on Databricks) is omitted, and a field that applies but could not be read is set to "error".
|
|
6
|
+
Versions are read from installed package metadata, never by importing the package (importing
|
|
7
|
+
Airflow, for instance, is slow and has side effects).
|
|
8
|
+
|
|
9
|
+
The header is opt-out: pass ``enabled=False`` (``Config(send_client_info=False)``) or set the
|
|
10
|
+
``DO_NOT_TRACK`` environment variable to ``1``, ``true`` or ``yes``. When opted out nothing is
|
|
11
|
+
collected. The User-Agent is not part of this: it is always sent, see ``zipher.client``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import functools
|
|
16
|
+
import importlib.metadata
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import platform
|
|
20
|
+
from typing import Callable, Optional
|
|
21
|
+
|
|
22
|
+
from zipher._version import SDK_VERSION
|
|
23
|
+
|
|
24
|
+
MAX_VALUE_LENGTH = 64
|
|
25
|
+
CLIENT_INFO_HEADER = "X-Zipher-Client-Info"
|
|
26
|
+
COLLECTION_ERROR = "error"
|
|
27
|
+
_DO_NOT_TRACK_VALUES = frozenset({"1", "true", "yes"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _distribution_version(distribution: str) -> Callable[[], Optional[str]]:
|
|
31
|
+
def collect() -> Optional[str]:
|
|
32
|
+
try:
|
|
33
|
+
return importlib.metadata.version(distribution)
|
|
34
|
+
except importlib.metadata.PackageNotFoundError:
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
return collect
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _dbr() -> Optional[str]:
|
|
41
|
+
return os.environ.get("DATABRICKS_RUNTIME_VERSION")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _fields() -> dict[str, Callable[[], Optional[str]]]:
|
|
45
|
+
return {
|
|
46
|
+
"sdk": lambda: SDK_VERSION,
|
|
47
|
+
"python": platform.python_version,
|
|
48
|
+
"python_impl": platform.python_implementation,
|
|
49
|
+
"os": lambda: platform.system().lower(),
|
|
50
|
+
"arch": platform.machine,
|
|
51
|
+
"databricks_sdk": _distribution_version("databricks-sdk"),
|
|
52
|
+
"requests": _distribution_version("requests"),
|
|
53
|
+
"pydantic": _distribution_version("pydantic"),
|
|
54
|
+
"airflow": _distribution_version("apache-airflow"),
|
|
55
|
+
"dbr": _dbr,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _clean(value) -> str:
|
|
60
|
+
"""Make a value safe for a header: ASCII, single line, bounded length."""
|
|
61
|
+
text = str(value).replace("\r", " ").replace("\n", " ").strip()
|
|
62
|
+
return text.encode("ascii", "replace").decode("ascii")[:MAX_VALUE_LENGTH]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@functools.cache
|
|
66
|
+
def _collect_client_info() -> dict[str, str]:
|
|
67
|
+
info: dict[str, str] = {}
|
|
68
|
+
try:
|
|
69
|
+
for name, collect in _fields().items():
|
|
70
|
+
try:
|
|
71
|
+
raw = collect()
|
|
72
|
+
value = None if raw is None else _clean(raw)
|
|
73
|
+
except Exception: # noqa: BLE001 - only this field is marked as failed
|
|
74
|
+
value = COLLECTION_ERROR
|
|
75
|
+
if value:
|
|
76
|
+
info[name] = value
|
|
77
|
+
except Exception: # noqa: BLE001, S110 - keep whatever was collected
|
|
78
|
+
pass
|
|
79
|
+
return info
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_client_info() -> dict[str, str]:
|
|
83
|
+
"""The client-info fields, computed once per process: omitted if n/a, "error" if unreadable.
|
|
84
|
+
|
|
85
|
+
Returns a copy, so a caller can't alter what later requests send.
|
|
86
|
+
"""
|
|
87
|
+
return dict(_collect_client_info())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _do_not_track() -> bool:
|
|
91
|
+
"""Whether DO_NOT_TRACK is set to 1/true/yes; read on every call, never cached."""
|
|
92
|
+
return os.environ.get("DO_NOT_TRACK", "").strip().lower() in _DO_NOT_TRACK_VALUES
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def client_info_headers(*, enabled: bool = True) -> dict[str, str]:
|
|
96
|
+
"""The X-Zipher-Client-Info header, or ``{}``; never raises.
|
|
97
|
+
|
|
98
|
+
Nothing is collected, and ``{}`` is returned, when ``enabled`` is False or the DO_NOT_TRACK
|
|
99
|
+
env var is set.
|
|
100
|
+
"""
|
|
101
|
+
with contextlib.suppress(Exception): # on failure send no client info
|
|
102
|
+
# If the DO_NOT_TRACK check itself fails we raise here too, i.e. fail closed.
|
|
103
|
+
if enabled and not _do_not_track():
|
|
104
|
+
return {
|
|
105
|
+
CLIENT_INFO_HEADER: json.dumps(
|
|
106
|
+
get_client_info(), separators=(",", ":"), sort_keys=True, ensure_ascii=True
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
return {}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The SDK's own version."""
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _sdk_version() -> str:
|
|
7
|
+
try:
|
|
8
|
+
return importlib.metadata.version("zipher-sdk")
|
|
9
|
+
except Exception: # noqa: BLE001 - the version must never fail an import
|
|
10
|
+
return "unknown"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SDK_VERSION = _sdk_version()
|
|
File without changes
|
|
@@ -4,9 +4,14 @@ from typing import Dict, Optional
|
|
|
4
4
|
|
|
5
5
|
import requests
|
|
6
6
|
from databricks.sdk.service.compute import ClusterSpec
|
|
7
|
+
from zipher._telemetry import client_info_headers
|
|
8
|
+
from zipher._version import SDK_VERSION
|
|
7
9
|
from zipher.models import ConfFetcherRequest, GetOptimizedTasksRequest, Config
|
|
8
10
|
from zipher.exceptions import MissingAPIKeyError
|
|
9
11
|
|
|
12
|
+
USER_AGENT = f'zipher-sdk/{SDK_VERSION}'
|
|
13
|
+
|
|
14
|
+
|
|
10
15
|
class Client:
|
|
11
16
|
def __init__(self, customer_id: str, zipher_api_key: str = '', config: Optional[Config] = None):
|
|
12
17
|
self.config = config or Config()
|
|
@@ -19,7 +24,9 @@ class Client:
|
|
|
19
24
|
|
|
20
25
|
def _call_conf_fetcher_api(self, params: ConfFetcherRequest):
|
|
21
26
|
headers = {
|
|
22
|
-
'x-api-key': self.zipher_api_key
|
|
27
|
+
'x-api-key': self.zipher_api_key,
|
|
28
|
+
'User-Agent': USER_AGENT,
|
|
29
|
+
**client_info_headers(enabled=self.config.send_client_info),
|
|
23
30
|
}
|
|
24
31
|
response = requests.get(
|
|
25
32
|
url=self.config.zipher_config_fetcher_api_endpoint,
|
|
@@ -33,7 +40,9 @@ class Client:
|
|
|
33
40
|
|
|
34
41
|
def _call_get_optimized_tasks_api(self, params: GetOptimizedTasksRequest):
|
|
35
42
|
headers = {
|
|
36
|
-
'x-api-key': self.zipher_api_key
|
|
43
|
+
'x-api-key': self.zipher_api_key,
|
|
44
|
+
'User-Agent': USER_AGENT,
|
|
45
|
+
**client_info_headers(enabled=self.config.send_client_info),
|
|
37
46
|
}
|
|
38
47
|
response = requests.get(
|
|
39
48
|
url=self.config.zipher_get_optimized_tasks_api_endpoint,
|
|
@@ -28,6 +28,9 @@ class Config(BaseModel, extra='forbid', use_enum_values=True):
|
|
|
28
28
|
zipher_config_fetcher_api_endpoint: str = 'https://api.zipher.cloud/default/get_clusterspec'
|
|
29
29
|
zipher_get_optimized_tasks_api_endpoint: str = 'https://api.zipher.cloud/default/get_optimized_tasks'
|
|
30
30
|
|
|
31
|
+
# Set False to stop sending the X-Zipher-Client-Info header; User-Agent is always sent.
|
|
32
|
+
send_client_info: bool = True
|
|
33
|
+
|
|
31
34
|
class GetOptimizedTasksRequest(BaseModel):
|
|
32
35
|
customer_id: str
|
|
33
36
|
job_id: str
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zipher-sdk
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.7
|
|
4
4
|
Summary: Python SDK for programmatic access to the Zipher API
|
|
5
5
|
Author: Zipher Inc.
|
|
6
6
|
Author-email: yoav@zipher.cloud
|
|
@@ -29,6 +29,7 @@ The Zipher SDK is a Python library for interacting with Zipher's APIs.
|
|
|
29
29
|
- [Package Installation](#package-installation)
|
|
30
30
|
- [Granting access to Zipher](#providing-zipher-with-access-to-databricks-workspace)
|
|
31
31
|
- [Usage](#sdk-usage)
|
|
32
|
+
- [Telemetry](#telemetry)
|
|
32
33
|
|
|
33
34
|
## Package Installation
|
|
34
35
|
|
|
@@ -223,3 +224,46 @@ optimized_tasks = client.get_optimized_tasks(job_id="my-job-id", tasks=tasks_to_
|
|
|
223
224
|
|
|
224
225
|
# Continue with sending the optimized tasks to Databricks via the Databricks python SDK, Airflow operator, etc.
|
|
225
226
|
```
|
|
227
|
+
|
|
228
|
+
## Telemetry
|
|
229
|
+
|
|
230
|
+
Every request the SDK sends to Zipher's APIs carries your API key and two headers:
|
|
231
|
+
|
|
232
|
+
- `User-Agent: zipher-sdk/<version>` identifies the SDK. It is always sent and is not part of the opt-out
|
|
233
|
+
telemetry below.
|
|
234
|
+
- `X-Zipher-Client-Info` is the opt-out telemetry: a compact JSON object describing the environment the SDK runs in,
|
|
235
|
+
for example
|
|
236
|
+
`{"airflow":"2.10.1","arch":"x86_64","databricks_sdk":"0.102.0","os":"linux","pydantic":"2.9.0","python":"3.11.4","python_impl":"CPython","requests":"2.32.3","sdk":"0.4.7"}`.
|
|
237
|
+
We collect it to know which Python, Airflow and databricks-sdk versions our customers run, so we know what to
|
|
238
|
+
support.
|
|
239
|
+
|
|
240
|
+
You can stop the SDK from sending `X-Zipher-Client-Info` (and from collecting it) by passing
|
|
241
|
+
`send_client_info=False` in the client config:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
from zipher import Client, Config
|
|
245
|
+
|
|
246
|
+
client = Client(customer_id="...", config=Config(send_client_info=False))
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
or by setting `DO_NOT_TRACK=1` (`true` and `yes` also work) in the environment.
|
|
250
|
+
|
|
251
|
+
Fields of `X-Zipher-Client-Info` (a field is left out when it does not apply, e.g. Airflow is not installed, and set to `"error"` when it applies
|
|
252
|
+
but could not be read):
|
|
253
|
+
|
|
254
|
+
| Field | Contents |
|
|
255
|
+
| --- | --- |
|
|
256
|
+
| `sdk` | Version of `zipher-sdk` (`unknown` when running from a source checkout) |
|
|
257
|
+
| `python` | Python version, e.g. `3.11.4` |
|
|
258
|
+
| `python_impl` | Python implementation, e.g. `CPython` |
|
|
259
|
+
| `os` | Operating system name, e.g. `linux`, `darwin`, `windows` |
|
|
260
|
+
| `arch` | CPU architecture, e.g. `x86_64`, `arm64` |
|
|
261
|
+
| `databricks_sdk` | Installed `databricks-sdk` version |
|
|
262
|
+
| `requests` | Installed `requests` version |
|
|
263
|
+
| `pydantic` | Installed `pydantic` version |
|
|
264
|
+
| `airflow` | Installed `apache-airflow` version, when Airflow is installed |
|
|
265
|
+
| `dbr` | Databricks Runtime version (the `DATABRICKS_RUNTIME_VERSION` environment variable), when running on Databricks |
|
|
266
|
+
|
|
267
|
+
Package versions are read from the installed package metadata; the packages themselves are not imported.
|
|
268
|
+
No hostnames, usernames, workspace IDs, job contents or credentials are sent. Values are limited to 64 ASCII
|
|
269
|
+
characters.
|
|
@@ -6,7 +6,13 @@ tests/test_client.py
|
|
|
6
6
|
tests/test_acl/__init__.py
|
|
7
7
|
tests/test_acl/sample_job.py
|
|
8
8
|
tests/test_acl/test_acl.py
|
|
9
|
+
tests/unit/__init__.py
|
|
10
|
+
tests/unit/conftest.py
|
|
11
|
+
tests/unit/test_client.py
|
|
12
|
+
tests/unit/test_telemetry.py
|
|
9
13
|
zipher/__init__.py
|
|
14
|
+
zipher/_telemetry.py
|
|
15
|
+
zipher/_version.py
|
|
10
16
|
zipher/cli.py
|
|
11
17
|
zipher/client.py
|
|
12
18
|
zipher/exceptions.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|