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
31 changes: 28 additions & 3 deletions mantis_sdk/resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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",
Expand All @@ -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)
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
49 changes: 49 additions & 0 deletions tests/test_create_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading