From 1589aedf5e14709deb621ae78dd4ed35a9aab29d Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 11 Nov 2026 09:57:01 +0000 Subject: [PATCH 1/4] Add project pad creation and environment discovery --- README.rst | 12 ++ newsfragments/language-discovery.change.md | 1 + newsfragments/project-pads.change.md | 1 + spec | 2 +- src/coderpad/async_client.py | 92 +++++++++++- src/coderpad/client.py | 92 +++++++++++- tests/pad_fixtures.py | 33 +++++ tests/test_languages.py | 82 +++++++++++ tests/test_pad_controls.py | 37 +---- tests/test_project_pads.py | 160 +++++++++++++++++++++ 10 files changed, 464 insertions(+), 48 deletions(-) create mode 100644 newsfragments/language-discovery.change.md create mode 100644 newsfragments/project-pads.change.md create mode 100644 tests/pad_fixtures.py create mode 100644 tests/test_languages.py create mode 100644 tests/test_project_pads.py diff --git a/README.rst b/README.rst index 61555cf..2ed94cb 100644 --- a/README.rst +++ b/README.rst @@ -132,3 +132,15 @@ See the `full documentation `__. .. |PyPI| image:: https://badge.fury.io/py/coderpad-py.svg :target: https://badge.fury.io/py/coderpad-py .. |minimum-python-version| replace:: 3.12 + +Environment discovery +--------------------- + +Use ``client.languages.list()`` to discover available environments and ``client.languages.get(language="multifile_python")`` to retrieve starter code and environment context before authoring a project. +Both methods return the complete catalogue JSON and are also available on ``AsyncCoderPad``. + +Create a project pad with ``client.pads.create(language="multifile_python")``. +Pass ``file_contents`` using ``QuestionFileContent`` overlays, or pass a ``zip_file`` or ``directory`` path to replace the template files. +A question ID and starter content are mutually exclusive. +Project pad responses can have null language and contents. +Retrieve files through ``client.pads.get_environment(environment_id=...)`` using the pad's environment IDs. diff --git a/newsfragments/language-discovery.change.md b/newsfragments/language-discovery.change.md new file mode 100644 index 0000000..fe860e7 --- /dev/null +++ b/newsfragments/language-discovery.change.md @@ -0,0 +1 @@ +Discover available environments and starter code with client.languages.list() and client.languages.get(language=...) in both clients, preserving the complete catalogue JSON. diff --git a/newsfragments/project-pads.change.md b/newsfragments/project-pads.change.md new file mode 100644 index 0000000..ff55939 --- /dev/null +++ b/newsfragments/project-pads.change.md @@ -0,0 +1 @@ +Create project-template pads with starter-file overlays, ZIP archives, or directories in both synchronous and asynchronous clients. diff --git a/spec b/spec index 2693a9a..870c895 160000 --- a/spec +++ b/spec @@ -1 +1 @@ -Subproject commit 2693a9ac219e18092d2c5abd662e99a8a6d75a8a +Subproject commit 870c895b2867898c8e90f68783b0d8e13a778d80 diff --git a/src/coderpad/async_client.py b/src/coderpad/async_client.py index a68d1e9..8feb153 100644 --- a/src/coderpad/async_client.py +++ b/src/coderpad/async_client.py @@ -9,6 +9,7 @@ from pathlib import Path from types import TracebackType from typing import Literal, Self +from urllib.parse import quote import httpx from beartype import beartype @@ -134,6 +135,40 @@ async def _request( return response +@beartype +class AsyncLanguagesNamespace(_AsyncNamespace): + """Discover available environments and their starter code. + + Return catalogue JSON unchanged, retaining new environment metadata. + """ + + async def list(self) -> JsonValue: + """Retrieve the available language and project environments.""" + response = await self._request( + method="GET", + url="/api/languages", + params=None, + data=None, + files=None, + ) + return TypeAdapter(type=JsonValue).validate_python(response.json()) + + async def get(self, *, language: str) -> JsonValue: + """Retrieve starter code and environment context for an identifier.""" + if language == "" or language in {".", ".."}: + message = "language must be a nonempty environment identifier." + raise ValueError(message) + identifier = quote(string=language, safe="") + response = await self._request( + method="GET", + url=f"/api/languages/{identifier}", + params=None, + data=None, + files=None, + ) + return TypeAdapter(type=JsonValue).validate_python(response.json()) + + @beartype class AsyncUserNamespace(_AsyncNamespace): """Information about the Interview API key owner.""" @@ -261,6 +296,9 @@ async def create( title: str | None = None, language: Language | str | None = None, contents: str | None = None, + file_contents: Sequence[QuestionFileContent] | None = None, + zip_file: Path | None = None, + directory: Path | None = None, notes: str | None = None, question_id: str | int | None = None, private: bool | None = None, @@ -279,7 +317,11 @@ async def create( Args: title: Title for the pad. language: Programming language for the pad. - contents: Initial contents of the pad editor. + contents: Initial contents of a single-file pad. + file_contents: Project starter files layered over the template. + zip_file: Project ZIP archive replacing the template files. + directory: Project directory uploaded as a ZIP archive. + Content sources and question_id are mutually exclusive. notes: Private notes for the interviewer. question_id: Id of an existing question to seed the pad from. @@ -298,6 +340,18 @@ async def create( Returns: The created pad. """ + validate_mutually_exclusive_question_content( + contents=contents, + file_contents=file_contents, + zip_file=zip_file, + directory=directory, + ) + if question_id is not None and any( + value is not None + for value in (contents, file_contents, zip_file, directory) + ): + message = "question_id cannot be combined with starter content." + raise ValueError(message) data: dict[str, str] = {} if title is not None: data["title"] = title @@ -325,12 +379,33 @@ async def create( } ) ) - response = await self._pad_request( - method="POST", - url="/api/pads/", - data=data, - allowed_interviewer_emails=allowed_interviewer_emails, - ) + if file_contents is not None: + data["file_contents"] = json.dumps( + obj=[ + file.model_dump(exclude_none=True) + for file in file_contents + ] + ) + archive = question_upload_files(zip_file=zip_file, directory=directory) + if archive is not None: + if allowed_interviewer_emails is not None: + data["allowed_interviewer_emails"] = json.dumps( + obj=list(allowed_interviewer_emails) + ) + response = await self._request( + method="POST", + url="/api/pads/", + params=None, + data={f"pad[{name}]": value for name, value in data.items()}, + files={"pad[zip_file]": archive["question[zip_file]"]}, + ) + else: + response = await self._pad_request( + method="POST", + url="/api/pads/", + data=data, + allowed_interviewer_emails=allowed_interviewer_emails, + ) return Pad.from_dict( data=TypeAdapter(type=PadDict).validate_python(response.json()), ) @@ -1313,6 +1388,9 @@ def __init__( **(default_headers if default_headers is not None else {}), "Authorization": f'Token token="{api_key}"', } + self.languages: AsyncLanguagesNamespace = AsyncLanguagesNamespace( + transport=resolved_transport, base_url=base_url, headers=headers + ) self.user: AsyncUserNamespace = AsyncUserNamespace( transport=resolved_transport, base_url=base_url, headers=headers ) diff --git a/src/coderpad/client.py b/src/coderpad/client.py index 1309f1b..6d47189 100644 --- a/src/coderpad/client.py +++ b/src/coderpad/client.py @@ -8,6 +8,7 @@ from pathlib import Path from types import TracebackType from typing import Literal, Self +from urllib.parse import quote import httpx from beartype import beartype @@ -132,6 +133,40 @@ def _request( return response +@beartype +class LanguagesNamespace(_Namespace): + """Discover available environments and their starter code. + + Return catalogue JSON unchanged, retaining new environment metadata. + """ + + def list(self) -> JsonValue: + """Retrieve the available language and project environments.""" + response = self._request( + method="GET", + url="/api/languages", + params=None, + data=None, + files=None, + ) + return TypeAdapter(type=JsonValue).validate_python(response.json()) + + def get(self, *, language: str) -> JsonValue: + """Retrieve starter code and environment context for an identifier.""" + if language == "" or language in {".", ".."}: + message = "language must be a nonempty environment identifier." + raise ValueError(message) + identifier = quote(string=language, safe="") + response = self._request( + method="GET", + url=f"/api/languages/{identifier}", + params=None, + data=None, + files=None, + ) + return TypeAdapter(type=JsonValue).validate_python(response.json()) + + @beartype class UserNamespace(_Namespace): """Information about the Interview API key owner.""" @@ -258,6 +293,9 @@ def create( title: str | None = None, language: Language | str | None = None, contents: str | None = None, + file_contents: Sequence[QuestionFileContent] | None = None, + zip_file: Path | None = None, + directory: Path | None = None, notes: str | None = None, question_id: str | int | None = None, private: bool | None = None, @@ -276,7 +314,11 @@ def create( Args: title: Title for the pad. language: Programming language for the pad. - contents: Initial contents of the pad editor. + contents: Initial contents of a single-file pad. + file_contents: Project starter files layered over the template. + zip_file: Project ZIP archive replacing the template files. + directory: Project directory uploaded as a ZIP archive. + Content sources and question_id are mutually exclusive. notes: Private notes for the interviewer. question_id: Id of an existing question to seed the pad from. @@ -295,6 +337,18 @@ def create( Returns: The created pad. """ + validate_mutually_exclusive_question_content( + contents=contents, + file_contents=file_contents, + zip_file=zip_file, + directory=directory, + ) + if question_id is not None and any( + value is not None + for value in (contents, file_contents, zip_file, directory) + ): + message = "question_id cannot be combined with starter content." + raise ValueError(message) data: dict[str, str] = {} if title is not None: data["title"] = title @@ -322,12 +376,33 @@ def create( } ) ) - response = self._pad_request( - method="POST", - url="/api/pads/", - data=data, - allowed_interviewer_emails=allowed_interviewer_emails, - ) + if file_contents is not None: + data["file_contents"] = json.dumps( + obj=[ + file.model_dump(exclude_none=True) + for file in file_contents + ] + ) + archive = question_upload_files(zip_file=zip_file, directory=directory) + if archive is not None: + if allowed_interviewer_emails is not None: + data["allowed_interviewer_emails"] = json.dumps( + obj=list(allowed_interviewer_emails) + ) + response = self._request( + method="POST", + url="/api/pads/", + params=None, + data={f"pad[{name}]": value for name, value in data.items()}, + files={"pad[zip_file]": archive["question[zip_file]"]}, + ) + else: + response = self._pad_request( + method="POST", + url="/api/pads/", + data=data, + allowed_interviewer_emails=allowed_interviewer_emails, + ) return Pad.from_dict( data=TypeAdapter(type=PadDict).validate_python(response.json()), ) @@ -1287,6 +1362,9 @@ def __init__( **(default_headers if default_headers is not None else {}), "Authorization": f'Token token="{api_key}"', } + self.languages: LanguagesNamespace = LanguagesNamespace( + transport=resolved_transport, base_url=base_url, headers=headers + ) self.user: UserNamespace = UserNamespace( transport=resolved_transport, base_url=base_url, headers=headers ) diff --git a/tests/pad_fixtures.py b/tests/pad_fixtures.py new file mode 100644 index 0000000..c669469 --- /dev/null +++ b/tests/pad_fixtures.py @@ -0,0 +1,33 @@ +"""Shared synthetic Interview pad responses.""" + +from coderpad.json_types import JsonValue + + +def pad_payload() -> dict[str, JsonValue]: + """Provide a complete pad with optional access metadata.""" + return { + "id": "abcdefgh", + "title": "Interview", + "state": "active", + "owner_email": "owner@example.com", + "language": "python3", + "private": False, + "execution_enabled": False, + "contents": "", + "participants": [], + "events": "", + "notes": "", + "created_at": "2026-10-06T12:00:00Z", + "updated_at": "2026-10-06T12:00:00Z", + "ended_at": None, + "url": "https://app.coderpad.io/abcdefgh", + "playback": "https://app.coderpad.io/abcdefgh/playback", + "drawing": None, + "type": "interview", + "question_ids": [123], + "pad_environment_ids": [], + "active_environment_id": None, + "team": {"id": "team-1", "name": "Backend"}, + "allowed_interviewer_emails": [], + "restrict_interviewer_access": False, + } diff --git a/tests/test_languages.py b/tests/test_languages.py new file mode 100644 index 0000000..e9b5977 --- /dev/null +++ b/tests/test_languages.py @@ -0,0 +1,82 @@ +"""Catalogue requests preserve new metadata and keep identifiers in one +path segment. +""" + +import httpx +import pytest +import respx + +from coderpad import AsyncCoderPad, AuthenticationError, CoderPad +from coderpad.json_types import JsonValue + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames="client_type", argvalues=[CoderPad, AsyncCoderPad] +) +async def test_language_catalogue( + client_type: type[CoderPad] | type[AsyncCoderPad], +) -> None: + """Return complete catalogue and starter metadata without narrowing + fields. + """ + payload = { + "status": "OK", + "environments": [ + {"slug": "multifile_python", "new_metadata": [1, None]} + ], + } + client = client_type(api_key="test-key") + with respx.mock() as router: + listing = router.get(url="https://app.coderpad.io/api/languages").mock( + return_value=httpx.Response(status_code=200, json=payload) + ) + detail = router.get( + url="https://app.coderpad.io/api/languages/multifile_python" + ).mock( + return_value=httpx.Response( + status_code=200, json={"starter": {"main.py": ""}} + ) + ) + assert await _language(client=client, language=None) == payload + assert await _language(client=client, language="multifile_python") == { + "starter": {"main.py": ""} + } + assert listing.calls.last.request.method == "GET" + assert ( + detail.calls.last.request.headers["Authorization"] + == 'Token token="test-key"' + ) + for language in ("", ".", ".."): + with pytest.raises( + expected_exception=ValueError, match="identifier" + ): + await _language(client=client, language=language) + _ = router.get(url="https://app.coderpad.io/api/languages").mock( + return_value=httpx.Response( + status_code=401, json={"status": "ERROR"} + ) + ) + with pytest.raises(expected_exception=AuthenticationError): + await _language(client=client, language=None) + if isinstance(client, AsyncCoderPad): + await client.aclose() + else: + client.close() + + +async def _language( + client: CoderPad | AsyncCoderPad, language: str | None +) -> JsonValue: + """Read through either public client with its declared result type.""" + if isinstance(client, AsyncCoderPad): + return ( + await client.languages.list() + if language is None + else await client.languages.get(language=language) + ) + return ( + client.languages.list() + if language is None + else client.languages.get(language=language) + ) diff --git a/tests/test_pad_controls.py b/tests/test_pad_controls.py index de9afb8..088d5bb 100644 --- a/tests/test_pad_controls.py +++ b/tests/test_pad_controls.py @@ -14,6 +14,7 @@ from coderpad.exceptions import CoderPadError from coderpad.json_types import JsonValue from coderpad.types import Pad +from tests.pad_fixtures import pad_payload _EMAIL_CASES: list[list[str] | None] = [ None, @@ -23,36 +24,6 @@ _URL = "https://app.coderpad.io/api/pads/" -def _pad_payload() -> dict[str, JsonValue]: - """Provide a complete pad with optional access metadata.""" - return { - "id": "abcdefgh", - "title": "Interview", - "state": "active", - "owner_email": "owner@example.com", - "language": "python3", - "private": False, - "execution_enabled": False, - "contents": "", - "participants": [], - "events": "", - "notes": "", - "created_at": "2026-10-06T12:00:00Z", - "updated_at": "2026-10-06T12:00:00Z", - "ended_at": None, - "url": "https://app.coderpad.io/abcdefgh", - "playback": "https://app.coderpad.io/abcdefgh/playback", - "drawing": None, - "type": "interview", - "question_ids": [123], - "pad_environment_ids": [], - "active_environment_id": None, - "team": {"id": "team-1", "name": "Backend"}, - "allowed_interviewer_emails": [], - "restrict_interviewer_access": False, - } - - def _assert_request( *, request: httpx.Request, @@ -122,7 +93,7 @@ def test_pad_controls( route = router.request( method="POST" if operation == "create" else "PUT", url=_URL if operation == "create" else _URL + "abcdefgh", - ).respond(status_code=200, json=_pad_payload()) + ).respond(status_code=200, json=pad_payload()) with CoderPad(api_key="synthetic-key") as client: if operation == "create": pad = client.pads.create( @@ -181,7 +152,7 @@ async def test_async_pad_controls( route = router.request( method="POST" if operation == "create" else "PUT", url=_URL if operation == "create" else _URL + "abcdefgh", - ).respond(status_code=200, json=_pad_payload()) + ).respond(status_code=200, json=pad_payload()) async with AsyncCoderPad(api_key="synthetic-key") as client: if operation == "create": pad = await client.pads.create( @@ -273,7 +244,7 @@ async def test_async_pad_control_errors(operation: str) -> None: @pytest.mark.parametrize(argnames="emails", argvalues=_EMAIL_CASES) def test_pad_access_metadata(emails: list[str] | None) -> None: """Both pad decoders preserve optional response lists exactly.""" - payload = _pad_payload() + payload = pad_payload() payload["allowed_interviewer_emails"] = ( None if emails is None else list[JsonValue](emails) ) diff --git a/tests/test_project_pads.py b/tests/test_project_pads.py new file mode 100644 index 0000000..991d409 --- /dev/null +++ b/tests/test_project_pads.py @@ -0,0 +1,160 @@ +"""Project-pad payloads preserve overlays and the multipart ZIP +contract. +""" + +import json +from collections.abc import Sequence +from pathlib import Path +from urllib.parse import parse_qs + +import httpx +import pytest +import respx + +from coderpad import AsyncCoderPad, CoderPad, Pad, QuestionFileContent +from tests.pad_fixtures import pad_payload + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames="client_type", argvalues=[CoderPad, AsyncCoderPad] +) +@pytest.mark.parametrize( + argnames="source", argvalues=["template", "files", "zip", "directory"] +) +@pytest.mark.parametrize(argnames="emails", argvalues=[None, []]) +async def test_project_pad( + client_type: type[CoderPad] | type[AsyncCoderPad], + source: str, + emails: list[str] | None, + tmp_path: Path, +) -> None: + """Exercise every content transport with nullable project response + fields. + """ + payload = pad_payload() + payload.update( + {"language": None, "contents": None, "pad_environment_ids": [42]} + ) + archive = tmp_path / "project.zip" + _ = archive.write_bytes(data=b"project archive") + project = tmp_path / "project" + project.mkdir() + _ = (project / "main.py").write_text(data="print(1)") + with respx.mock() as router: + route = router.post(url="https://app.coderpad.io/api/pads/").mock( + return_value=httpx.Response(status_code=200, json=payload) + ) + client = client_type(api_key="test-key") + pad = await _create( + client=client, + contents=None, + question_id=None, + language="multifile_python", + file_contents=[ + QuestionFileContent(path="main.py", contents="", hidden=True), + QuestionFileContent(path="old.py", deleted=True), + ] + if source == "files" + else None, + zip_file=archive if source == "zip" else None, + directory=project if source == "directory" else None, + allowed_interviewer_emails=emails, + ) + if isinstance(client, AsyncCoderPad): + await client.aclose() + else: + client.close() + assert pad.language is None + assert pad.contents is None + assert pad.pad_environment_ids == [42] + request = route.calls.last.request + if source in {"zip", "directory"}: + assert 'name="pad[zip_file]"' in request.content.decode( + encoding="latin1" + ) + assert 'name="pad[language]"' in request.content.decode( + encoding="latin1" + ) + assert b"question[" not in request.content + else: + data = ( + json.loads(s=request.content)["pad"] + if emails is not None + else { + name: values[0] + for name, values in parse_qs( + qs=request.content.decode() + ).items() + } + ) + assert data["language"] == "multifile_python" + if source == "files": + assert json.loads(s=data["file_contents"]) == [ + {"path": "main.py", "contents": "", "hidden": True}, + {"path": "old.py", "deleted": True}, + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + argnames="client_type", argvalues=[CoderPad, AsyncCoderPad] +) +@pytest.mark.parametrize( + argnames="source", argvalues=["contents", "files", "zip", "directory"] +) +async def test_question_seed_rejects_starter_content( + client_type: type[CoderPad] | type[AsyncCoderPad], + source: str, + tmp_path: Path, +) -> None: + """Reject ambiguous creation before reading files or sending a request.""" + client = client_type(api_key="test-key") + with pytest.raises(expected_exception=ValueError, match="question_id"): + await _create( + client=client, + language=None, + question_id=1, + contents="" if source == "contents" else None, + file_contents=[] if source == "files" else None, + zip_file=tmp_path if source == "zip" else None, + directory=tmp_path if source == "directory" else None, + allowed_interviewer_emails=None, + ) + if isinstance(client, AsyncCoderPad): + await client.aclose() + else: + client.close() + + +async def _create( + *, + client: CoderPad | AsyncCoderPad, + language: str | None, + contents: str | None, + question_id: int | None, + file_contents: Sequence[QuestionFileContent] | None, + zip_file: Path | None, + directory: Path | None, + allowed_interviewer_emails: list[str] | None, +) -> Pad: + """Exercise each public client without losing its declared result type.""" + if isinstance(client, AsyncCoderPad): + return await client.pads.create( + language=language, + contents=contents, + question_id=question_id, + file_contents=file_contents, + zip_file=zip_file, + directory=directory, + allowed_interviewer_emails=allowed_interviewer_emails, + ) + return client.pads.create( + language=language, + contents=contents, + question_id=question_id, + file_contents=file_contents, + zip_file=zip_file, + directory=directory, + allowed_interviewer_emails=allowed_interviewer_emails, + ) From 35a780d11c9f569fe44145069137cc73d9804ab8 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 11 Nov 2026 09:59:11 +0000 Subject: [PATCH 2/4] Preserve JSON access-list semantics for project creation --- src/coderpad/async_client.py | 15 +++++++++++---- src/coderpad/client.py | 15 +++++++++++---- tests/test_project_pads.py | 22 +++++++++++++++++++++- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/coderpad/async_client.py b/src/coderpad/async_client.py index 8feb153..de129ed 100644 --- a/src/coderpad/async_client.py +++ b/src/coderpad/async_client.py @@ -322,6 +322,8 @@ async def create( zip_file: Project ZIP archive replacing the template files. directory: Project directory uploaded as a ZIP archive. Content sources and question_id are mutually exclusive. + ZIP and directory uploads cannot set an interviewer access + list; use file_contents when setting that list. notes: Private notes for the interviewer. question_id: Id of an existing question to seed the pad from. @@ -386,12 +388,17 @@ async def create( for file in file_contents ] ) + if allowed_interviewer_emails is not None and ( + zip_file is not None or directory is not None + ): + message = ( + "Use file_contents to combine project creation with " + "allowed_interviewer_emails; ZIP uploads do not support " + "replacement access lists." + ) + raise ValueError(message) archive = question_upload_files(zip_file=zip_file, directory=directory) if archive is not None: - if allowed_interviewer_emails is not None: - data["allowed_interviewer_emails"] = json.dumps( - obj=list(allowed_interviewer_emails) - ) response = await self._request( method="POST", url="/api/pads/", diff --git a/src/coderpad/client.py b/src/coderpad/client.py index 6d47189..f4dfa7e 100644 --- a/src/coderpad/client.py +++ b/src/coderpad/client.py @@ -319,6 +319,8 @@ def create( zip_file: Project ZIP archive replacing the template files. directory: Project directory uploaded as a ZIP archive. Content sources and question_id are mutually exclusive. + ZIP and directory uploads cannot set an interviewer access + list; use file_contents when setting that list. notes: Private notes for the interviewer. question_id: Id of an existing question to seed the pad from. @@ -383,12 +385,17 @@ def create( for file in file_contents ] ) + if allowed_interviewer_emails is not None and ( + zip_file is not None or directory is not None + ): + message = ( + "Use file_contents to combine project creation with " + "allowed_interviewer_emails; ZIP uploads do not support " + "replacement access lists." + ) + raise ValueError(message) archive = question_upload_files(zip_file=zip_file, directory=directory) if archive is not None: - if allowed_interviewer_emails is not None: - data["allowed_interviewer_emails"] = json.dumps( - obj=list(allowed_interviewer_emails) - ) response = self._request( method="POST", url="/api/pads/", diff --git a/tests/test_project_pads.py b/tests/test_project_pads.py index 991d409..6a257a6 100644 --- a/tests/test_project_pads.py +++ b/tests/test_project_pads.py @@ -41,11 +41,31 @@ async def test_project_pad( project = tmp_path / "project" project.mkdir() _ = (project / "main.py").write_text(data="print(1)") - with respx.mock() as router: + with respx.mock(assert_all_called=False) as router: route = router.post(url="https://app.coderpad.io/api/pads/").mock( return_value=httpx.Response(status_code=200, json=payload) ) client = client_type(api_key="test-key") + if emails is not None and source in {"zip", "directory"}: + with pytest.raises( + expected_exception=ValueError, match="access lists" + ): + await _create( + client=client, + language="multifile_python", + contents=None, + question_id=None, + file_contents=None, + zip_file=archive if source == "zip" else None, + directory=project if source == "directory" else None, + allowed_interviewer_emails=emails, + ) + if isinstance(client, AsyncCoderPad): + await client.aclose() + else: + client.close() + assert len(router.calls) == 0 + return pad = await _create( client=client, contents=None, From d3e6e6ee554077c2b6726b08aaf79ba60dc18c0d Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 11 Nov 2026 10:00:58 +0000 Subject: [PATCH 3/4] Use the documentation dictionary spelling --- README.rst | 2 +- newsfragments/language-discovery.change.md | 2 +- src/coderpad/async_client.py | 2 +- src/coderpad/client.py | 2 +- tests/test_languages.py | 6 +++--- tests/test_project_pads.py | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index 2ed94cb..4ccc178 100644 --- a/README.rst +++ b/README.rst @@ -137,7 +137,7 @@ Environment discovery --------------------- Use ``client.languages.list()`` to discover available environments and ``client.languages.get(language="multifile_python")`` to retrieve starter code and environment context before authoring a project. -Both methods return the complete catalogue JSON and are also available on ``AsyncCoderPad``. +Both methods return the complete catalog JSON and are also available on ``AsyncCoderPad``. Create a project pad with ``client.pads.create(language="multifile_python")``. Pass ``file_contents`` using ``QuestionFileContent`` overlays, or pass a ``zip_file`` or ``directory`` path to replace the template files. diff --git a/newsfragments/language-discovery.change.md b/newsfragments/language-discovery.change.md index fe860e7..d755922 100644 --- a/newsfragments/language-discovery.change.md +++ b/newsfragments/language-discovery.change.md @@ -1 +1 @@ -Discover available environments and starter code with client.languages.list() and client.languages.get(language=...) in both clients, preserving the complete catalogue JSON. +Discover available environments and starter code with client.languages.list() and client.languages.get(language=...) in both clients, preserving the complete catalog JSON. diff --git a/src/coderpad/async_client.py b/src/coderpad/async_client.py index de129ed..45779f7 100644 --- a/src/coderpad/async_client.py +++ b/src/coderpad/async_client.py @@ -139,7 +139,7 @@ async def _request( class AsyncLanguagesNamespace(_AsyncNamespace): """Discover available environments and their starter code. - Return catalogue JSON unchanged, retaining new environment metadata. + Return catalog JSON unchanged, retaining new environment metadata. """ async def list(self) -> JsonValue: diff --git a/src/coderpad/client.py b/src/coderpad/client.py index f4dfa7e..c9e7202 100644 --- a/src/coderpad/client.py +++ b/src/coderpad/client.py @@ -137,7 +137,7 @@ def _request( class LanguagesNamespace(_Namespace): """Discover available environments and their starter code. - Return catalogue JSON unchanged, retaining new environment metadata. + Return catalog JSON unchanged, retaining new environment metadata. """ def list(self) -> JsonValue: diff --git a/tests/test_languages.py b/tests/test_languages.py index e9b5977..dc71d25 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -1,4 +1,4 @@ -"""Catalogue requests preserve new metadata and keep identifiers in one +"""Catalog requests preserve new metadata and keep identifiers in one path segment. """ @@ -14,10 +14,10 @@ @pytest.mark.parametrize( argnames="client_type", argvalues=[CoderPad, AsyncCoderPad] ) -async def test_language_catalogue( +async def test_language_catalog( client_type: type[CoderPad] | type[AsyncCoderPad], ) -> None: - """Return complete catalogue and starter metadata without narrowing + """Return complete catalog and starter metadata without narrowing fields. """ payload = { diff --git a/tests/test_project_pads.py b/tests/test_project_pads.py index 6a257a6..505f50a 100644 --- a/tests/test_project_pads.py +++ b/tests/test_project_pads.py @@ -29,7 +29,7 @@ async def test_project_pad( emails: list[str] | None, tmp_path: Path, ) -> None: - """Exercise every content transport with nullable project response + """Exercise every content transport with null project response fields. """ payload = pad_payload() From d87975342ef1e15ded03489dc306a9f8d6a1bb90 Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 11 Nov 2026 10:03:21 +0000 Subject: [PATCH 4/4] Attach release notes to the project support pull request --- newsfragments/{language-discovery.change.md => 600.change.md} | 2 ++ newsfragments/project-pads.change.md | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) rename newsfragments/{language-discovery.change.md => 600.change.md} (56%) delete mode 100644 newsfragments/project-pads.change.md diff --git a/newsfragments/language-discovery.change.md b/newsfragments/600.change.md similarity index 56% rename from newsfragments/language-discovery.change.md rename to newsfragments/600.change.md index d755922..b1ca85f 100644 --- a/newsfragments/language-discovery.change.md +++ b/newsfragments/600.change.md @@ -1 +1,3 @@ +Create project-template pads with starter-file overlays, ZIP archives, or directories in both synchronous and asynchronous clients. + Discover available environments and starter code with client.languages.list() and client.languages.get(language=...) in both clients, preserving the complete catalog JSON. diff --git a/newsfragments/project-pads.change.md b/newsfragments/project-pads.change.md deleted file mode 100644 index ff55939..0000000 --- a/newsfragments/project-pads.change.md +++ /dev/null @@ -1 +0,0 @@ -Create project-template pads with starter-file overlays, ZIP archives, or directories in both synchronous and asynchronous clients.