Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a4a40a8
feat: DM-4390 add create_project
elliotsimpson-dm Oct 12, 2026
9681cbe
test: DM-4390 describe what the trim test checks
elliotsimpson-dm Oct 15, 2026
b8f8296
feat: add delete_project and get_project_delete_conflicts
cph-datamasque Oct 15, 2026
6d286c4
feat: add list_resources
cph-datamasque Oct 15, 2026
f9b7a9e
docs: follow the client's docstring patterns for create_project
cph-datamasque Oct 16, 2026
66c7b44
fix!: DM-4394 key unfinished runs by connection ID
elliotsimpson-dm Oct 18, 2026
9065514
feat: DM-4394 add list_projects and get_project
elliotsimpson-dm Oct 18, 2026
1e63a0b
feat!: DM-4394 replace namespace with project and scope lookups by pr…
elliotsimpson-dm Oct 18, 2026
f2a2c10
feat: DM-4388: add a client-wide default_project for creates
jiatolentino Oct 22, 2026
155767c
feat: add move_resources and get_resource_move_conflicts
cph-datamasque Oct 20, 2026
0be0b51
feat: accept a project's <id>-<slug> form wherever the client takes a…
cph-datamasque Oct 20, 2026
ac89d89
feat: DM-4950: add ordering to list_resources and expose each resourc…
elliotsimpson-dm Nov 1, 2026
8bb8781
feat: DM-4677: add git push, pull, file listing and push paths
elliotsimpson-dm Oct 29, 2026
2d463e5
feat: DM-4393: add export_bundle and import_bundle for v2 bundles
elliotsimpson-dm Oct 28, 2026
a7059dc
DM-4391 feat(projects): add update_project and has_git_synced_resources
andrewchester-dm Oct 27, 2026
1fe4c5f
DM-4391 docs(projects): match the update_project and git flag docs to…
andrewchester-dm Oct 28, 2026
9ef92fb
docs(projects): a project name only has to differ from active project…
jiatolentino Nov 3, 2026
fb2ab9f
docs: tidy the 1.7.0 changelog and drop the unreleased marker from 1.6.0
cph-datamasque Nov 4, 2026
529832f
chore: bump version to 1.7.0
cph-datamasque Nov 4, 2026
ca39daa
docs: correct the git push, run project, project name and import fall…
cph-datamasque Nov 4, 2026
d122bf5
fix: list every type as an empty group when listing resources with no…
cph-datamasque Nov 4, 2026
efe7213
docs: fix the Sphinx warnings that fail the docs build
cph-datamasque Nov 4, 2026
92060e8
Merge pull request #73 from datamasque/fix-docs-build
carlosfunk Nov 12, 2026
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
35 changes: 33 additions & 2 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,39 @@
History
=======

1.6.0 (unreleased)
------------------
1.7.0 (2026-11-06)
------------------

* Added project models and management APIs.
* Added resource management APIs.
* Added ``default_project`` to ``DataMasqueInstanceConfig``.
* ``ConnectionConfig``, ``Ruleset``, ``RulesetLibrary``, ``DiscoveryConfig``, ``DiscoveryConfigLibrary``, ``RGConfig``
and ``TableReference`` send their ``project`` to the server when it is set.
It takes a project's ``<id>-<slug>`` reference, such as ``Project.reference``, or its ID as a string.
Left unset, a create goes into the client's ``default_project``, else the server's default project.
Each create sets the model's ``project`` to the project the server stored it in.
* **Breaking:** names are unique per project, so every by-name helper takes a required ``project``:
``get_*_by_name``, ``delete_*_by_name_if_exists`` and ``get_file_of_type_by_name``.
These ``project`` parameters take a ``Project`` or its ``ProjectId``.
``create_or_update_*`` updates an existing resource only when it has the same name in the same project.
If the model has no ``project``, it looks in the client's ``default_project``, else the server's only active project,
and raises ``DataMasqueException`` when there is no active project or more than one.
* **Breaking:** ``upload_file`` and ``upload_file_if_not_exists`` take a required ``project``.
* **Breaking:** ``namespace`` is removed from ``RulesetLibrary`` and ``DiscoveryConfigLibrary``,
and the client drops it from server responses so that it is never sent back.
The library by-name helpers take ``project`` in its place.
* **Breaking:** ``get_unfinished_runs`` returns its runs keyed by ``ConnectionId`` instead of connection name,
because two connections in different projects can share a name.
* ``list_connections``, ``list_table_references`` and ``list_files_of_type`` take an optional ``project`` filter.
* ``DataMasqueFile`` gains a read-only ``project`` field, which is ``None`` for built-in seeds.
* ``get_generated_rulesets`` sets each ruleset's ``project`` to the connection's project.
* Added Git APIs for rulesets, ruleset libraries, discovery configs and discovery config libraries.
* Added bundle export and import APIs.

Requires server version 3.26.21

1.6.0
-----

* Added a read-only ``project`` field to ``ConnectionConfig``, ``Ruleset``, ``RulesetLibrary``, ``DiscoveryConfig``,
``DiscoveryConfigLibrary``, ``RGConfig`` and ``TableReference``:
Expand Down
62 changes: 61 additions & 1 deletion datamasque/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,21 @@
InvalidDiscoveryConfigError,
InvalidLibraryError,
InvalidRulesetError,
ProjectDeleteConflictError,
RGConfigNotFoundError,
RunNotCancellableError,
)
from datamasque.client.git import GitExtension
from datamasque.client.ifm import DataMasqueIfmClient
from datamasque.client.models.bundle import (
BundleImportCounts,
BundleImportItemResults,
BundleImportResult,
BundleImportStatus,
LibraryImportResult,
RulesetImportResult,
SeedFileImportResult,
)
from datamasque.client.models.connection import (
AzureConnectionConfig,
CassandraConnectionConfig,
Expand Down Expand Up @@ -100,7 +111,7 @@
SnowflakeKeyFile,
SslZipFile,
)
from datamasque.client.models.git import GitSnapshot
from datamasque.client.models.git import GitFile, GitFileCommit, GitSnapshot
from datamasque.client.models.ifm import (
DataMasqueIfmInstanceConfig,
IfmLog,
Expand All @@ -115,6 +126,27 @@
RulesetPlanUpdateRequest,
)
from datamasque.client.models.license import LicenseInfo, SwitchableLicenseMetadata
from datamasque.client.models.project import (
Project,
ProjectCreateRequest,
ProjectDeleteConflict,
ProjectDeleteConflictReason,
ProjectId,
ProjectIdentifier,
ProjectUpdateRequest,
)
from datamasque.client.models.resource import (
ConnectionResource,
DiscoveryConfigResource,
Resource,
ResourceMoveConflict,
ResourceMoveConflictReason,
ResourceMoveResult,
ResourceOrdering,
ResourcesByType,
RulesetResource,
)
from datamasque.client.models.resource_type import ResourceTypeName
from datamasque.client.models.rg_config import RGConfig, RGConfigId
from datamasque.client.models.ruleset import Ruleset, RulesetId, RulesetType
from datamasque.client.models.ruleset_library import RulesetLibrary, RulesetLibraryId
Expand Down Expand Up @@ -182,12 +214,17 @@
"AzureConnectionConfig",
"BooleanPreview",
"BooleanStatistics",
"BundleImportCounts",
"BundleImportItemResults",
"BundleImportResult",
"BundleImportStatus",
"CassandraConnectionConfig",
"ColumnKind",
"ColumnPreview",
"CommonStatistics",
"ConnectionConfig",
"ConnectionId",
"ConnectionResource",
"ConstraintColumns",
"DataMasqueApiError",
"DataMasqueClient",
Expand All @@ -208,6 +245,7 @@
"DiscoveryConfigLibrary",
"DiscoveryConfigLibraryId",
"DiscoveryConfigNotFoundError",
"DiscoveryConfigResource",
"DiscoveryConfigType",
"DiscoveryMatch",
"CosmosDbConnectionConfig",
Expand All @@ -231,6 +269,9 @@
"FirstCharsStatistics",
"ForeignKeyRef",
"GcsConnectionConfig",
"GitExtension",
"GitFile",
"GitFileCommit",
"GitSnapshot",
"HashColumnsTableConfig",
"IfmAuthError",
Expand All @@ -248,6 +289,7 @@
"LengthEntry",
"LengthUnit",
"LengthsStatistics",
"LibraryImportResult",
"LicenseInfo",
"LicenseLock",
"Locator",
Expand All @@ -269,21 +311,38 @@
"PatternComposition",
"PatternEntry",
"PatternsStatistics",
"Project",
"ProjectCreateRequest",
"ProjectDeleteConflict",
"ProjectDeleteConflictError",
"ProjectDeleteConflictReason",
"ProjectId",
"ProjectIdentifier",
"ProjectUpdateRequest",
"RGConfig",
"RGConfigId",
"RGConfigNotFoundError",
"ReferencingForeignKey",
"Resource",
"ResourceMoveConflict",
"ResourceMoveConflictReason",
"ResourceMoveResult",
"ResourceOrdering",
"ResourceTypeName",
"ResourcesByType",
"Ruleset",
"RulesetGenerationRequest",
"RulesetGenerationWithRGConfigRequest",
"RulesetId",
"RulesetImportResult",
"RulesetLibrary",
"RulesetLibraryId",
"RulesetPlan",
"RulesetPlanCreateRequest",
"RulesetPlanOptions",
"RulesetPlanPartialUpdateRequest",
"RulesetPlanUpdateRequest",
"RulesetResource",
"RulesetType",
"RunConnectionRef",
"RunId",
Expand All @@ -300,6 +359,7 @@
"SchemaDiscoveryRequest",
"SchemaDiscoveryResult",
"SeedFile",
"SeedFileImportResult",
"SelectedColumns",
"SelectedData",
"SelectedFileData",
Expand Down
49 changes: 47 additions & 2 deletions datamasque/client/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,13 @@

from datamasque.client.exceptions import (
DataMasqueApiError,
DataMasqueException,
DataMasqueNotReadyError,
DataMasqueTransportError,
)
from datamasque.client.models.dm_instance import DataMasqueInstanceConfig
from datamasque.client.models.pagination import Page
from datamasque.client.models.project import Project, ProjectId
from datamasque.client.spcs import install_spcs_gateway_auth

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -130,13 +133,15 @@ class BaseClient:
password: Optional[str]
verify_ssl: bool
token_source: Optional[Callable[[], str]]
default_project: Optional[str]

def __init__(self, connection_config: DataMasqueInstanceConfig) -> None:
self.base_url = connection_config.base_url
self.username = connection_config.username
self.password = connection_config.password
self.verify_ssl = connection_config.verify_ssl
self.token_source = connection_config.token_source
self.default_project = connection_config.default_project
self._session = _build_session(self.verify_ssl)
self._timeout = (connection_config.connect_timeout_seconds, connection_config.read_timeout_seconds)
if connection_config.spcs_pat:
Expand Down Expand Up @@ -313,6 +318,40 @@ def _delete_if_exists(self, path: str, *, params: Optional[dict] = None) -> None

self._raise_for_status(response)

def _resolve_project(self, project: Optional[Union[ProjectId, str]]) -> Union[ProjectId, str]:
"""
Returns `project`, else the client's `default_project`, else the ID of the only active project.

The last is the project the server puts a create into when it is sent no `project`.
With no active project, or more than one, the server rejects such a create,
so this raises `DataMasqueException` instead.
"""

if project is not None:
return project
if self.default_project is not None:
return self.default_project

response = self.make_request("GET", "/api/projects/", params={"limit": 1})
page = Page[Project].model_validate(response.json())
if page.count != 1:
raise DataMasqueException(
f"No `project` is set and the server has {page.count} active projects. Set `project` to choose one."
)

return page.results[0].id

def _default_project_field(self, project: Optional[Union[ProjectId, str]]) -> dict[str, str]:
"""
Returns the `project` field a create sends when the resource names no `project`: the client's `default_project`.

Empty when the resource names one, which its own fields carry, or when there is no `default_project`.
"""

if project is None and self.default_project is not None:
return {"project": self.default_project}
return {}

def _iter_paginated(
self,
path: str,
Expand All @@ -321,8 +360,14 @@ def _iter_paginated(
params: Optional[dict] = None,
page_size: int = 100,
) -> Iterator[_T]:
"""Iterate every `T` across all pages of an admin-server list endpoint."""

for results in self._iter_pages(path, params=params, page_size=page_size):
yield from (model.model_validate(item) for item in results)

def _iter_pages(self, path: str, *, params: Optional[dict] = None, page_size: int = 100) -> Iterator[Any]:
"""
Iterate every `T` across all pages of an admin-server list endpoint.
Iterate the `results` of each page of an admin-server list endpoint.

Opts into pagination by sending `limit`/`offset` on the first request,
then follows the absolute `next` URL returned by the server.
Expand All @@ -338,7 +383,7 @@ def _iter_paginated(
while url:
response = self.make_request("GET", url, params=current_params)
data = response.json()
yield from (model.model_validate(item) for item in data["results"])
yield data["results"]
url = data.get("next")
# The `next` URL is absolute and already contains the pagination cursor;
# do not re-send our initial params alongside it.
Expand Down
Loading
Loading