datamasque-python 1.3.0__tar.gz → 1.4.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.
Files changed (90) hide show
  1. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/.github/workflows/release.yml +24 -0
  2. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/CONTRIBUTING.rst +6 -0
  3. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/HISTORY.rst +32 -0
  4. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/PKG-INFO +1 -1
  5. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/RELEASING.rst +4 -25
  6. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/__init__.py +4 -0
  7. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/connection.py +27 -0
  8. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/discovery.py +9 -0
  9. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/ruleset.py +7 -0
  10. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/ruleset_library.py +7 -0
  11. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/ruleset_libraries.py +4 -1
  12. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/rulesets.py +2 -1
  13. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/pyproject.toml +1 -1
  14. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_connections.py +198 -0
  15. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_discovery.py +17 -0
  16. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_ruleset_library.py +35 -1
  17. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_rulesets.py +84 -1
  18. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/uv.lock +1 -1
  19. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/.editorconfig +0 -0
  20. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/.github/workflows/ci.yml +0 -0
  21. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/.github/workflows/release-testpypi.yml +0 -0
  22. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/.gitignore +0 -0
  23. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/.readthedocs.yaml +0 -0
  24. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/LICENSE +0 -0
  25. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/MANIFEST.in +0 -0
  26. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/Makefile +0 -0
  27. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/NOTICE +0 -0
  28. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/README.rst +0 -0
  29. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/base.py +0 -0
  30. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/connections.py +0 -0
  31. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/discovery.py +0 -0
  32. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/discovery_config_libraries.py +0 -0
  33. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/discovery_configs.py +0 -0
  34. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/dmclient.py +0 -0
  35. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/exceptions.py +0 -0
  36. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/files.py +0 -0
  37. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/ifm.py +0 -0
  38. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/license.py +0 -0
  39. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/__init__.py +0 -0
  40. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/data_selection.py +0 -0
  41. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/discovery_config.py +0 -0
  42. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/discovery_config_library.py +0 -0
  43. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/dm_instance.py +0 -0
  44. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/files.py +0 -0
  45. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/git.py +0 -0
  46. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/ifm.py +0 -0
  47. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/license.py +0 -0
  48. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/pagination.py +0 -0
  49. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/rg_config.py +0 -0
  50. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/runs.py +0 -0
  51. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/safe_data_preview.py +0 -0
  52. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/status.py +0 -0
  53. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/table_reference.py +0 -0
  54. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/models/user.py +0 -0
  55. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/py.typed +0 -0
  56. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/rg_configs.py +0 -0
  57. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/runs.py +0 -0
  58. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/settings.py +0 -0
  59. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/spcs.py +0 -0
  60. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/table_references.py +0 -0
  61. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/datamasque/client/users.py +0 -0
  62. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/docs/Makefile +0 -0
  63. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/docs/client.models.rst +0 -0
  64. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/docs/client.rst +0 -0
  65. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/docs/conf.py +0 -0
  66. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/docs/contributing.rst +0 -0
  67. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/docs/history.rst +0 -0
  68. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/docs/index.rst +0 -0
  69. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/docs/installation.rst +0 -0
  70. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/docs/make.bat +0 -0
  71. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/docs/modules.rst +0 -0
  72. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/docs/readme.rst +0 -0
  73. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/docs/usage.rst +0 -0
  74. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/__init__.py +0 -0
  75. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/conftest.py +0 -0
  76. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/helpers.py +0 -0
  77. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_base.py +0 -0
  78. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_discovery_config_libraries.py +0 -0
  79. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_discovery_configs.py +0 -0
  80. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_files.py +0 -0
  81. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_ifm.py +0 -0
  82. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_license.py +0 -0
  83. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_pagination.py +0 -0
  84. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_rg_configs.py +0 -0
  85. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_runs.py +0 -0
  86. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_safe_data_preview.py +0 -0
  87. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_settings.py +0 -0
  88. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_spcs.py +0 -0
  89. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_table_references.py +0 -0
  90. {datamasque_python-1.3.0 → datamasque_python-1.4.0}/tests/test_users.py +0 -0
@@ -60,3 +60,27 @@ jobs:
60
60
 
61
61
  - name: Publish to PyPI
62
62
  uses: pypa/gh-action-pypi-publish@release/v1
63
+
64
+ github-release:
65
+ name: Publish GitHub Release
66
+ needs: publish
67
+ runs-on: ubuntu-latest
68
+ permissions:
69
+ contents: write
70
+ steps:
71
+ - name: Download distributions
72
+ uses: actions/download-artifact@v4
73
+ with:
74
+ name: dist
75
+ path: dist/
76
+
77
+ - name: Create GitHub Release
78
+ env:
79
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
80
+ run: |
81
+ gh release create "${GITHUB_REF_NAME}" \
82
+ --repo "${GITHUB_REPOSITORY}" \
83
+ --title "${GITHUB_REF_NAME}" \
84
+ --verify-tag \
85
+ --notes "See [HISTORY.rst](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_REF_NAME}/HISTORY.rst) for changelog." \
86
+ dist/*
@@ -149,6 +149,12 @@ Pull requests
149
149
  5. Open a PR against ``main`` and describe what the change does and why.
150
150
  6. The maintainers will review and either merge, request changes, or close with an explanation.
151
151
 
152
+ Changelog
153
+ =========
154
+
155
+ Create a new version heading in ``HISTORY.rst``,
156
+ and add one concise bullet point for each high-level change.
157
+
152
158
  Commit messages
153
159
  ===============
154
160
 
@@ -2,6 +2,38 @@
2
2
  History
3
3
  =======
4
4
 
5
+ 1.4.0 (2026-09-25)
6
+ ------------------
7
+
8
+ * Added ``LicenseLock`` (``locked_at`` and ``editable_from``) and a read-only ``license_lock`` field on connection configs.
9
+ The server reports it only on instances whose license caps the number of connections
10
+ and counts that connection's type against the cap,
11
+ once a connection's first run has finished successfully;
12
+ otherwise it is ``None``.
13
+ It is excluded when a config is serialized back to the API,
14
+ as it must never be sent on create or update.
15
+
16
+ * Added ``unlicensed_feature_warnings`` to ``Ruleset`` and ``RulesetLibrary``: at most one sentence naming the features
17
+ each uses that the server's license does not allow.
18
+
19
+ * Added read-only ``target_frozen``, ``frozen_target_fields`` and ``deletion_frozen`` fields on connection configs.
20
+ ``target_frozen`` is ``True`` while the connection's protected fields are locked under the license edit lock,
21
+ ``frozen_target_fields`` lists those locked fields as server config keys, such as ``schema`` (empty when not frozen),
22
+ and ``deletion_frozen`` is whether deleting the connection is currently blocked
23
+ (it can differ from ``target_frozen``: deletion stays allowed while over the connection cap).
24
+ All three are excluded when a config is serialized back to the API.
25
+
26
+ Requires server version 3.26.18
27
+
28
+ 1.3.1 (2026-09-09)
29
+ ------------------
30
+
31
+ * Added ``max_length_unit`` to ``SchemaDiscoveryColumn``, typed by the new ``LengthUnit`` enum
32
+ (``chars`` or ``bytes``), reporting the unit a column's ``max_length`` is counted in.
33
+ It is ``None`` for results from older servers that do not report a unit.
34
+
35
+ Requires server version 3.26.17
36
+
5
37
  1.3.0 (2026-09-04)
6
38
  ------------------
7
39
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: datamasque-python
3
- Version: 1.3.0
3
+ Version: 1.4.0
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/
@@ -68,31 +68,10 @@ Releasing to PyPI
68
68
  You can verify that the release appears at https://pypi.org/project/datamasque-python/,
69
69
  and Read the Docs has built the new tag
70
70
  at https://datamasque-python.readthedocs.io/.
71
- 5. Create the GitHub Release (see below).
72
-
73
- Creating the GitHub Release
74
- ===========================
75
-
76
- Do this after the PyPI publish has succeeded,
77
- so the release never points at a version nobody can install.
78
-
79
- 1. On the repository home page,
80
- find **Releases** in the right-hand sidebar under the *About* box,
81
- and click it,
82
- then **Draft a new release**.
83
- 2. **Choose a tag** — pick the existing tag you pushed as part of releasing to PyPI.
84
- Don't let GitHub create a new one; the tag should already exist from the release flow.
85
- 3. Set the target to ``main``.
86
- 4. Title it the same as the tag, e.g. ``v1.2.2``.
87
- 5. Paste the ``HISTORY.rst`` entry for this version into the body, converted to Markdown.
88
- **Generate release notes** is a reasonable starting point for the commit list,
89
- but the hand-written changelog is what users read.
90
- 6. Leave *Set as a pre-release* unticked for a normal release.
91
- 7. Click **Publish release**.
92
-
93
- Creating releases can also be done from the ``gh`` CLI::
94
-
95
- gh release create v1.2.2 --title v1.2.2 --notes-file notes.md
71
+ 5. The GitHub Release is created automatically once the PyPI publish succeeds.
72
+ ``release.yml`` runs a ``github-release`` job that creates the release for the pushed tag,
73
+ attaches the built distributions, and links to ``HISTORY.rst`` at that tag for the changelog.
74
+ Nothing to do by hand; just confirm it appears under **Releases** on the repository home page.
96
75
 
97
76
  Releasing a dev build to TestPyPI
98
77
  =================================
@@ -38,6 +38,7 @@ from datamasque.client.models.connection import (
38
38
  DocumentDbConnectionConfig,
39
39
  DynamoConnectionConfig,
40
40
  FileConnectionConfig,
41
+ LicenseLock,
41
42
  MongoConnectionConfig,
42
43
  MountedShareConnectionConfig,
43
44
  MssqlLinkedServerConnectionConfig,
@@ -73,6 +74,7 @@ from datamasque.client.models.discovery import (
73
74
  ForeignKeyRef,
74
75
  InDataDiscoveryConfig,
75
76
  InDataDiscoveryRule,
77
+ LengthUnit,
76
78
  ReferencingForeignKey,
77
79
  RulesetGenerationRequest,
78
80
  RulesetGenerationWithRGConfigRequest,
@@ -239,8 +241,10 @@ __all__ = [
239
241
  "InvalidRulesetError",
240
242
  "JsonPath",
241
243
  "LengthEntry",
244
+ "LengthUnit",
242
245
  "LengthsStatistics",
243
246
  "LicenseInfo",
247
+ "LicenseLock",
244
248
  "Locator",
245
249
  "MaskType",
246
250
  "MaskedFormEntry",
@@ -1,5 +1,6 @@
1
1
  """Connection configuration models for the DataMasque API."""
2
2
 
3
+ from datetime import datetime
3
4
  from enum import Enum
4
5
  from typing import Any, Callable, Literal, NewType, Optional
5
6
 
@@ -99,6 +100,22 @@ class SseConfig(BaseModel):
99
100
  return self
100
101
 
101
102
 
103
+ class LicenseLock(BaseModel):
104
+ """
105
+ Read-only license-lock metadata for a connection.
106
+
107
+ The server reports this only on instances whose license has a finite connection cap
108
+ and counts this connection's type against that cap,
109
+ once the connection's first run has finished successfully;
110
+ otherwise it is `null`.
111
+ """
112
+
113
+ model_config = ConfigDict(extra="allow")
114
+
115
+ locked_at: datetime
116
+ editable_from: datetime
117
+
118
+
102
119
  class ConnectionConfig(BaseModel):
103
120
  """
104
121
  Base class for all connection configurations.
@@ -111,6 +128,16 @@ class ConnectionConfig(BaseModel):
111
128
 
112
129
  name: str
113
130
  id: Optional[ConnectionId] = None
131
+ # Server-populated and read-only, so it is excluded when serializing a config back to the API.
132
+ license_lock: Optional[LicenseLock] = Field(default=None, exclude=True)
133
+ # Whether the connection's protected fields are currently locked under the license edit lock.
134
+ target_frozen: bool = Field(default=False, exclude=True)
135
+ # The protected fields currently locked, as server config keys (e.g. `schema`, not `db_schema`);
136
+ # empty when `target_frozen` is False.
137
+ frozen_target_fields: list[str] = Field(default_factory=list, exclude=True)
138
+ # Whether deleting the connection is currently blocked. Not always equal to `target_frozen`:
139
+ # deletion stays allowed while over the connection cap, so a downgrade can be recovered from.
140
+ deletion_frozen: bool = Field(default=False, exclude=True)
114
141
 
115
142
 
116
143
  class DynamoConnectionConfig(ConnectionConfig):
@@ -367,6 +367,13 @@ class ReferencingForeignKey(BaseModel):
367
367
  referencing_column: str # Dotted path: "schema.table.column".
368
368
 
369
369
 
370
+ class LengthUnit(Enum):
371
+ """The unit a column's declared width (`max_length`) is counted in."""
372
+
373
+ chars = "chars"
374
+ bytes = "bytes"
375
+
376
+
370
377
  class SchemaDiscoveryColumn(BaseModel):
371
378
  """Column-level data in a schema discovery result."""
372
379
 
@@ -374,6 +381,8 @@ class SchemaDiscoveryColumn(BaseModel):
374
381
 
375
382
  data_type: Optional[str] = None
376
383
  max_length: Optional[int] = None
384
+ # None when the server does not report a unit (older runs count chars).
385
+ max_length_unit: Optional[LengthUnit] = None
377
386
  foreign_keys: list[ForeignKeyRef]
378
387
  discovery_matches: list[DiscoveryMatch]
379
388
  numeric_precision: Optional[int] = None
@@ -46,3 +46,10 @@ class Ruleset(GitTrackedEntity):
46
46
  is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True)
47
47
  validation_errors: list[ValidationErrorDetails] = Field(default_factory=list, exclude=True)
48
48
  """Validation errors surfaced by the server; empty when valid."""
49
+ unlicensed_feature_warnings: Optional[list[str]] = Field(default=None, exclude=True)
50
+ """
51
+ At most one sentence, naming every feature the ruleset uses that the server's license does not allow.
52
+ Empty when the ruleset needs nothing its license withholds,
53
+ and `None` when the server does not know which features the ruleset uses, for example before it has passed
54
+ validation. `None` is not the same as having no warnings.
55
+ """
@@ -21,5 +21,12 @@ class RulesetLibrary(GitTrackedEntity):
21
21
  is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True)
22
22
  validation_errors: list[ValidationErrorDetails] = Field(default_factory=list, exclude=True)
23
23
  """Validation errors surfaced by the server; empty when valid."""
24
+ unlicensed_feature_warnings: Optional[list[str]] = Field(default=None, exclude=True)
25
+ """
26
+ At most one sentence, naming every feature the library uses that the server's license does not allow.
27
+ Empty when the license allows every feature,
28
+ and `None` when the server does not know which features the library uses, for example before it has passed
29
+ validation.
30
+ """
24
31
  created: Optional[datetime] = Field(default=None, exclude=True)
25
32
  modified: Optional[datetime] = Field(default=None, exclude=True)
@@ -64,7 +64,8 @@ class RulesetLibraryClient(BaseClient):
64
64
  Creates a new ruleset library on the server.
65
65
 
66
66
  Sets the library's server-assigned fields
67
- (`id`, `is_valid`, `validation_errors`, `git`, `created`, `modified`) and returns the library.
67
+ (`id`, `is_valid`, `validation_errors`, `unlicensed_feature_warnings`, `git`, `created`, `modified`)
68
+ and returns the library.
68
69
  """
69
70
 
70
71
  data = library.model_dump(exclude_none=True, by_alias=True, mode="json")
@@ -73,6 +74,7 @@ class RulesetLibraryClient(BaseClient):
73
74
  library.id = created_library.id
74
75
  library.is_valid = created_library.is_valid
75
76
  library.validation_errors = created_library.validation_errors
77
+ library.unlicensed_feature_warnings = created_library.unlicensed_feature_warnings
76
78
  library.git = created_library.git
77
79
  library.created = created_library.created
78
80
  library.modified = created_library.modified
@@ -94,6 +96,7 @@ class RulesetLibraryClient(BaseClient):
94
96
  updated_library = RulesetLibrary.model_validate(response.json())
95
97
  library.is_valid = updated_library.is_valid
96
98
  library.validation_errors = updated_library.validation_errors
99
+ library.unlicensed_feature_warnings = updated_library.unlicensed_feature_warnings
97
100
  library.git = updated_library.git
98
101
  library.modified = updated_library.modified
99
102
  logger.debug('Update of ruleset library "%s" successful', library.name)
@@ -20,7 +20,7 @@ class RulesetClient(BaseClient):
20
20
  """
21
21
  Creates or updates a ruleset.
22
22
 
23
- Populates the given ruleset's `id`, `is_valid`, `validation_errors`,
23
+ Populates the given ruleset's `id`, `is_valid`, `validation_errors`, `unlicensed_feature_warnings`,
24
24
  and `git` fields from the server response, and returns the same ruleset instance for convenience.
25
25
  """
26
26
 
@@ -30,6 +30,7 @@ class RulesetClient(BaseClient):
30
30
  ruleset.id = created.id
31
31
  ruleset.is_valid = created.is_valid
32
32
  ruleset.validation_errors = created.validation_errors
33
+ ruleset.unlicensed_feature_warnings = created.unlicensed_feature_warnings
33
34
  ruleset.git = created.git
34
35
 
35
36
  if response.status_code == 201:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "datamasque-python"
3
- version = "1.3.0"
3
+ version = "1.4.0"
4
4
  description = "Official Python client for the DataMasque data-masking API."
5
5
  authors = [
6
6
  { name = "DataMasque Ltd" },
@@ -1,11 +1,14 @@
1
1
  """Tests for `ConnectionClient` (CRUD + Snowflake-specific behaviour)."""
2
2
 
3
+ from datetime import datetime, timezone
4
+
3
5
  import pytest
4
6
  import requests_mock
5
7
 
6
8
  from datamasque.client.exceptions import DataMasqueApiError, DataMasqueException
7
9
  from datamasque.client.models.connection import (
8
10
  AzureConnectionConfig,
11
+ ConnectionConfig,
9
12
  ConnectionId,
10
13
  CosmosDbConnectionConfig,
11
14
  DatabaseConnectionConfig,
@@ -13,6 +16,7 @@ from datamasque.client.models.connection import (
13
16
  DatabricksConnectionConfig,
14
17
  DocumentDbConnectionConfig,
15
18
  DynamoConnectionConfig,
19
+ LicenseLock,
16
20
  MongoConnectionConfig,
17
21
  MountedShareConnectionConfig,
18
22
  MssqlLinkedServerConnectionConfig,
@@ -682,6 +686,200 @@ def test_s3_connection_model_validate():
682
686
  assert conn.iam_role_arn == "arn:aws:iam::111122223333:role/s3-role"
683
687
 
684
688
 
689
+ def test_connection_model_validate_parses_license_lock() -> None:
690
+ payload = {
691
+ "id": "88dabb63-aca5-4cc4-8f76-f78736a42f39",
692
+ "name": "s3",
693
+ "mask_type": "file",
694
+ "type": "s3_connection",
695
+ "base_directory": "data/",
696
+ "is_file_mask_source": True,
697
+ "is_file_mask_destination": False,
698
+ "bucket": "my-bucket",
699
+ "license_lock": {
700
+ "locked_at": "2026-01-01T12:00:00Z",
701
+ "editable_from": "2026-04-01T12:00:00Z",
702
+ },
703
+ }
704
+
705
+ conn = validate_connection(payload)
706
+
707
+ assert isinstance(conn.license_lock, LicenseLock)
708
+ assert conn.license_lock.locked_at == datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc)
709
+ assert conn.license_lock.editable_from == datetime(2026, 4, 1, 12, 0, tzinfo=timezone.utc)
710
+
711
+
712
+ def test_connection_model_validate_null_license_lock() -> None:
713
+ payload = {
714
+ "id": "id-1",
715
+ "name": "s3",
716
+ "mask_type": "file",
717
+ "type": "s3_connection",
718
+ "base_directory": "",
719
+ "is_file_mask_source": True,
720
+ "is_file_mask_destination": False,
721
+ "bucket": "my-bucket",
722
+ "license_lock": None,
723
+ }
724
+
725
+ conn = validate_connection(payload)
726
+ assert conn.license_lock is None
727
+
728
+
729
+ def test_connection_model_validate_parses_freeze_fields() -> None:
730
+ payload = {
731
+ "id": "db-1",
732
+ "name": "pg",
733
+ "mask_type": "database",
734
+ "db_type": "postgres",
735
+ "host": "h",
736
+ "port": 5432,
737
+ "database": "d",
738
+ "user": "u",
739
+ "schema": "public",
740
+ "target_frozen": True,
741
+ "frozen_target_fields": ["host", "port"],
742
+ "deletion_frozen": True,
743
+ }
744
+
745
+ conn = validate_connection(payload)
746
+
747
+ assert conn.target_frozen is True
748
+ assert conn.frozen_target_fields == ["host", "port"]
749
+ assert conn.deletion_frozen is True
750
+
751
+
752
+ def test_connection_freeze_fields_default_when_absent() -> None:
753
+ payload = {
754
+ "id": "db-1",
755
+ "name": "pg",
756
+ "mask_type": "database",
757
+ "db_type": "postgres",
758
+ "host": "h",
759
+ "port": 5432,
760
+ "database": "d",
761
+ "user": "u",
762
+ "schema": "public",
763
+ }
764
+
765
+ conn = validate_connection(payload)
766
+
767
+ assert conn.target_frozen is False
768
+ assert conn.frozen_target_fields == []
769
+ assert conn.deletion_frozen is False
770
+
771
+
772
+ _LICENSE_LOCK_PAYLOAD = {"locked_at": "2026-01-01T12:00:00Z", "editable_from": "2026-04-01T12:00:00Z"}
773
+
774
+
775
+ @pytest.mark.parametrize(
776
+ "config_class, payload",
777
+ [
778
+ # Default serializer.
779
+ (
780
+ S3ConnectionConfig,
781
+ {
782
+ "id": "id-1",
783
+ "name": "s3",
784
+ "mask_type": "file",
785
+ "type": "s3_connection",
786
+ "base_directory": "",
787
+ "is_file_mask_source": True,
788
+ "is_file_mask_destination": False,
789
+ "bucket": "my-bucket",
790
+ },
791
+ ),
792
+ # Each subclass below overrides `@model_serializer`, so exclusion must be proven for each.
793
+ (
794
+ DatabaseConnectionConfig,
795
+ {
796
+ "id": "db-1",
797
+ "name": "pg",
798
+ "mask_type": "database",
799
+ "db_type": "postgres",
800
+ "host": "h",
801
+ "port": 5432,
802
+ "database": "d",
803
+ "user": "u",
804
+ "schema": "public",
805
+ "is_read_only": False,
806
+ },
807
+ ),
808
+ (
809
+ DynamoConnectionConfig,
810
+ {
811
+ "id": "dyn-1",
812
+ "name": "dyn",
813
+ "mask_type": "database",
814
+ "db_type": "dynamo_db",
815
+ "s3_bucket_name": "bucket",
816
+ "dynamo_append_datetime": False,
817
+ "dynamo_append_suffix": "-masked",
818
+ "dynamo_replace_tables": True,
819
+ "dynamo_default_region": "us-east-1",
820
+ },
821
+ ),
822
+ (
823
+ MongoConnectionConfig,
824
+ {
825
+ "id": "mongo-1",
826
+ "name": "mongo",
827
+ "mask_type": "database",
828
+ "db_type": "mongodb",
829
+ "host": "mongo.example",
830
+ "port": 27017,
831
+ "database": "people",
832
+ "user": "alice",
833
+ "auth_source": "admin",
834
+ "tls": True,
835
+ "direct_connection": False,
836
+ "replica_set": "rs0",
837
+ "is_read_only": False,
838
+ },
839
+ ),
840
+ (
841
+ SnowflakeConnectionConfig,
842
+ {
843
+ "id": "sf-1",
844
+ "name": "snowflake",
845
+ "mask_type": "database",
846
+ "db_type": "snowflake",
847
+ "user": "snowman",
848
+ "database": "icicle",
849
+ "snowflake_account_id": "ABCDEF-123456",
850
+ "snowflake_warehouse": "warehouse1",
851
+ "snowflake_storage_integration_name": "mysi",
852
+ "host": "snowflake.com",
853
+ "port": 443,
854
+ "s3_bucket_name": "ice-bucket",
855
+ "iam_role_arn": "role",
856
+ "snowflake_stage_location": "aws_s3",
857
+ "is_read_only": False,
858
+ },
859
+ ),
860
+ ],
861
+ )
862
+ def test_read_only_server_fields_are_excluded_when_serializing(
863
+ config_class: type[ConnectionConfig], payload: dict
864
+ ) -> None:
865
+ # `license_lock` and the target-freeze fields are server-populated and read-only, so they must
866
+ # never be sent back on create/update. Cover the subclasses with custom `@model_serializer`
867
+ # overrides, not just the default serializer.
868
+ conn = config_class.model_validate(
869
+ {
870
+ **payload,
871
+ "license_lock": _LICENSE_LOCK_PAYLOAD,
872
+ "target_frozen": True,
873
+ "frozen_target_fields": ["host"],
874
+ "deletion_frozen": True,
875
+ }
876
+ )
877
+
878
+ dumped = conn.model_dump(by_alias=True, mode="json")
879
+ for field in ("license_lock", "target_frozen", "frozen_target_fields", "deletion_frozen"):
880
+ assert field not in dumped
881
+
882
+
685
883
  def test_s3_connection_model_validate_no_iam_role():
686
884
  payload = {
687
885
  "id": "id-1",
@@ -21,6 +21,7 @@ from datamasque.client import (
21
21
  FileRulesetGenerationRequest,
22
22
  FileRulesetGenerationWithRGConfigRequest,
23
23
  InDataDiscoveryConfig,
24
+ LengthUnit,
24
25
  RGConfig,
25
26
  RGConfigId,
26
27
  RulesetGenerationRequest,
@@ -795,6 +796,22 @@ def test_schema_discovery_result_parses_safe_data_preview():
795
796
  assert preview.statistics_common.count_distinct == 988
796
797
 
797
798
 
799
+ def test_schema_discovery_result_parses_max_length_unit() -> None:
800
+ row = _schema_discovery_row(1, "name")
801
+ row["data"]["max_length"] = 10
802
+ row["data"]["max_length_unit"] = "bytes"
803
+ result = SchemaDiscoveryResult.model_validate(row)
804
+ assert result.data.max_length == 10
805
+ assert result.data.max_length_unit is LengthUnit.bytes
806
+
807
+
808
+ def test_schema_discovery_result_without_max_length_unit_is_none() -> None:
809
+ row = _schema_discovery_row(1, "name")
810
+ assert "max_length_unit" not in row["data"]
811
+ result = SchemaDiscoveryResult.model_validate(row)
812
+ assert result.data.max_length_unit is None
813
+
814
+
798
815
  def test_list_schema_discovery_results_follows_pagination(client):
799
816
  run_id = RunId(42)
800
817
  page1 = {
@@ -1,7 +1,7 @@
1
1
  """Tests for ruleset library support in the DataMasque client."""
2
2
 
3
3
  from datetime import datetime
4
- from typing import Any
4
+ from typing import Any, Optional
5
5
 
6
6
  import pytest
7
7
  import requests_mock
@@ -739,3 +739,37 @@ def test_create_ruleset_library_collapses_git_snapshot(
739
739
  assert result.git.commit_sha == "abc123"
740
740
  assert result.git.repo_url == "https://git.example.com/repo.git"
741
741
  assert result.git.synced_at == datetime.fromisoformat("2025-06-01T10:00:00+00:00")
742
+
743
+
744
+ UNSTRUCTURED_WARNING = "Foundation license does not allow Unstructured Masking. Upgrade to Enterprise."
745
+
746
+
747
+ @pytest.mark.parametrize("returned", [[UNSTRUCTURED_WARNING], [], None])
748
+ def test_create_and_update_ruleset_library_populate_unlicensed_feature_warnings(
749
+ client: DataMasqueClient, ruleset_library: RulesetLibrary, returned: Optional[list[str]]
750
+ ) -> None:
751
+ """`None` (not yet validated) stays distinct from `[]` (no warnings) on both the create and the update path."""
752
+ response = {
753
+ "id": LIBRARY_ID_1,
754
+ "name": "test_library",
755
+ "namespace": "test_ns",
756
+ "config_yaml": "version: '1.0'\nfunctions: []",
757
+ "is_valid": "valid",
758
+ "unlicensed_feature_warnings": returned,
759
+ "created": "2025-06-01T10:00:00Z",
760
+ "modified": "2025-06-01T10:00:00Z",
761
+ }
762
+
763
+ with requests_mock.Mocker() as m:
764
+ m.post("http://test-server/api/ruleset-libraries/", json=response, status_code=201)
765
+ m.put(f"http://test-server/api/ruleset-libraries/{LIBRARY_ID_1}/", json=response)
766
+
767
+ created = client.create_ruleset_library(ruleset_library)
768
+ assert created.unlicensed_feature_warnings == returned
769
+ assert "unlicensed_feature_warnings" not in m.last_request.json()
770
+
771
+ created.unlicensed_feature_warnings = ["stale"]
772
+ updated = client.update_ruleset_library(created)
773
+
774
+ assert updated.unlicensed_feature_warnings == returned
775
+ assert "unlicensed_feature_warnings" not in m.last_request.json()
@@ -177,6 +177,86 @@ def test_create_or_update_ruleset_validation_errors_empty_when_valid(client, rul
177
177
  assert result.validation_errors == []
178
178
 
179
179
 
180
+ UNSTRUCTURED_WARNING = "Foundation license does not allow Unstructured Masking. Upgrade to Enterprise."
181
+ SUBSETTING_WARNING = "Foundation license does not allow Subsetting. Upgrade to Structured or Enterprise."
182
+
183
+
184
+ @pytest.mark.parametrize(
185
+ ("returned", "expected"),
186
+ [
187
+ ([UNSTRUCTURED_WARNING, SUBSETTING_WARNING], [UNSTRUCTURED_WARNING, SUBSETTING_WARNING]),
188
+ ([], []),
189
+ (None, None),
190
+ ],
191
+ ids=["warned", "fully_licensed", "not_yet_known"],
192
+ )
193
+ def test_create_or_update_ruleset_populates_unlicensed_feature_warnings(client, ruleset, returned, expected):
194
+ """Every warning the server sends is carried, and an empty list stays distinct from `None`."""
195
+ with requests_mock.Mocker() as m:
196
+ m.post(
197
+ "http://test-server/api/rulesets/?upsert=true",
198
+ json={"id": "2", "name": "test_ruleset", "is_valid": "valid", "unlicensed_feature_warnings": returned},
199
+ status_code=201,
200
+ )
201
+ result = client.create_or_update_ruleset(ruleset)
202
+
203
+ assert result.unlicensed_feature_warnings == expected
204
+
205
+
206
+ def test_create_or_update_ruleset_unlicensed_feature_warnings_default_to_none(client, ruleset):
207
+ """A server that omits the field leaves the warnings unknown rather than empty."""
208
+ with requests_mock.Mocker() as m:
209
+ m.post(
210
+ "http://test-server/api/rulesets/?upsert=true",
211
+ json={"id": "2", "name": "test_ruleset", "is_valid": "in_progress"},
212
+ status_code=201,
213
+ )
214
+ result = client.create_or_update_ruleset(ruleset)
215
+
216
+ assert result.unlicensed_feature_warnings is None
217
+
218
+
219
+ def test_list_rulesets_unlicensed_feature_warnings(client):
220
+ """Each listed ruleset carries its own `unlicensed_feature_warnings`, including `None` for an unvalidated one."""
221
+ with requests_mock.Mocker() as m:
222
+ m.get(
223
+ "http://test-server/api/v2/rulesets/",
224
+ json=[
225
+ {
226
+ "id": "1",
227
+ "name": "unstructured_ruleset",
228
+ "mask_type": "database",
229
+ "is_valid": "valid",
230
+ "unlicensed_feature_warnings": [
231
+ "Foundation license does not allow Unstructured Masking. Upgrade to Enterprise."
232
+ ],
233
+ },
234
+ {
235
+ "id": "2",
236
+ "name": "licensed_ruleset",
237
+ "mask_type": "database",
238
+ "is_valid": "valid",
239
+ "unlicensed_feature_warnings": [],
240
+ },
241
+ {
242
+ "id": "3",
243
+ "name": "unvalidated_ruleset",
244
+ "mask_type": "database",
245
+ "is_valid": "in_progress",
246
+ "unlicensed_feature_warnings": None,
247
+ },
248
+ ],
249
+ status_code=200,
250
+ )
251
+ rulesets = client.list_rulesets()
252
+
253
+ assert rulesets[0].unlicensed_feature_warnings == [
254
+ "Foundation license does not allow Unstructured Masking. Upgrade to Enterprise."
255
+ ]
256
+ assert rulesets[1].unlicensed_feature_warnings == []
257
+ assert rulesets[2].unlicensed_feature_warnings is None
258
+
259
+
180
260
  def test_create_or_update_ruleset_does_not_send_read_only_fields(client, ruleset):
181
261
  """Read-only server fields must never be echoed back into a re-submit's request body."""
182
262
  with requests_mock.Mocker() as m:
@@ -187,6 +267,9 @@ def test_create_or_update_ruleset_does_not_send_read_only_fields(client, ruleset
187
267
  "name": "test_ruleset",
188
268
  "is_valid": "invalid",
189
269
  "validation_errors": [{"message": "bad", "validation_error_type": "ruleset"}],
270
+ "unlicensed_feature_warnings": [
271
+ "Foundation license does not allow Unstructured Masking. Upgrade to Enterprise."
272
+ ],
190
273
  },
191
274
  status_code=201,
192
275
  )
@@ -196,7 +279,7 @@ def test_create_or_update_ruleset_does_not_send_read_only_fields(client, ruleset
196
279
  client.create_or_update_ruleset(ruleset)
197
280
 
198
281
  body = m.last_request.json()
199
- for read_only_field in ("id", "is_valid", "validation_errors"):
282
+ for read_only_field in ("id", "is_valid", "validation_errors", "unlicensed_feature_warnings"):
200
283
  assert read_only_field not in body
201
284
  # Input fields are still present.
202
285
  assert body["config_yaml"] == "version: '1.0'\ntasks: []"
@@ -419,7 +419,7 @@ toml = [
419
419
 
420
420
  [[package]]
421
421
  name = "datamasque-python"
422
- version = "1.3.0"
422
+ version = "1.4.0"
423
423
  source = { editable = "." }
424
424
  dependencies = [
425
425
  { name = "pydantic" },