diff --git a/HISTORY.rst b/HISTORY.rst index 1786d5a..7c86dc9 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -2,6 +2,20 @@ History ======= +1.6.0 (unreleased) +------------------ + +* Added a read-only ``project`` field to ``ConnectionConfig``, ``Ruleset``, ``RulesetLibrary``, ``DiscoveryConfig``, + ``DiscoveryConfigLibrary``, ``RGConfig`` and ``TableReference``: + the ``-`` reference of the project the resource belongs to, such as ``1-default``. + It is ``None`` until the resource is created on the server, + and is excluded when a model is serialized back to the API, + so creating a resource places it in the server's default project. +* ``RunInfo`` and ``UnfinishedRun`` gain a required ``project`` field: + the run's project, taken from its ruleset's project when the run starts. + +Requires server version 3.26.20 + 1.5.0 (2026-10-07) ------------------ diff --git a/datamasque/client/models/connection.py b/datamasque/client/models/connection.py index b391e0e..5bec97b 100644 --- a/datamasque/client/models/connection.py +++ b/datamasque/client/models/connection.py @@ -124,6 +124,10 @@ class ConnectionConfig(BaseModel): id: Optional[ConnectionId] = None # Server-populated and read-only, so it is excluded when serializing a config back to the API. license_lock: Optional[LicenseLock] = Field(default=None, exclude=True) + # Its project's `-` reference, e.g. `1-default`; + # `None` until it is created on the server. + # Server-populated, so excluded when serializing. + project: Optional[str] = Field(default=None, exclude=True) # Whether the connection's protected fields are currently locked under the license edit lock. target_frozen: bool = Field(default=False, exclude=True) # The protected fields currently locked, as server config keys (e.g. `schema`, not `db_schema`); diff --git a/datamasque/client/models/discovery_config.py b/datamasque/client/models/discovery_config.py index d1a0aa8..086257f 100644 --- a/datamasque/client/models/discovery_config.py +++ b/datamasque/client/models/discovery_config.py @@ -45,6 +45,8 @@ class DiscoveryConfig(BaseModel): # Server-populated read-only fields, excluded from request bodies. id: Optional[DiscoveryConfigId] = Field(default=None, exclude=True) + project: Optional[str] = Field(default=None, exclude=True) + """Its project's `-` reference, e.g. `1-default`; `None` until it is created on the server.""" is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True) """Validation status; may be `in_progress` briefly after creating a large config.""" validation_error: Optional[str] = Field(default=None, exclude=True) diff --git a/datamasque/client/models/discovery_config_library.py b/datamasque/client/models/discovery_config_library.py index d73b82f..629160f 100644 --- a/datamasque/client/models/discovery_config_library.py +++ b/datamasque/client/models/discovery_config_library.py @@ -23,6 +23,8 @@ class DiscoveryConfigLibrary(BaseModel): yaml: Optional[str] = Field(default=None, alias="config_yaml") # Server-populated read-only fields, excluded from request bodies. id: Optional[DiscoveryConfigLibraryId] = Field(default=None, exclude=True) + project: Optional[str] = Field(default=None, exclude=True) + """Its project's `-` reference, e.g. `1-default`; `None` until it is created on the server.""" is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True) """Validation status; libraries are validated synchronously on create/update.""" validation_error: Optional[str] = Field(default=None, exclude=True) diff --git a/datamasque/client/models/rg_config.py b/datamasque/client/models/rg_config.py index 0db1827..1403937 100644 --- a/datamasque/client/models/rg_config.py +++ b/datamasque/client/models/rg_config.py @@ -43,6 +43,8 @@ class RGConfig(BaseModel): # Server-populated read-only fields, excluded from request bodies. id: Optional[RGConfigId] = Field(default=None, exclude=True) + project: Optional[str] = Field(default=None, exclude=True) + """Its project's `-` reference, e.g. `1-default`; `None` until it is created on the server.""" is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True) validation_errors: list[ValidationErrorDetails] = Field(default_factory=list, exclude=True) created: Optional[datetime] = Field(default=None, exclude=True) diff --git a/datamasque/client/models/ruleset.py b/datamasque/client/models/ruleset.py index 2b9f160..d14c2d4 100644 --- a/datamasque/client/models/ruleset.py +++ b/datamasque/client/models/ruleset.py @@ -43,6 +43,8 @@ class Ruleset(GitTrackedEntity): # Server-populated read-only fields, excluded from request bodies. id: Optional[RulesetId] = Field(default=None, exclude=True) + project: Optional[str] = Field(default=None, exclude=True) + """Its project's `-` reference, e.g. `1-default`; `None` until it is created on the server.""" is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True) validation_errors: list[ValidationErrorDetails] = Field(default_factory=list, exclude=True) """Validation errors surfaced by the server; empty when valid.""" diff --git a/datamasque/client/models/ruleset_library.py b/datamasque/client/models/ruleset_library.py index bfd2192..68929c2 100644 --- a/datamasque/client/models/ruleset_library.py +++ b/datamasque/client/models/ruleset_library.py @@ -18,6 +18,8 @@ class RulesetLibrary(GitTrackedEntity): # Server-populated read-only fields, excluded from request bodies. id: Optional[RulesetLibraryId] = Field(default=None, exclude=True) + project: Optional[str] = Field(default=None, exclude=True) + """Its project's `-` reference, e.g. `1-default`; `None` until it is created on the server.""" is_valid: Optional[ValidationStatus] = Field(default=None, exclude=True) validation_errors: list[ValidationErrorDetails] = Field(default_factory=list, exclude=True) """Validation errors surfaced by the server; empty when valid.""" diff --git a/datamasque/client/models/runs.py b/datamasque/client/models/runs.py index dc15663..09bae55 100644 --- a/datamasque/client/models/runs.py +++ b/datamasque/client/models/runs.py @@ -142,6 +142,12 @@ class RunInfo(BaseModel): start_time: Optional[datetime] = None end_time: Optional[datetime] = None options: Optional[dict[str, Any]] = None + project: str + """ + Its project's `-` reference, e.g. `1-default`. + + Taken from the ruleset's project when the run starts, and never changes after that. + """ @model_validator(mode="before") @classmethod @@ -159,6 +165,8 @@ class UnfinishedRun(BaseModel): ruleset_name: str status: MaskingRunStatus destination_connection: Optional[RunConnectionRef] = None + project: str + """Its project's `-` reference, e.g. `1-default`.""" @model_validator(mode="before") @classmethod diff --git a/datamasque/client/models/table_reference.py b/datamasque/client/models/table_reference.py index ac94fdc..e03bb38 100644 --- a/datamasque/client/models/table_reference.py +++ b/datamasque/client/models/table_reference.py @@ -81,6 +81,8 @@ class TableReference(BaseModel): # Server-populated read-only fields, excluded from request bodies. id: Optional[TableReferenceId] = Field(default=None, exclude=True) + project: Optional[str] = Field(default=None, exclude=True) + """Its project's `-` reference, e.g. `1-default`; `None` until it is created on the server.""" created: Optional[datetime] = Field(default=None, exclude=True) modified: Optional[datetime] = Field(default=None, exclude=True) diff --git a/tests/test_base.py b/tests/test_base.py index 041880c..1e24972 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -219,6 +219,7 @@ def test_re_authenticate(config): "mask_type": "database", "source_connection_name": "c", "ruleset_name": "r", + "project": "1-default", }, "status_code": 200, }, @@ -270,6 +271,7 @@ def test_token_source_called_again_on_401_retry(): "mask_type": "database", "source_connection_name": "c", "ruleset_name": "r", + "project": "1-default", }, "status_code": 200, }, diff --git a/tests/test_model_project.py b/tests/test_model_project.py new file mode 100644 index 0000000..112b18a --- /dev/null +++ b/tests/test_model_project.py @@ -0,0 +1,83 @@ +"""Tests for the `project` field on resource and run models.""" + +from collections.abc import Callable +from typing import Union + +import pytest + +from datamasque.client.models.connection import ConnectionConfig, validate_connection +from datamasque.client.models.discovery_config import DiscoveryConfig +from datamasque.client.models.discovery_config_library import DiscoveryConfigLibrary +from datamasque.client.models.rg_config import RGConfig +from datamasque.client.models.ruleset import Ruleset +from datamasque.client.models.ruleset_library import RulesetLibrary +from datamasque.client.models.runs import RunInfo, UnfinishedRun +from datamasque.client.models.table_reference import TableReference + +ResourceModel = Union[ + ConnectionConfig, Ruleset, RulesetLibrary, DiscoveryConfig, DiscoveryConfigLibrary, RGConfig, TableReference +] +Payload = dict[str, object] +ParseResource = Callable[[Payload], ResourceModel] + +_CONNECTION_PAYLOAD: Payload = { + "id": "db-1", + "name": "pg", + "mask_type": "database", + "db_type": "postgres", + "host": "h", + "port": 5432, + "database": "d", + "user": "u", + "schema": "public", +} + +_RESOURCE_CASES = [ + pytest.param(validate_connection, _CONNECTION_PAYLOAD, id="connection"), + pytest.param( + Ruleset.model_validate, {"id": "r-1", "name": "r", "config_yaml": "", "mask_type": "database"}, id="ruleset" + ), + pytest.param(RulesetLibrary.model_validate, {"id": "rl-1", "name": "rl"}, id="ruleset_library"), + pytest.param( + DiscoveryConfig.model_validate, {"id": "dc-1", "name": "dc", "config_type": "database"}, id="discovery_config" + ), + pytest.param(DiscoveryConfigLibrary.model_validate, {"id": "dcl-1", "name": "dcl"}, id="discovery_config_library"), + pytest.param(RGConfig.model_validate, {"id": "rg-1", "name": "rg"}, id="rg_config"), + pytest.param( + TableReference.model_validate, + {"id": "tr-1", "name": "tr", "connection": "db-1", "source": "public.people"}, + id="table_reference", + ), +] + + +@pytest.mark.parametrize(("parse", "payload"), _RESOURCE_CASES) +def test_resource_parses_project_and_excludes_it_when_serializing(parse: ParseResource, payload: Payload) -> None: + model = parse({**payload, "project": "1-default"}) + + assert model.project == "1-default" + assert "project" not in (model.model_extra or {}) + assert "project" not in model.model_dump(by_alias=True, mode="json") + + +@pytest.mark.parametrize(("parse", "payload"), _RESOURCE_CASES) +def test_resource_project_defaults_to_none_when_absent(parse: ParseResource, payload: Payload) -> None: + assert parse(payload).project is None + + +_RUN_PAYLOAD: Payload = { + "id": 21, + "status": "running", + "mask_type": "database", + "source_connection": "db-1", + "source_connection_name": "pg", + "ruleset_name": "r", +} + + +@pytest.mark.parametrize("run_class", [RunInfo, UnfinishedRun]) +def test_run_parses_project(run_class: type[Union[RunInfo, UnfinishedRun]]) -> None: + run = run_class.model_validate({**_RUN_PAYLOAD, "project": "1-default"}) + + assert run.project == "1-default" + assert "project" not in (run.model_extra or {}) diff --git a/tests/test_runs.py b/tests/test_runs.py index d4be2e8..295409a 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -72,6 +72,7 @@ def test_unfinished_run_str_with_destination(): source_connection=RunConnectionRef(name="source_db"), destination_connection=RunConnectionRef(name="destination_db"), ruleset_name="my_ruleset", + project="1-default", status=MaskingRunStatus.running, ) @@ -84,6 +85,7 @@ def test_unfinished_run_str_without_destination(): id=42, source_connection=RunConnectionRef(name="source_db"), ruleset_name="my_ruleset", + project="1-default", status=MaskingRunStatus.queued, ) @@ -101,6 +103,7 @@ def test_get_unfinished_runs(client): "destination_connection_name": "queued_dst", "id": 1, "ruleset_name": "ruleset_1", + "project": "1-default", "status": "queued", } ] @@ -116,6 +119,7 @@ def test_get_unfinished_runs(client): "destination_connection_name": "running_dst", "id": 2, "ruleset_name": "ruleset_2", + "project": "1-default", "status": "running", } ] @@ -131,6 +135,7 @@ def test_get_unfinished_runs(client): "destination_connection_name": "validating_dst", "id": 3, "ruleset_name": "ruleset_3", + "project": "1-default", "status": "validating", } ] @@ -146,6 +151,7 @@ def test_get_unfinished_runs(client): "destination_connection_name": "", "id": 4, "ruleset_name": "ruleset_4", + "project": "1-default", "status": "cancelling", } ] @@ -324,6 +330,7 @@ def test_get_run_info(client): "mask_type": "database", "source_connection_name": "conn1", "ruleset_name": "rs1", + "project": "1-default", }, status_code=200, ) @@ -418,6 +425,7 @@ def test_run_info_collapses_flat_connection_fields(): "destination_connection": "dst-uuid", "destination_connection_name": "staging", "ruleset_name": "rs", + "project": "1-default", } ) assert isinstance(run.source_connection, RunConnectionRef) @@ -438,6 +446,7 @@ def test_run_info_treats_empty_destination_name_as_absent(): "source_connection_name": "prod", "destination_connection_name": "", "ruleset_name": "rs", + "project": "1-default", } ) assert run.destination_connection is None @@ -491,6 +500,7 @@ def test_cancel_run_returns_updated_run_info(client): "mask_type": "database", "source_connection_name": "conn1", "ruleset_name": "rs1", + "project": "1-default", }, status_code=200, )