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.
Files changed (32) hide show
  1. {zipher_sdk-0.4.6/zipher_sdk.egg-info → zipher_sdk-0.4.7}/PKG-INFO +45 -1
  2. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/README.md +45 -1
  3. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/setup.py +1 -1
  4. zipher_sdk-0.4.7/tests/unit/conftest.py +12 -0
  5. zipher_sdk-0.4.7/tests/unit/test_client.py +47 -0
  6. zipher_sdk-0.4.7/tests/unit/test_telemetry.py +298 -0
  7. zipher_sdk-0.4.7/zipher/__init__.py +5 -0
  8. zipher_sdk-0.4.7/zipher/_telemetry.py +109 -0
  9. zipher_sdk-0.4.7/zipher/_version.py +13 -0
  10. zipher_sdk-0.4.7/zipher/acl/__init__.py +0 -0
  11. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/client.py +11 -2
  12. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/models.py +3 -0
  13. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7/zipher_sdk.egg-info}/PKG-INFO +45 -1
  14. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher_sdk.egg-info/SOURCES.txt +6 -0
  15. zipher_sdk-0.4.6/zipher/__init__.py +0 -2
  16. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/LICENSE +0 -0
  17. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/setup.cfg +0 -0
  18. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/tests/__init__.py +0 -0
  19. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/tests/test_acl/__init__.py +0 -0
  20. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/tests/test_acl/sample_job.py +0 -0
  21. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/tests/test_acl/test_acl.py +0 -0
  22. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/tests/test_client.py +0 -0
  23. {zipher_sdk-0.4.6/zipher/acl → zipher_sdk-0.4.7/tests/unit}/__init__.py +0 -0
  24. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/acl/models.py +0 -0
  25. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/acl/zipher_installer.py +0 -0
  26. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/cli.py +0 -0
  27. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/exceptions.py +0 -0
  28. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher/utils.py +0 -0
  29. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher_sdk.egg-info/dependency_links.txt +0 -0
  30. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher_sdk.egg-info/entry_points.txt +0 -0
  31. {zipher_sdk-0.4.6 → zipher_sdk-0.4.7}/zipher_sdk.egg-info/requires.txt +0 -0
  32. {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.6
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.6',
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,5 @@
1
+ from ._version import SDK_VERSION
2
+ from .client import Client
3
+ from .models import Config
4
+
5
+ __version__ = SDK_VERSION
@@ -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.6
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
@@ -1,2 +0,0 @@
1
- from .client import Client
2
- from .models import Config
File without changes
File without changes
File without changes
File without changes
File without changes