datamasque-python 1.1.0__tar.gz → 1.1.2__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 (77) hide show
  1. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/HISTORY.rst +15 -0
  2. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/PKG-INFO +1 -1
  3. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/discovery.py +20 -4
  4. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/discovery.py +3 -3
  5. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/status.py +15 -1
  6. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/runs.py +9 -2
  7. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/pyproject.toml +1 -1
  8. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/setup.cfg +1 -1
  9. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_discovery.py +92 -0
  10. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/uv.lock +1 -1
  11. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/.editorconfig +0 -0
  12. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/.github/workflows/ci.yml +0 -0
  13. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/.github/workflows/release-testpypi.yml +0 -0
  14. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/.github/workflows/release.yml +0 -0
  15. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/.gitignore +0 -0
  16. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/.readthedocs.yaml +0 -0
  17. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/CONTRIBUTING.rst +0 -0
  18. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/LICENSE +0 -0
  19. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/MANIFEST.in +0 -0
  20. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/Makefile +0 -0
  21. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/NOTICE +0 -0
  22. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/README.rst +0 -0
  23. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/__init__.py +0 -0
  24. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/base.py +0 -0
  25. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/connections.py +0 -0
  26. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/discovery_configs.py +0 -0
  27. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/dmclient.py +0 -0
  28. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/exceptions.py +0 -0
  29. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/files.py +0 -0
  30. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/ifm.py +0 -0
  31. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/license.py +0 -0
  32. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/__init__.py +0 -0
  33. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/connection.py +0 -0
  34. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/data_selection.py +0 -0
  35. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/discovery_config.py +0 -0
  36. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/dm_instance.py +0 -0
  37. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/files.py +0 -0
  38. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/git.py +0 -0
  39. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/ifm.py +0 -0
  40. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/license.py +0 -0
  41. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/pagination.py +0 -0
  42. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/ruleset.py +0 -0
  43. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/ruleset_library.py +0 -0
  44. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/runs.py +0 -0
  45. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/models/user.py +0 -0
  46. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/py.typed +0 -0
  47. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/ruleset_libraries.py +0 -0
  48. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/rulesets.py +0 -0
  49. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/settings.py +0 -0
  50. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/datamasque/client/users.py +0 -0
  51. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/docs/Makefile +0 -0
  52. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/docs/client.models.rst +0 -0
  53. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/docs/client.rst +0 -0
  54. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/docs/conf.py +0 -0
  55. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/docs/contributing.rst +0 -0
  56. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/docs/history.rst +0 -0
  57. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/docs/index.rst +0 -0
  58. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/docs/installation.rst +0 -0
  59. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/docs/make.bat +0 -0
  60. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/docs/modules.rst +0 -0
  61. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/docs/readme.rst +0 -0
  62. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/docs/usage.rst +0 -0
  63. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/__init__.py +0 -0
  64. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/conftest.py +0 -0
  65. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/helpers.py +0 -0
  66. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_base.py +0 -0
  67. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_connections.py +0 -0
  68. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_discovery_configs.py +0 -0
  69. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_files.py +0 -0
  70. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_ifm.py +0 -0
  71. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_license.py +0 -0
  72. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_pagination.py +0 -0
  73. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_ruleset_library.py +0 -0
  74. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_rulesets.py +0 -0
  75. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_runs.py +0 -0
  76. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_settings.py +0 -0
  77. {datamasque_python-1.1.0 → datamasque_python-1.1.2}/tests/test_users.py +0 -0
@@ -2,6 +2,21 @@
2
2
  History
3
3
  =======
4
4
 
5
+ 1.1.2 (2026-06-26)
6
+ ------------------
7
+
8
+ * Completed wiring of the ``finished_with_warnings`` status for ``AsyncRulesetGenerationTaskStatus``.
9
+ * Added the ``cancelled`` status to ``AsyncRulesetGenerationTaskStatus``.
10
+
11
+ 1.1.1 (2026-06-25)
12
+ ------------------
13
+
14
+ * Made ``DiscoveryMatch.label`` optional (it is absent for non-sensitive/ignore matches).
15
+ * Added the ``finished_with_warnings`` status to ``AsyncRulesetGenerationTaskStatus``.
16
+ * ``get_db_discovery_result_report`` may now return ``bytes`` (a zip)
17
+ when the server splits a large DB-discovery report,
18
+ and ruleset generation from CSV now detects and forwards zip uploads.
19
+
5
20
  1.1.0 (2026-06-24)
6
21
  ------------------
7
22
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datamasque-python
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: Official Python client for the DataMasque data-masking API.
5
5
  Project-URL: Homepage, https://datamasque.com/
6
6
  Project-URL: Documentation, https://datamasque-python.readthedocs.io/
@@ -98,6 +98,10 @@ class DiscoveryClient(BaseClient):
98
98
  - A text file handle (e.g. `open(path)`)
99
99
  - A binary file handle (e.g. `open(path, 'rb')`)
100
100
 
101
+ If the content is a zip (for example a split report from `get_db_discovery_result_report()`),
102
+ it is detected by its magic bytes and uploaded as a zip;
103
+ otherwise it is uploaded as CSV.
104
+
101
105
  Generation runs asynchronously on the server.
102
106
  Poll `get_async_ruleset_generation_task_status` until it returns
103
107
  `AsyncRulesetGenerationTaskStatus.finished`,
@@ -114,14 +118,22 @@ class DiscoveryClient(BaseClient):
114
118
  else:
115
119
  content = csv_content
116
120
 
121
+ is_zip = False
122
+ if content.seekable():
123
+ is_zip = content.read(4) == b"PK\x03\x04"
124
+ content.seek(0)
125
+ filename = "ruleset.zip" if is_zip else "ruleset.csv"
126
+ content_type = "application/zip" if is_zip else "text/csv"
127
+
117
128
  files = [
118
129
  UploadFile(
119
130
  field_name="csv_or_zip_file",
120
- filename="ruleset.csv",
131
+ filename=filename,
121
132
  content=content,
122
- content_type="text/csv",
133
+ content_type=content_type,
123
134
  ),
124
135
  ]
136
+
125
137
  self.make_request(
126
138
  method="POST",
127
139
  path=f"/api/async-generate-ruleset/{connection_id}/from-csv/",
@@ -154,7 +166,7 @@ class DiscoveryClient(BaseClient):
154
166
  returns a list containing one ruleset
155
167
 
156
168
  Raises `AsyncRulesetGenerationInProgressError` if the task hasn't finished yet,
157
- and `DataMasqueException` if it failed.
169
+ and `DataMasqueException` if it failed or was cancelled.
158
170
 
159
171
  Note that the ruleset(s) have autogenerated names, which you may want to customize before uploading.
160
172
  """
@@ -164,7 +176,11 @@ class DiscoveryClient(BaseClient):
164
176
  logger.error("Ruleset generation failed for connection: %s", connection_id)
165
177
  raise DataMasqueException(f"Ruleset generation failed for connection: {connection_id}")
166
178
 
167
- if status is not AsyncRulesetGenerationTaskStatus.finished:
179
+ if status is AsyncRulesetGenerationTaskStatus.cancelled:
180
+ logger.error("Ruleset generation was cancelled for connection: %s", connection_id)
181
+ raise DataMasqueException(f"Ruleset generation was cancelled for connection: {connection_id}")
182
+
183
+ if not status.is_finished:
168
184
  logger.error(
169
185
  "Ruleset generation is still in progress for connection: %s. Status: `%s`",
170
186
  connection_id,
@@ -252,7 +252,7 @@ class DiscoveryMatch(BaseModel):
252
252
 
253
253
  model_config = ConfigDict(extra="allow")
254
254
 
255
- label: str
255
+ label: Optional[str] = None
256
256
  categories: list[str]
257
257
  flagged_by: str
258
258
  description: str
@@ -343,8 +343,8 @@ class FileDiscoveryMatch(BaseModel):
343
343
 
344
344
  flagged_by: str
345
345
  description: str
346
- label: Optional[str] = None # Omitted for non-sensitive and ignored matches.
347
- categories: Optional[list[str]] = None # Omitted for ignored matches.
346
+ label: Optional[str] = None # Omitted for non-sensitive matches.
347
+ categories: Optional[list[str]] = None
348
348
  hit_ratio: Optional[int] = None # None for metadata matches, percentage 0-100 for IDD matches.
349
349
 
350
350
 
@@ -60,18 +60,32 @@ class AsyncRulesetGenerationTaskStatus(enum.Enum):
60
60
  """List of statuses of async ruleset generation tasks."""
61
61
 
62
62
  finished = "finished"
63
+ finished_with_warnings = "finished_with_warnings"
63
64
  failed = "failed"
64
65
  running = "running"
65
66
  queued = "queued"
67
+ cancelled = "cancelled"
66
68
 
67
69
  @classmethod
68
70
  def get_final_states(cls) -> set["AsyncRulesetGenerationTaskStatus"]:
69
71
  """Returns the list of final statuses, i.e. the ruleset generation has completed, successfully or otherwise."""
70
72
 
71
- return {cls.finished, cls.failed}
73
+ return {cls.finished, cls.finished_with_warnings, cls.failed, cls.cancelled}
74
+
75
+ @classmethod
76
+ def get_finished_states(cls) -> set["AsyncRulesetGenerationTaskStatus"]:
77
+ """Returns the list of statuses that indicate the ruleset generation completed successfully."""
78
+
79
+ return {cls.finished, cls.finished_with_warnings}
72
80
 
73
81
  @property
74
82
  def is_in_final_state(self) -> bool:
75
83
  """Returns True if this status is a final status."""
76
84
 
77
85
  return self in self.get_final_states()
86
+
87
+ @property
88
+ def is_finished(self) -> bool:
89
+ """Returns True if this status is a finished status."""
90
+
91
+ return self in self.get_finished_states()
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
  import re
3
+ from typing import Union
3
4
 
4
5
  from datamasque.client.base import BaseClient
5
6
  from datamasque.client.exceptions import (
@@ -43,9 +44,12 @@ class RunClient(BaseClient):
43
44
  response = self.make_request("GET", f"api/runs/{run_id}/run-report/")
44
45
  return response.text
45
46
 
46
- def get_db_discovery_result_report(self, run_id: RunId, include_selection_column: bool = True) -> str:
47
+ def get_db_discovery_result_report(self, run_id: RunId, include_selection_column: bool = True) -> Union[str, bytes]:
47
48
  """
48
- Returns the database-discovery result report for the specified run as CSV.
49
+ Returns the database-discovery result report for the specified run.
50
+
51
+ Returns CSV text (`str`),
52
+ or a zip of numbered CSV parts as `bytes` when the server splits a large report.
49
53
 
50
54
  When `include_selection_column` is true (the default),
51
55
  the CSV includes a `selected` column suitable for feeding back into ruleset generation.
@@ -54,6 +58,9 @@ class RunClient(BaseClient):
54
58
  url = f"api/runs/{run_id}/db-discovery-results/report/"
55
59
  params = None if include_selection_column else {"include_selection_column": "false"}
56
60
  response = self.make_request("GET", url, params=params)
61
+
62
+ if response.headers.get("Content-Type", "").startswith("application/zip"):
63
+ return response.content
57
64
  return response.text
58
65
 
59
66
  def get_unfinished_runs(self) -> dict[str, UnfinishedRun]:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datamasque-python"
3
- version = "1.1.0"
3
+ version = "1.1.2"
4
4
  description = "Official Python client for the DataMasque data-masking API."
5
5
  authors = [
6
6
  { name = "DataMasque Ltd" },
@@ -1,5 +1,5 @@
1
1
  [bumpversion]
2
- current_version = 1.1.0
2
+ current_version = 1.1.2
3
3
  commit = True
4
4
  tag = True
5
5
 
@@ -108,6 +108,17 @@ def test_get_db_discovery_result_report(client):
108
108
  assert result == "db discovery report without selection column"
109
109
 
110
110
 
111
+ def test_get_db_discovery_result_report_returns_zip_bytes_when_split(client):
112
+ run_id = RunId(1)
113
+ zip_bytes = b"PK\x03\x04 split report zip bytes"
114
+ with requests_mock.Mocker() as m:
115
+ url = f"http://test-server/api/runs/{run_id}/db-discovery-results/report/"
116
+ m.get(url, content=zip_bytes, headers={"Content-Type": "application/zip"}, status_code=200)
117
+ result = client.get_db_discovery_result_report(run_id)
118
+ assert result == zip_bytes
119
+ assert isinstance(result, bytes)
120
+
121
+
111
122
  def test_poll_async_ruleset_generation(client):
112
123
  connection_id = ConnectionId("1")
113
124
  with requests_mock.Mocker() as m:
@@ -193,6 +204,47 @@ def test_get_generated_rulesets_success(client):
193
204
  assert rulesets[1].yaml == yaml_content_2.decode("utf-8")
194
205
 
195
206
 
207
+ def test_get_generated_rulesets_finished_with_warnings_success(client):
208
+ """A task that finishes with warnings is still successful: its rulesets are returned, not treated as in-progress."""
209
+ connection_id = ConnectionId("1")
210
+ yaml_content = b"""
211
+ version: "1.0"
212
+ tasks:
213
+ - type: mask_table
214
+ table: table1
215
+ key: id
216
+ rules:
217
+ - column: col1
218
+ masks:
219
+ - type: do_nothing
220
+ """
221
+
222
+ with requests_mock.Mocker() as m:
223
+ m.get(
224
+ f"http://test-server/api/async-generate-ruleset/{connection_id}/",
225
+ json={"status": "finished_with_warnings"},
226
+ status_code=200,
227
+ )
228
+
229
+ zip_buffer = BytesIO()
230
+ with zipfile.ZipFile(zip_buffer, "w") as zip_file:
231
+ zip_file.writestr("ruleset1.yml", yaml_content.decode("utf-8"))
232
+ zip_buffer.seek(0)
233
+
234
+ m.get(
235
+ f"http://test-server/api/async-generate-ruleset/{connection_id}/download-rulesets/",
236
+ content=zip_buffer.getvalue(),
237
+ headers={"Content-Disposition": 'attachment; filename="rulesets.zip"'},
238
+ status_code=200,
239
+ )
240
+
241
+ rulesets = client.get_generated_rulesets(connection_id)
242
+
243
+ assert len(rulesets) == 1
244
+ assert rulesets[0].name == "ruleset1"
245
+ assert rulesets[0].yaml == yaml_content.decode("utf-8")
246
+
247
+
196
248
  def test_get_generated_rulesets_empty_archive_raises(client):
197
249
  """A finished task whose download archive contains no ruleset files raises a clear error."""
198
250
  connection_id = ConnectionId("1")
@@ -279,6 +331,21 @@ def test_get_generated_rulesets_failed(client):
279
331
  client.get_generated_rulesets(connection_id)
280
332
 
281
333
 
334
+ def test_get_generated_rulesets_cancelled(client):
335
+ """A cancelled task is terminal, so it raises a non-retryable DataMasqueException, not the in-progress error."""
336
+ connection_id = ConnectionId("1")
337
+
338
+ with requests_mock.Mocker() as m:
339
+ m.get(
340
+ f"http://test-server/api/async-generate-ruleset/{connection_id}/",
341
+ json={"status": "cancelled"},
342
+ status_code=200,
343
+ )
344
+
345
+ with pytest.raises(DataMasqueException, match="Ruleset generation was cancelled for connection"):
346
+ client.get_generated_rulesets(connection_id)
347
+
348
+
282
349
  def test_get_generated_rulesets_in_progress(client):
283
350
  connection_id = ConnectionId("1")
284
351
 
@@ -463,6 +530,31 @@ def test_start_async_ruleset_generation_from_csv_success(client, csv_content):
463
530
  assert form_data["csv_or_zip_file"]["content"] == b"schema,table,column,selected\npublic,users,email,true"
464
531
 
465
532
 
533
+ @pytest.mark.parametrize(
534
+ "zip_content",
535
+ [
536
+ b"PK\x03\x04 zipped discovery report",
537
+ BytesIO(b"PK\x03\x04 zipped discovery report"),
538
+ ],
539
+ ids=["bytes", "BytesIO"],
540
+ )
541
+ def test_start_async_ruleset_generation_from_csv_uploads_zip_as_zip(client, zip_content):
542
+ """A split report is uploaded with a .zip filename and zip content-type, whether passed as bytes or a binary stream."""
543
+ connection_id = ConnectionId("1")
544
+
545
+ with requests_mock.Mocker() as m:
546
+ m.post(
547
+ f"http://test-server/api/async-generate-ruleset/{connection_id}/from-csv/",
548
+ status_code=201,
549
+ )
550
+ client.start_async_ruleset_generation_from_csv(connection_id, zip_content)
551
+
552
+ form_data = parse_multipart_form(m.last_request)
553
+ assert form_data["csv_or_zip_file"]["filename"] == "ruleset.zip"
554
+ assert form_data["csv_or_zip_file"]["content_type"] == "application/zip"
555
+ assert form_data["csv_or_zip_file"]["content"] == b"PK\x03\x04 zipped discovery report"
556
+
557
+
466
558
  def test_start_async_ruleset_generation_from_csv_with_target_size(client):
467
559
  """Test async ruleset generation from CSV with target_size_bytes parameter."""
468
560
  connection_id = ConnectionId("1")
@@ -428,7 +428,7 @@ toml = [
428
428
 
429
429
  [[package]]
430
430
  name = "datamasque-python"
431
- version = "1.1.0"
431
+ version = "1.1.2"
432
432
  source = { editable = "." }
433
433
  dependencies = [
434
434
  { name = "pydantic" },