Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<id>-<slug>`` 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)
------------------

Expand Down
4 changes: 4 additions & 0 deletions datamasque/client/models/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>-<slug>` 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`);
Expand Down
2 changes: 2 additions & 0 deletions datamasque/client/models/discovery_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>-<slug>` 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)
Expand Down
2 changes: 2 additions & 0 deletions datamasque/client/models/discovery_config_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>-<slug>` 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)
Expand Down
2 changes: 2 additions & 0 deletions datamasque/client/models/rg_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>-<slug>` 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)
Expand Down
2 changes: 2 additions & 0 deletions datamasque/client/models/ruleset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>-<slug>` 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."""
Expand Down
2 changes: 2 additions & 0 deletions datamasque/client/models/ruleset_library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>-<slug>` 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."""
Expand Down
8 changes: 8 additions & 0 deletions datamasque/client/models/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>-<slug>` 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
Expand All @@ -159,6 +165,8 @@ class UnfinishedRun(BaseModel):
ruleset_name: str
status: MaskingRunStatus
destination_connection: Optional[RunConnectionRef] = None
project: str
"""Its project's `<id>-<slug>` reference, e.g. `1-default`."""

@model_validator(mode="before")
@classmethod
Expand Down
2 changes: 2 additions & 0 deletions datamasque/client/models/table_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<id>-<slug>` 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)

Expand Down
2 changes: 2 additions & 0 deletions tests/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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,
},
Expand Down
83 changes: 83 additions & 0 deletions tests/test_model_project.py
Original file line number Diff line number Diff line change
@@ -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 {})
10 changes: 10 additions & 0 deletions tests/test_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand All @@ -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,
)

Expand All @@ -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",
}
]
Expand All @@ -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",
}
]
Expand All @@ -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",
}
]
Expand All @@ -146,6 +151,7 @@ def test_get_unfinished_runs(client):
"destination_connection_name": "",
"id": 4,
"ruleset_name": "ruleset_4",
"project": "1-default",
"status": "cancelling",
}
]
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down
Loading