Skip to content

Extending the SDK

New resources and endpoints follow one pattern: a module in the service's v1/ package defines a ResourceCollection (the URL collection), a nested Resource (a single item), and thin methods that send one request each. This page is the toolkit + conventions; the reference for every base class is embedded below.

The three-line method

Endpoint methods send through the Transport verb helpers, keep the envelope key inline, and map records to models at the call site:

class Circles(ResourceCollection):
    path = "circles"

    def list(self) -> "list[campus.model.Circle]":
        body = self._get_json(self.make_path())          # GET; raises on 4xx/5xx
        return [campus.model.Circle.from_resource(item) for item in body["data"]]

    def new(self, *, name: str, tag: str, ...) -> campus.model.Circle:
        return campus.model.Circle.from_resource(self._post_json(
            self.make_path(),
            json=payload(name=name, tag=tag, description=description, parents=parents),
        ))

Helpers available on every resource class:

  • _get_json / _post_json / _put_json / _patch_json / _delete_json — send one verb, raise the mapped APIError on 4xx/5xx, return the parsed body. None query/json kwargs are omitted from the wire call.
  • payload(**fields) — request body dropping None fields (provided falsy values survive).
  • updates(**fields) — same, plus the non-empty guard PATCH routes require. Neither helper can send an explicit null — build bodies like {"submitted_at": null} by hand (see Submissions.unsubmit).
  • When a method must inspect the raw response first (map 404 → KeyError, shape a 400 error, observe rate-limit headers), skip the helpers and call self.client.<verb>() + raise_for_status() directly — see vaults.Vault.__getitem__, oauth.poll_for_token, traces.ingest.

Request bodies

json=payload(title=title, description=description, questions=questions)
  • An omitted (None) field is left unchanged upstream — the convention across Campus write routes.
  • Convert only methods where None means omit. Deliberate exceptions in the codebase: unsubmit (explicit null), timetable.new (nested metadata/data body), sessions.new (str() coercion), and required-only literals where the helper adds nothing.
  • Clients.update uses payload() without updates() — the auth server accepts its empty PATCHes; don't add guards the server doesn't have.

Paths

  • Collections end with a trailing slash (make_path default); item routes carry one too; dead-end action/sub-resource parts (submit, add, revoke, entries) do not — mirror the upstream Flask route rules (strict_slashes makes these load-bearing).
  • Collection path attrs carry no slashes (path = "circles").
  • join_path(*parts) strips ends and single-slashes the join; inner slashes survive — by-slug tails rely on that. Don't build paths by raw f-string; go through make_path.
  • Nested sub-resources are addressed by __getitem__ (id) and properties (fixed segment): Circles.Circle.CircleMembers("members", parent=self).

Response envelopes

Envelope keys (data, value, timetable, entries, users, clients, connections, grant, spans, key, secret, swept_count, ...) are upstream-driven and stay inline at each method — no hidden unwrap layer. A wrong key is a KeyError right in the method that owns it; the unit tests pin every shape.

By-slug surfaces

Resources whose records are addressed by a terminal slug path tail (files, hosted apps) subclass BySlugCollection instead, parameterized by record type. The raw-tail rule (slugs ride unquoted — the whole tail after by-slug/ is the slug) and the list/get_meta/get_content/put_by_slug/delete_by_slug surface are defined once:

class HostedApps(BySlugCollection[campus.model.HostedApp]):
    path = "hostedapps"

    def _from_resource(self, record: dict) -> campus.model.HostedApp:
        return campus.model.HostedApp.from_resource(record)

Dict-returning surfaces (Files(BySlugCollection[dict])) need no override at all.

Testing it

Unit tests drive a Mock client through the shared factories and pin exact recorded calls:

from tests.support import json_response, make_api

def test_list_unwraps_data_envelope(self):
    api, client = make_api()
    client.get.return_value = json_response({"data": [CIRCLE_RESOURCE]})
    circles = api.circles.list()
    client.get.assert_called_once_with("/api/v1/circles/")
    self.assertEqual(len(circles), 1)

json_response(body, status_code=400) raises the mapped APIError from raise_for_status() — use it for error-path tests.

Base-class reference

campus_python.interface

campus.client.interface

Interface descriptions for the Campus client interface.

This interface is designed to: - wrap flask.testing.FlaskClient - wrap most common client interfaces e.g. requests - provide a common Response interface that wraps werkzeug.test.TestResponse,requests.Response`, etc - so aa to enable WSGI hooks or unit testing with a local WSGI app.

ResourceRoot

Bases: Transport

Root of all resources.

This class is used to group all top-level resources together.

Source code in campus_python/interface.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
class ResourceRoot(Transport):
    """Root of all resources.

    This class is used to group all top-level resources together.
    """
    _client: Optional[JsonClient] = None
    url_prefix: str

    def __init__(self, json_client: Optional[JsonClient] = None):
        self._client = json_client

    @property
    def base_url(self) -> str:
        """Get the base URL for this resource root."""
        if not self._client:
            raise AttributeError("No client defined")
        return self._client.base_url

    @property
    def client(self) -> JsonClient:
        """Get the JsonClient associated with this resource root."""
        if not self._client:
            raise AttributeError(
                f"No client defined for {self}"
            )
        return self._client

    def make_path(self, part: str | None = None) -> str:
        """Create a full path for the resource root or a sub-resource.

        Only leading slashes are trimmed here: a url_prefix that
        intentionally ends with a slash (e.g. /integrations/v1/) keeps
        it, and a part with an inner path tail (e.g. broker's
        "google/classroom") is preserved verbatim.

        Args:
            part (str | None): Optional sub-resource or action path.
        Returns:
            str: Full path for the resource root or sub-resource.
        """
        if part:
            return f"/{self.url_prefix.lstrip(SLASH)}/{part.lstrip(SLASH)}"
        else:
            return f"/{self.url_prefix.lstrip(SLASH)}"

    def make_url(self) -> str:
        """Create a full path for the resource root."""
        return f"{self.base_url}/{self.url_prefix.lstrip(SLASH)}"

url_prefix: str instance-attribute

_client: Optional[JsonClient] = json_client class-attribute instance-attribute

base_url: str property

Get the base URL for this resource root.

client: JsonClient property

Get the JsonClient associated with this resource root.

__init__(json_client: Optional[JsonClient] = None)

Source code in campus_python/interface.py
153
154
def __init__(self, json_client: Optional[JsonClient] = None):
    self._client = json_client

make_path(part: str | None = None) -> str

Create a full path for the resource root or a sub-resource.

Only leading slashes are trimmed here: a url_prefix that intentionally ends with a slash (e.g. /integrations/v1/) keeps it, and a part with an inner path tail (e.g. broker's "google/classroom") is preserved verbatim.

Parameters:

Name Type Description Default
part str | None

Optional sub-resource or action path.

None

Returns: str: Full path for the resource root or sub-resource.

Source code in campus_python/interface.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
def make_path(self, part: str | None = None) -> str:
    """Create a full path for the resource root or a sub-resource.

    Only leading slashes are trimmed here: a url_prefix that
    intentionally ends with a slash (e.g. /integrations/v1/) keeps
    it, and a part with an inner path tail (e.g. broker's
    "google/classroom") is preserved verbatim.

    Args:
        part (str | None): Optional sub-resource or action path.
    Returns:
        str: Full path for the resource root or sub-resource.
    """
    if part:
        return f"/{self.url_prefix.lstrip(SLASH)}/{part.lstrip(SLASH)}"
    else:
        return f"/{self.url_prefix.lstrip(SLASH)}"

make_url() -> str

Create a full path for the resource root.

Source code in campus_python/interface.py
190
191
192
def make_url(self) -> str:
    """Create a full path for the resource root."""
    return f"{self.base_url}/{self.url_prefix.lstrip(SLASH)}"

ResourceCollection

Bases: Transport

Collection of resources.

This class is used to group related resources together.

Source code in campus_python/interface.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
class ResourceCollection(Transport):
    """Collection of resources.

    This class is used to group related resources together.
    """
    _client: Optional[JsonClient] = None
    path: str
    root: ResourceRoot

    def __init__(
            self,
            client: Optional[JsonClient] = None,
            *,
            root: ResourceRoot
    ):
        self._client = client
        self.root = root

    @property
    def client(self) -> JsonClient:
        """Get the JsonClient associated with this resource."""
        if self._client:
            return self._client
        if self.root.client:
            return self.root.client
        raise AttributeError(f"No client defined for {self}")

    def make_path(self, part: str | None = None, end_slash: bool = True) -> str:
        """Create a full path for a resource collection.

        Collection routes carry a trailing slash by default (the
        upstream API schema); item ids, sub-resources and action parts
        append after the collection segment.

        Args:
            part: Optional sub-resource or action path.
            end_slash: Whether to add a trailing slash.

        Returns:
            Full path for the resource collection or sub-resource.
        """
        if part:
            path = join_path(self.root.make_path(self.path), part)
        else:
            path = join_path(self.root.make_path(self.path))
        if end_slash and not path.endswith(SLASH):
            path += SLASH
        return path

    def make_url(self, part: str | None = None) -> str:
        """Create a full URL for the collection or a sub-resource."""
        return f"{self.client.base_url}{self.make_path(part)}"

path: str instance-attribute

_client: Optional[JsonClient] = client class-attribute instance-attribute

root: ResourceRoot = root instance-attribute

client: JsonClient property

Get the JsonClient associated with this resource.

__init__(client: Optional[JsonClient] = None, *, root: ResourceRoot)

Source code in campus_python/interface.py
204
205
206
207
208
209
210
211
def __init__(
        self,
        client: Optional[JsonClient] = None,
        *,
        root: ResourceRoot
):
    self._client = client
    self.root = root

make_path(part: str | None = None, end_slash: bool = True) -> str

Create a full path for a resource collection.

Collection routes carry a trailing slash by default (the upstream API schema); item ids, sub-resources and action parts append after the collection segment.

Parameters:

Name Type Description Default
part str | None

Optional sub-resource or action path.

None
end_slash bool

Whether to add a trailing slash.

True

Returns:

Type Description
str

Full path for the resource collection or sub-resource.

Source code in campus_python/interface.py
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
def make_path(self, part: str | None = None, end_slash: bool = True) -> str:
    """Create a full path for a resource collection.

    Collection routes carry a trailing slash by default (the
    upstream API schema); item ids, sub-resources and action parts
    append after the collection segment.

    Args:
        part: Optional sub-resource or action path.
        end_slash: Whether to add a trailing slash.

    Returns:
        Full path for the resource collection or sub-resource.
    """
    if part:
        path = join_path(self.root.make_path(self.path), part)
    else:
        path = join_path(self.root.make_path(self.path))
    if end_slash and not path.endswith(SLASH):
        path += SLASH
    return path

make_url(part: str | None = None) -> str

Create a full URL for the collection or a sub-resource.

Source code in campus_python/interface.py
244
245
246
def make_url(self, part: str | None = None) -> str:
    """Create a full URL for the collection or a sub-resource."""
    return f"{self.client.base_url}{self.make_path(part)}"

Resource

Bases: Transport

Resource class that represents API resources

The resource class uses a JsonClient instance to handle all API requests. It only tracks the path of the current resource.

Source code in campus_python/interface.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
class Resource(Transport):
    """Resource class that represents API resources

    The resource class uses a JsonClient instance to handle all API requests.
    It only tracks the path of the current resource.
    """
    _client: Optional[JsonClient] = None
    parent: "Resource | ResourceCollection"
    path: str

    def __init__(
            self,
            *parts: str,
            parent: "Resource | ResourceCollection",
            client: Optional[JsonClient] = None,
    ):
        self._client = client
        self.parent = parent
        self.path = parent.make_path(SLASH.join(parts))

    def __repr__(self) -> str:
        return f"Resource(client={self.client}, path={self.path})"

    def __str__(self) -> str:
        return self.path

    @property
    def client(self) -> JsonClient:
        """Get the JsonClient associated with this resource."""
        if self._client:
            return self._client
        if self.parent and self.parent.client:
            return self.parent.client
        raise AttributeError(f"No client defined for {self}")

    def make_path(self, part: str | None = None, end_slash=False) -> str:
        """Create a full path for a sub-resource or action.

        Item paths built from a parent collection carry the
        collection's trailing slash; dead-end sub-resource and action
        parts append after the item segment without one by default.
        With no part, the instance path is returned as constructed —
        only a leading slash is trimmed, so an item's trailing slash
        survives.
        """
        if part:
            path = join_path(self.path, part)
        else:
            path = SLASH + self.path.lstrip(SLASH)
        if end_slash and not path.endswith(SLASH):
            path += SLASH
        return path

    def make_url(self, part: str | None = None) -> str:
        """Create a full URL for this resource or a sub-resource."""
        return f"{self.client.base_url}{self.make_path(part)}"

_client: Optional[JsonClient] = client class-attribute instance-attribute

parent: Resource | ResourceCollection = parent instance-attribute

path: str = parent.make_path(SLASH.join(parts)) instance-attribute

client: JsonClient property

Get the JsonClient associated with this resource.

__init__(*parts: str, parent: Resource | ResourceCollection, client: Optional[JsonClient] = None)

Source code in campus_python/interface.py
259
260
261
262
263
264
265
266
267
def __init__(
        self,
        *parts: str,
        parent: "Resource | ResourceCollection",
        client: Optional[JsonClient] = None,
):
    self._client = client
    self.parent = parent
    self.path = parent.make_path(SLASH.join(parts))

make_path(part: str | None = None, end_slash=False) -> str

Create a full path for a sub-resource or action.

Item paths built from a parent collection carry the collection's trailing slash; dead-end sub-resource and action parts append after the item segment without one by default. With no part, the instance path is returned as constructed — only a leading slash is trimmed, so an item's trailing slash survives.

Source code in campus_python/interface.py
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
def make_path(self, part: str | None = None, end_slash=False) -> str:
    """Create a full path for a sub-resource or action.

    Item paths built from a parent collection carry the
    collection's trailing slash; dead-end sub-resource and action
    parts append after the item segment without one by default.
    With no part, the instance path is returned as constructed —
    only a leading slash is trimmed, so an item's trailing slash
    survives.
    """
    if part:
        path = join_path(self.path, part)
    else:
        path = SLASH + self.path.lstrip(SLASH)
    if end_slash and not path.endswith(SLASH):
        path += SLASH
    return path

make_url(part: str | None = None) -> str

Create a full URL for this resource or a sub-resource.

Source code in campus_python/interface.py
302
303
304
def make_url(self, part: str | None = None) -> str:
    """Create a full URL for this resource or a sub-resource."""
    return f"{self.client.base_url}{self.make_path(part)}"

join_path(*parts: str) -> str

Join path parts into a single absolute path.

Leading/trailing slashes are stripped from each part (inner slashes survive — by-slug path tails rely on them), the parts are joined with single slashes, and the result gets one leading slash.

Source code in campus_python/interface.py
21
22
23
24
25
26
27
28
29
def join_path(*parts: str) -> str:
    """Join path parts into a single absolute path.

    Leading/trailing slashes are stripped from each part (inner
    slashes survive — by-slug path tails rely on them), the parts are
    joined with single slashes, and the result gets one leading slash.
    """
    cleaned = [part.strip(SLASH) for part in parts]
    return SLASH + SLASH.join(part for part in cleaned if part)

payload(**fields: Any) -> dict[str, Any]

Build a JSON request body from keyword fields.

Fields set to None are omitted — the "not provided" convention across Campus write routes (an omitted field is left unchanged upstream). Fields that ARE provided are sent verbatim, including falsy ones (0, "", False).

Not for bodies that must carry an explicit null: build those by hand (e.g. submissions.unsubmit's {"submitted_at": null}).

Source code in campus_python/interface.py
32
33
34
35
36
37
38
39
40
41
42
43
def payload(**fields: Any) -> dict[str, Any]:
    """Build a JSON request body from keyword fields.

    Fields set to None are omitted — the "not provided" convention
    across Campus write routes (an omitted field is left unchanged
    upstream). Fields that ARE provided are sent verbatim, including
    falsy ones (0, "", False).

    Not for bodies that must carry an explicit null: build those by
    hand (e.g. submissions.unsubmit's {"submitted_at": null}).
    """
    return {key: value for key, value in fields.items() if value is not None}

updates(**fields: Any) -> dict[str, Any]

Build a PATCH body from keyword fields, dropping None fields.

Raises ValueError when every field is None — Campus PATCH routes reject an empty body outright.

The same explicit-null caveat as payload applies: a PATCH that must set a field to null cannot go through here.

Source code in campus_python/interface.py
46
47
48
49
50
51
52
53
54
55
56
57
58
def updates(**fields: Any) -> dict[str, Any]:
    """Build a PATCH body from keyword fields, dropping None fields.

    Raises ValueError when every field is None — Campus PATCH routes
    reject an empty body outright.

    The same explicit-null caveat as payload applies: a PATCH that
    must set a field to null cannot go through here.
    """
    body = payload(**fields)
    if not body:
        raise ValueError("At least one field must be provided for update")
    return body

campus_python.api.v1.byslug

campus_python.api.v1.byslug

Shared base for by-slug consumer surfaces (files #135, hosted apps #141).

By-slug routes are terminal upstream — the whole path tail after <resource>/by-slug/ is the slug — so slugs ride as raw path tails, never quoted or collapsed. Surfaces differ only in what their records deserialize to, which is the _from_resource hook.

BySlugCollection

Bases: ResourceCollection, Generic[R]

Collection whose records are addressed by a terminal by-slug tail.

Subclasses set path and, when records deserialize into a campus.model class, override _from_resource (the default passes the raw record dict through). Parameterize the subclass with the record type (BySlugCollection[dict], BySlugCollection[HostedApp]) so the inherited methods return precise types.

Source code in campus_python/api/v1/byslug.py
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
class BySlugCollection(ResourceCollection, Generic[R]):
    """Collection whose records are addressed by a terminal by-slug tail.

    Subclasses set `path` and, when records deserialize into a
    campus.model class, override `_from_resource` (the default passes
    the raw record dict through). Parameterize the subclass with the
    record type (`BySlugCollection[dict]`, `BySlugCollection[HostedApp]`)
    so the inherited methods return precise types.
    """

    def _from_resource(self, record: dict) -> Any:
        """Map a raw record dict to this surface's record type.

        Default: pass the record through unchanged (dict-returning
        surfaces). Model-backed surfaces override this, e.g. with
        ``campus.model.HostedApp.from_resource``.
        """
        return record

    def list(self, prefix: str | None = None) -> "list[R]":
        """List records, optionally under a slug prefix.

        Args:
            prefix: Only include records whose slug starts with this
                prefix.

        Returns:
            The records under the ``{"data": [...]}`` envelope, each
            mapped through _from_resource.
        """
        query = {"prefix": prefix} if prefix else None
        body = self._get_json(self.make_path(), query=query)
        return [self._from_resource(record) for record in body["data"]]

    def get_meta(self, slug: str) -> R:
        """Get a record's metadata by slug (upstream's ?meta=1 query).

        Args:
            slug: The record's stable slug, e.g. "apps/quest-dom".

        Returns:
            The record mapped through _from_resource.
        """
        body = self._get_json(self._by_slug_path(slug), query={"meta": "1"})
        return self._from_resource(body)

    def get_content(self, slug: str) -> bytes:
        """Get a record's bytes by slug — the CURRENT version.

        Responses carry an ETag and a short max-age cache header.
        Content is returned undecoded — callers decode.

        Args:
            slug: The record's stable slug.

        Returns:
            The content, as stored at serve time.
        """
        # Raw bytes, not JSON: this one keeps the raw response.
        resp = self.client.get(self._by_slug_path(slug))
        resp.raise_for_status()
        return resp.content

    def put_by_slug(
            self,
            slug: str,
            data: bytes | str,
            content_type: str | None = None,
            name: str | None = None,
    ) -> R:
        """Create or replace a record's content by slug (upsert).

        Replacing an existing slug keeps its address stable — consumers
        rely on this for in-place updates. Attribution
        (uploaded_by_client_id/user_id) is server-side from the
        bearer; user-session calls attribute to the user
        automatically.

        Args:
            slug: The stable slug to create or replace.
            data: Raw content (the request body).
            content_type: MIME type to store and serve back (e.g.
                "text/html"); defaults to the transport's choice and
                upstream stores application/octet-stream when absent.
            name: Optional display name, stored on the record for new
                records (upstream's PUT ?name= parameter).

        Returns:
            The record mapped through _from_resource (201 shape on
            create, 200 shape on replace; identical dict fields).
        """
        query = {"name": name} if name else None
        # Raw request body, not JSON: this one keeps the raw call.
        resp = self.client.put_data(
            self._by_slug_path(slug), data, content_type=content_type,
            query=query,
        )
        resp.raise_for_status()
        return self._from_resource(resp.json())

    def delete_by_slug(self, slug: str) -> None:
        """Delete a record (its metadata and content) by slug.

        Args:
            slug: The slug to delete.
        """
        self._delete_json(self._by_slug_path(slug))
        return None

    def _by_slug_path(self, slug: str) -> str:
        """Path for a by-slug route.

        The slug is a path tail — slashes are part of the slug and
        upstream's ``<path:slug>`` converter expects them literal — so
        it is concatenated unquoted; make_path only trims a
        leading/trailing slash.
        """
        return self.make_path(f"by-slug/{slug}", end_slash=False)

list(prefix: str | None = None) -> list[R]

List records, optionally under a slug prefix.

Parameters:

Name Type Description Default
prefix str | None

Only include records whose slug starts with this prefix.

None

Returns:

Type Description
list[R]

The records under the {"data": [...]} envelope, each

list[R]

mapped through _from_resource.

Source code in campus_python/api/v1/byslug.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
def list(self, prefix: str | None = None) -> "list[R]":
    """List records, optionally under a slug prefix.

    Args:
        prefix: Only include records whose slug starts with this
            prefix.

    Returns:
        The records under the ``{"data": [...]}`` envelope, each
        mapped through _from_resource.
    """
    query = {"prefix": prefix} if prefix else None
    body = self._get_json(self.make_path(), query=query)
    return [self._from_resource(record) for record in body["data"]]

get_meta(slug: str) -> R

Get a record's metadata by slug (upstream's ?meta=1 query).

Parameters:

Name Type Description Default
slug str

The record's stable slug, e.g. "apps/quest-dom".

required

Returns:

Type Description
R

The record mapped through _from_resource.

Source code in campus_python/api/v1/byslug.py
52
53
54
55
56
57
58
59
60
61
62
def get_meta(self, slug: str) -> R:
    """Get a record's metadata by slug (upstream's ?meta=1 query).

    Args:
        slug: The record's stable slug, e.g. "apps/quest-dom".

    Returns:
        The record mapped through _from_resource.
    """
    body = self._get_json(self._by_slug_path(slug), query={"meta": "1"})
    return self._from_resource(body)

get_content(slug: str) -> bytes

Get a record's bytes by slug — the CURRENT version.

Responses carry an ETag and a short max-age cache header. Content is returned undecoded — callers decode.

Parameters:

Name Type Description Default
slug str

The record's stable slug.

required

Returns:

Type Description
bytes

The content, as stored at serve time.

Source code in campus_python/api/v1/byslug.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def get_content(self, slug: str) -> bytes:
    """Get a record's bytes by slug — the CURRENT version.

    Responses carry an ETag and a short max-age cache header.
    Content is returned undecoded — callers decode.

    Args:
        slug: The record's stable slug.

    Returns:
        The content, as stored at serve time.
    """
    # Raw bytes, not JSON: this one keeps the raw response.
    resp = self.client.get(self._by_slug_path(slug))
    resp.raise_for_status()
    return resp.content

put_by_slug(slug: str, data: bytes | str, content_type: str | None = None, name: str | None = None) -> R

Create or replace a record's content by slug (upsert).

Replacing an existing slug keeps its address stable — consumers rely on this for in-place updates. Attribution (uploaded_by_client_id/user_id) is server-side from the bearer; user-session calls attribute to the user automatically.

Parameters:

Name Type Description Default
slug str

The stable slug to create or replace.

required
data bytes | str

Raw content (the request body).

required
content_type str | None

MIME type to store and serve back (e.g. "text/html"); defaults to the transport's choice and upstream stores application/octet-stream when absent.

None
name str | None

Optional display name, stored on the record for new records (upstream's PUT ?name= parameter).

None

Returns:

Type Description
R

The record mapped through _from_resource (201 shape on

R

create, 200 shape on replace; identical dict fields).

Source code in campus_python/api/v1/byslug.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def put_by_slug(
        self,
        slug: str,
        data: bytes | str,
        content_type: str | None = None,
        name: str | None = None,
) -> R:
    """Create or replace a record's content by slug (upsert).

    Replacing an existing slug keeps its address stable — consumers
    rely on this for in-place updates. Attribution
    (uploaded_by_client_id/user_id) is server-side from the
    bearer; user-session calls attribute to the user
    automatically.

    Args:
        slug: The stable slug to create or replace.
        data: Raw content (the request body).
        content_type: MIME type to store and serve back (e.g.
            "text/html"); defaults to the transport's choice and
            upstream stores application/octet-stream when absent.
        name: Optional display name, stored on the record for new
            records (upstream's PUT ?name= parameter).

    Returns:
        The record mapped through _from_resource (201 shape on
        create, 200 shape on replace; identical dict fields).
    """
    query = {"name": name} if name else None
    # Raw request body, not JSON: this one keeps the raw call.
    resp = self.client.put_data(
        self._by_slug_path(slug), data, content_type=content_type,
        query=query,
    )
    resp.raise_for_status()
    return self._from_resource(resp.json())

delete_by_slug(slug: str) -> None

Delete a record (its metadata and content) by slug.

Parameters:

Name Type Description Default
slug str

The slug to delete.

required
Source code in campus_python/api/v1/byslug.py
118
119
120
121
122
123
124
125
def delete_by_slug(self, slug: str) -> None:
    """Delete a record (its metadata and content) by slug.

    Args:
        slug: The slug to delete.
    """
    self._delete_json(self._by_slug_path(slug))
    return None