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
12 changes: 12 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,15 @@ See the `full documentation <https://adamtheturtle.github.io/coderpad/>`__.
.. |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 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.
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.
3 changes: 3 additions & 0 deletions newsfragments/600.change.md
Original file line number Diff line number Diff line change
@@ -0,0 +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.
2 changes: 1 addition & 1 deletion spec
99 changes: 92 additions & 7 deletions src/coderpad/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -134,6 +135,40 @@ async def _request(
return response


@beartype
class AsyncLanguagesNamespace(_AsyncNamespace):
"""Discover available environments and their starter code.

Return catalog 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."""
Expand Down Expand Up @@ -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,
Expand All @@ -279,7 +317,13 @@ 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.
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.
Expand All @@ -298,6 +342,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
Expand Down Expand Up @@ -325,12 +381,38 @@ 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
]
)
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:
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()),
)
Expand Down Expand Up @@ -1313,6 +1395,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
)
Expand Down
99 changes: 92 additions & 7 deletions src/coderpad/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -132,6 +133,40 @@ def _request(
return response


@beartype
class LanguagesNamespace(_Namespace):
"""Discover available environments and their starter code.

Return catalog 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."""
Expand Down Expand Up @@ -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,
Expand All @@ -276,7 +314,13 @@ 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.
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.
Expand All @@ -295,6 +339,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
Expand Down Expand Up @@ -322,12 +378,38 @@ 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
]
)
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:
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()),
)
Expand Down Expand Up @@ -1287,6 +1369,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
)
Expand Down
33 changes: 33 additions & 0 deletions tests/pad_fixtures.py
Original file line number Diff line number Diff line change
@@ -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,
}
Loading
Loading