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 mappedAPIErroron 4xx/5xx, return the parsed body.Nonequery/jsonkwargs are omitted from the wire call.payload(**fields)— request body droppingNonefields (provided falsy values survive).updates(**fields)— same, plus the non-empty guard PATCH routes require. Neither helper can send an explicitnull— build bodies like{"submitted_at": null}by hand (seeSubmissions.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 callself.client.<verb>()+raise_for_status()directly — seevaults.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(nestedmetadata/databody),sessions.new(str()coercion), and required-only literals where the helper adds nothing. Clients.updateusespayload()withoutupdates()— the auth server accepts its empty PATCHes; don't add guards the server doesn't have.
Paths¶
- Collections end with a trailing slash (
make_pathdefault); item routes carry one too; dead-end action/sub-resource parts (submit,add,revoke,entries) do not — mirror the upstream Flask route rules (strict_slashesmakes these load-bearing). - Collection
pathattrs 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 throughmake_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 | |
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 | |
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 | |
make_url() -> str
¶
Create a full path for the resource root.
Source code in campus_python/interface.py
190 191 192 | |
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 | |
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 | |
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 | |
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 | |
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 | |
_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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 | |
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 |
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 | |
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 | |
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 | |
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 | |
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 | |