From aee4146ee0d7f9aae57001089933b02df265605c Mon Sep 17 00:00:00 2001 From: MantisCartography Date: Mon, 28 Sep 2026 07:27:51 +0000 Subject: [PATCH] fix(spaces): send visibility instead of the legacy is_public field The backend hard-rejects the legacy is_public field on /synthesis/landscape and /synthesis/github (CreateMapSerializer, 'Use visibility instead.') since the Unified Space Visibility change (#1958, Jul 2026). Every SDK-driven space creation against prod has been 400-ing since. - add _visibility_from_privacy_level(): maps SpacePrivacy.PUBLIC -> 'unlisted' (public is backend-set-only), PRIVATE/SHARED -> 'private'; an explicit visibility= kwarg wins over privacy_level= - both create() and from_github() now send 'visibility' and never 'is_public' - new visibility= kwarg on both methods for callers that want the exact tier - 4 new tests asserting is_public is absent and visibility is correct Verified: full test suite 72 passed (was 68). Fixes the repo_radar daily task silent no-op (MantisAPI #1627 automation refreshing the Mantis Radar space since June). --- mantis_sdk/resources.py | 31 +++++++++++++++++++++--- tests/test_create_space.py | 49 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/mantis_sdk/resources.py b/mantis_sdk/resources.py index 5026256..d2a6ac7 100644 --- a/mantis_sdk/resources.py +++ b/mantis_sdk/resources.py @@ -26,6 +26,24 @@ VariationCallback = Callable[[dict], str] +def _visibility_from_privacy_level( + privacy_level: SpacePrivacy | str | None, + visibility: str | None, +) -> str: + """Resolve the backend `visibility` field ('private' | 'unlisted'). + + The backend hard-rejects the legacy `is_public` field (and 'public' as a + visibility — public is set only by backend code). Map the legacy + SpacePrivacy enum onto the two user-settable values: PUBLIC requests a + link-shareable space ('unlisted'), PRIVATE/SHARED stay 'private'. + An explicit `visibility=` argument wins over `privacy_level=`. + """ + if visibility is not None: + return str(visibility) + level = str(privacy_level) if privacy_level is not None else str(SpacePrivacy.PRIVATE) + return "unlisted" if level == str(SpacePrivacy.PUBLIC) else "private" + + class SpaceHandle(dict): """rich handle for a created/opened space. @@ -130,6 +148,7 @@ def create( custom_models: list[str | None] | None = None, reducer: ReducerModels | str = ReducerModels.UMAP, privacy_level: SpacePrivacy | str = SpacePrivacy.PRIVATE, + visibility: str | None = None, ai_provider: AIProvider | str = AIProvider.OpenAI, chat_model: str = "gpt-4o-mini", embedding_model: str = "text-embedding-3-small", @@ -153,7 +172,12 @@ def create( than creating a new one) — this is how you keep one space with a fixed set of maps. map_name names the map; without it the backend falls back to "Untitled Map". defaults - to space_name so a single-map space gets a sensible label out of the box.""" + to space_name so a single-map space gets a sensible label out of the box. + + privacy_level/visibility control the space's access tier. the backend no longer + accepts the legacy is_public field: it wants `visibility` in {'private','unlisted'} + ('public' is set only by backend code). SpacePrivacy.PUBLIC maps to 'unlisted' + (link-shareable); pass visibility='unlisted'/'private' explicitly to override.""" buffer, columns, file_extension = self._load_data(data) data_types_sanitized = self._sanitize_data_types(columns, data_types) @@ -175,7 +199,7 @@ def create( "space_id": space_id, "space_name": space_name, "map_name": map_name or space_name, # backend defaults to "Untitled Map" if omitted - "is_public": str(str(privacy_level) == str(SpacePrivacy.PUBLIC)).lower(), + "visibility": _visibility_from_privacy_level(privacy_level, visibility), "red_model": str(reducer), "custom_models": json.dumps(custom_models), "data_types": json.dumps(data_types_sanitized), @@ -212,6 +236,7 @@ def from_github( space_name: str | None = None, *, privacy_level: SpacePrivacy | str = SpacePrivacy.PRIVATE, + visibility: str | None = None, on_progress: ProgressCallback | None = None, show_progress: bool = False, wait: bool = True, @@ -223,7 +248,7 @@ def from_github( "space_id": space_id, "space_name": space_name or repo_url.rstrip("/").split("/")[-1], "repo_url": repo_url, - "is_public": str(str(privacy_level) == str(SpacePrivacy.PUBLIC)).lower(), + "visibility": _visibility_from_privacy_level(privacy_level, visibility), **extra, } resp = self.http.request("POST", "/synthesis/github", json=payload) diff --git a/tests/test_create_space.py b/tests/test_create_space.py index d50a41b..a20a5c9 100644 --- a/tests/test_create_space.py +++ b/tests/test_create_space.py @@ -87,3 +87,52 @@ def test_create_space_no_wait_skips_polling(client, transport): ) assert handle.map_id == "m1" assert len(transport.calls) == 1 # only the POST, no progress polls. + + +# --- visibility contract (backend rejects legacy is_public since #1958) --- + +def _posted_form(transport): + kwargs = transport.calls[0]["kwargs"] + return kwargs.get("data") or kwargs.get("json") + + +def test_create_space_sends_visibility_not_is_public(client, transport): + transport.queue = [{"map_id": "m1", "space_id": "s1"}] + client.spaces.create("t", _df(), {"A": DataType.Title, "B": DataType.Semantic}, wait=False) + form = _posted_form(transport) + assert form is not None + assert "is_public" not in form, "legacy is_public field is hard-rejected by the backend" + assert form.get("visibility") == "private" # SpacePrivacy.PRIVATE default + + +def test_create_space_public_privacy_maps_to_unlisted(client, transport): + # 'public' is not user-settable on the backend; PUBLIC maps to 'unlisted' + # (link-shareable) so the request is accepted instead of 400-ing. + transport.queue = [{"map_id": "m1", "space_id": "s1"}] + client.spaces.create( + "t", _df(), {"A": DataType.Title, "B": DataType.Semantic}, + privacy_level="public", wait=False, + ) + form = _posted_form(transport) + assert form.get("visibility") == "unlisted" + + +def test_create_space_explicit_visibility_wins(client, transport): + transport.queue = [{"map_id": "m1", "space_id": "s1"}] + client.spaces.create( + "t", _df(), {"A": DataType.Title, "B": DataType.Semantic}, + privacy_level="public", visibility="private", wait=False, + ) + form = _posted_form(transport) + assert form.get("visibility") == "private" + + +def test_from_github_sends_visibility_not_is_public(client, transport): + transport.queue = [ + {"map_id": "m1", "space_id": "s1"}, + {"progress": 100, "completed": True, "error": None}, + ] + client.spaces.from_github("https://github.com/o/r") + payload = _posted_form(transport) + assert "is_public" not in payload + assert payload.get("visibility") == "private"