Skip to content

Campus API (campus.api)

The /api/v1 service: assignments, circles, files, hosted apps, submissions, and timetable. Address collections directly (campus.api.assignments.list()), single items by id (campus.api.circles["circle_ab12cd34"].get()), and drill into sub-resources (... submissions["sub_1"].feedback.add(...)).

All methods raise the mapped APIError subclass on non-2xx responses. Records deserialize to campus.model classes, except files, which are plain validated dicts.

Note: campus.model.* types are documented in the campus repo; signatures here reference them without cross-links.

Assignments

campus_python.api.v1.assignments.Assignments

Bases: ResourceCollection

Campus API Assignments resource.

Source code in campus_python/api/v1/assignments.py
 11
 12
 13
 14
 15
 16
 17
 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
class Assignments(ResourceCollection):
    """Campus API Assignments resource."""
    path = "assignments"

    def __getitem__(self, assignment_id: str) -> "Assignments.Assignment":
        """Get a specific assignment resource by ID."""
        return Assignments.Assignment(assignment_id, parent=self)

    def list(self, *, created_by: "str | None" = None) -> "list[campus.model.Assignment]":
        """List all assignments matching filter requirements.

        Args:
            created_by: Filter by teacher who created the assignment

        Returns:
            List of Assignment objects
        """
        params = {}
        if created_by:
            params["created_by"] = created_by

        body = self._get_json(self.make_path(), query=params)
        return [
            campus.model.Assignment.from_resource(item)
            for item in body["data"]
        ]

    def new(
            self,
            *,
            title: str,
            description: "str | None" = None,
            questions: "list[dict] | None" = None,
            classroom_links: "list[dict] | None" = None,
    ) -> campus.model.Assignment:
        """Create a new assignment.

        Args:
            title: Assignment title (required by the server).
            description: Assignment description.
            questions: Question definitions.
            classroom_links: Google Classroom course/coursework links.

        Returns:
            The created Assignment.
        """
        return campus.model.Assignment.from_resource(self._post_json(
            self.make_path(),
            json=payload(
                title=title,
                description=description,
                questions=questions,
                classroom_links=classroom_links,
            )
        ))

    class Assignment(Resource):
        """Single campus API assignment resource."""

        @property
        def links(self) -> "Assignments.Assignment.Links":
            """Get the links resource for this assignment."""
            return Assignments.Assignment.Links("links", parent=self)

        def delete(self) -> None:
            """Delete this assignment."""
            self._delete_json(self.make_path(end_slash=True))

        def get(self) -> campus.model.Assignment:
            """Get this assignment.

            Returns:
                The Assignment.
            """
            body = self._get_json(self.make_path(end_slash=True))
            return campus.model.Assignment.from_resource(body)

        def update(self, **fields) -> None:
            """Update this assignment with the given fields.

            The server rejects an empty PATCH body, so at least one
            field must be provided.
            """
            self._patch_json(self.make_path(end_slash=True), json=updates(**fields))
            return None

        class Links(Resource):
            """Campus API Assignment Links resource."""

            def add(
                    self,
                    *,
                    course_id: str,
                    coursework_id: str,
                    attachment_id: str | None = None,
            ) -> None:
                """Attach a Google Classroom course/coursework link.

                Args:
                    course_id: Classroom course ID.
                    coursework_id: Classroom coursework ID.
                    attachment_id: Optional Drive attachment ID.
                """
                self._post_json(self.make_path(), json=payload(
                    course_id=course_id,
                    coursework_id=coursework_id,
                    attachment_id=attachment_id,
                ))
                return None

path = 'assignments' class-attribute instance-attribute

Assignment

Bases: Resource

Single campus API assignment resource.

Source code in campus_python/api/v1/assignments.py
 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
class Assignment(Resource):
    """Single campus API assignment resource."""

    @property
    def links(self) -> "Assignments.Assignment.Links":
        """Get the links resource for this assignment."""
        return Assignments.Assignment.Links("links", parent=self)

    def delete(self) -> None:
        """Delete this assignment."""
        self._delete_json(self.make_path(end_slash=True))

    def get(self) -> campus.model.Assignment:
        """Get this assignment.

        Returns:
            The Assignment.
        """
        body = self._get_json(self.make_path(end_slash=True))
        return campus.model.Assignment.from_resource(body)

    def update(self, **fields) -> None:
        """Update this assignment with the given fields.

        The server rejects an empty PATCH body, so at least one
        field must be provided.
        """
        self._patch_json(self.make_path(end_slash=True), json=updates(**fields))
        return None

    class Links(Resource):
        """Campus API Assignment Links resource."""

        def add(
                self,
                *,
                course_id: str,
                coursework_id: str,
                attachment_id: str | None = None,
        ) -> None:
            """Attach a Google Classroom course/coursework link.

            Args:
                course_id: Classroom course ID.
                coursework_id: Classroom coursework ID.
                attachment_id: Optional Drive attachment ID.
            """
            self._post_json(self.make_path(), json=payload(
                course_id=course_id,
                coursework_id=coursework_id,
                attachment_id=attachment_id,
            ))
            return None

Get the links resource for this assignment.

Bases: Resource

Campus API Assignment Links resource.

Source code in campus_python/api/v1/assignments.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
class Links(Resource):
    """Campus API Assignment Links resource."""

    def add(
            self,
            *,
            course_id: str,
            coursework_id: str,
            attachment_id: str | None = None,
    ) -> None:
        """Attach a Google Classroom course/coursework link.

        Args:
            course_id: Classroom course ID.
            coursework_id: Classroom coursework ID.
            attachment_id: Optional Drive attachment ID.
        """
        self._post_json(self.make_path(), json=payload(
            course_id=course_id,
            coursework_id=coursework_id,
            attachment_id=attachment_id,
        ))
        return None
add(*, course_id: str, coursework_id: str, attachment_id: str | None = None) -> None

Attach a Google Classroom course/coursework link.

Parameters:

Name Type Description Default
course_id str

Classroom course ID.

required
coursework_id str

Classroom coursework ID.

required
attachment_id str | None

Optional Drive attachment ID.

None
Source code in campus_python/api/v1/assignments.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def add(
        self,
        *,
        course_id: str,
        coursework_id: str,
        attachment_id: str | None = None,
) -> None:
    """Attach a Google Classroom course/coursework link.

    Args:
        course_id: Classroom course ID.
        coursework_id: Classroom coursework ID.
        attachment_id: Optional Drive attachment ID.
    """
    self._post_json(self.make_path(), json=payload(
        course_id=course_id,
        coursework_id=coursework_id,
        attachment_id=attachment_id,
    ))
    return None

delete() -> None

Delete this assignment.

Source code in campus_python/api/v1/assignments.py
75
76
77
def delete(self) -> None:
    """Delete this assignment."""
    self._delete_json(self.make_path(end_slash=True))

get() -> campus.model.Assignment

Get this assignment.

Returns:

Type Description
Assignment

The Assignment.

Source code in campus_python/api/v1/assignments.py
79
80
81
82
83
84
85
86
def get(self) -> campus.model.Assignment:
    """Get this assignment.

    Returns:
        The Assignment.
    """
    body = self._get_json(self.make_path(end_slash=True))
    return campus.model.Assignment.from_resource(body)

update(**fields) -> None

Update this assignment with the given fields.

The server rejects an empty PATCH body, so at least one field must be provided.

Source code in campus_python/api/v1/assignments.py
88
89
90
91
92
93
94
95
def update(self, **fields) -> None:
    """Update this assignment with the given fields.

    The server rejects an empty PATCH body, so at least one
    field must be provided.
    """
    self._patch_json(self.make_path(end_slash=True), json=updates(**fields))
    return None

__getitem__(assignment_id: str) -> Assignments.Assignment

Get a specific assignment resource by ID.

Source code in campus_python/api/v1/assignments.py
15
16
17
def __getitem__(self, assignment_id: str) -> "Assignments.Assignment":
    """Get a specific assignment resource by ID."""
    return Assignments.Assignment(assignment_id, parent=self)

list(*, created_by: str | None = None) -> list[campus.model.Assignment]

List all assignments matching filter requirements.

Parameters:

Name Type Description Default
created_by str | None

Filter by teacher who created the assignment

None

Returns:

Type Description
list[Assignment]

List of Assignment objects

Source code in campus_python/api/v1/assignments.py
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def list(self, *, created_by: "str | None" = None) -> "list[campus.model.Assignment]":
    """List all assignments matching filter requirements.

    Args:
        created_by: Filter by teacher who created the assignment

    Returns:
        List of Assignment objects
    """
    params = {}
    if created_by:
        params["created_by"] = created_by

    body = self._get_json(self.make_path(), query=params)
    return [
        campus.model.Assignment.from_resource(item)
        for item in body["data"]
    ]

new(*, title: str, description: str | None = None, questions: list[dict] | None = None, classroom_links: list[dict] | None = None) -> campus.model.Assignment

Create a new assignment.

Parameters:

Name Type Description Default
title str

Assignment title (required by the server).

required
description str | None

Assignment description.

None
questions list[dict] | None

Question definitions.

None
classroom_links list[dict] | None

Google Classroom course/coursework links.

None

Returns:

Type Description
Assignment

The created Assignment.

Source code in campus_python/api/v1/assignments.py
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
def new(
        self,
        *,
        title: str,
        description: "str | None" = None,
        questions: "list[dict] | None" = None,
        classroom_links: "list[dict] | None" = None,
) -> campus.model.Assignment:
    """Create a new assignment.

    Args:
        title: Assignment title (required by the server).
        description: Assignment description.
        questions: Question definitions.
        classroom_links: Google Classroom course/coursework links.

    Returns:
        The created Assignment.
    """
    return campus.model.Assignment.from_resource(self._post_json(
        self.make_path(),
        json=payload(
            title=title,
            description=description,
            questions=questions,
            classroom_links=classroom_links,
        )
    ))

Circles

campus_python.api.v1.circles.Circles

Bases: ResourceCollection

Campus API Circles resource.

Source code in campus_python/api/v1/circles.py
 13
 14
 15
 16
 17
 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
136
137
138
139
140
141
142
143
144
145
class Circles(ResourceCollection):
    """Campus API Circles resource."""
    path = "circles"

    def __getitem__(self, circle_id: str) -> "Circles.Circle":
        """Get a specific circle resource by ID."""
        return Circles.Circle(circle_id, parent=self)

    def list(self) -> "list[campus.model.Circle]":
        """List all circles.

        Returns:
            List of Circle objects.
        """
        body = self._get_json(self.make_path())
        return [
            campus.model.Circle.from_resource(item)
            for item in body["data"]
        ]

    def new(
            self,
            *,
            name: str,
            tag: str,
            description: str = "",
            parents: dict[str, int] | None = None,
    ) -> campus.model.Circle:
        """Create a new circle.

        The server rejects a name that collides with an existing
        circle (409).

        Args:
            name: Circle name (unique across Campus).
            tag: Circle tag (e.g. "project").
            description: Circle description.
            parents: Parent circle ids mapped to access values.

        Returns:
            The created Circle.
        """
        return campus.model.Circle.from_resource(self._post_json(
            self.make_path(),
            json=payload(name=name, tag=tag, description=description, parents=parents)
        ))

    class Circle(Resource):
        """Single campus API circle resource."""

        @property
        def members(self) -> "Circles.Circle.CircleMembers":
            """Get the members resource for this circle."""
            return Circles.Circle.CircleMembers("members", parent=self)

        def delete(self) -> None:
            """Delete this circle."""
            self._delete_json(self.make_path(end_slash=True))

        def get(self) -> campus.model.Circle:
            """Get this circle.

            Returns:
                The Circle.
            """
            body = self._get_json(self.make_path(end_slash=True))
            return campus.model.Circle.from_resource(body)

        def update(
                self,
                *,
                name: str,
                description: "str | None" = None,
        ) -> None:
            """Update this circle.

            The server requires name on every circle PATCH and returns
            422 without it, so it is a required parameter here.
            Description is unchanged when omitted.

            Args:
                name: New circle name (must not collide with an
                    existing circle's name)
                description: New description; omitted leaves it unchanged
            """
            payload: dict[str, Any] = {"name": name}
            if description is not None:
                payload["description"] = description
            self._patch_json(self.make_path(end_slash=True), json=payload)
            return None

        class CircleMembers(Resource):
            """Campus API Circle Members resource."""

            def list(self) -> dict[str, int]:
                """Return the circle's {member_id: access_value} mapping."""
                return self._get_json(self.make_path(end_slash=True))

            def add(self, member_id: str, access_value: int) -> None:
                """Add a member with an access value.

                Targets the /members/add action route; adding an
                existing member re-errors server-side. Use set() for
                upsert semantics.
                """
                self._post_json(
                    self.make_path("add"),
                    json={"member_id": member_id, "access_value": access_value}
                )

            def remove(self, member_id: str) -> None:
                """Remove a member.

                Targets the /members/remove action route.
                """
                self._delete_json(
                    self.make_path("remove"),
                    json={"member_id": member_id}
                )

            def set(self, member_id: str, access_value: int) -> None:
                """Create or update a member's access value (upsert).

                Unlike add()/remove(), which target the /members/add and
                /members/remove action routes, this PATCHes the members
                collection directly — the server's set semantics apply
                (no error when the access value is unchanged).
                """
                self._patch_json(
                    self.make_path(end_slash=True),
                    json={"member_id": member_id, "access_value": access_value}
                )
                return None

path = 'circles' class-attribute instance-attribute

Circle

Bases: Resource

Single campus API circle resource.

Source code in campus_python/api/v1/circles.py
 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
136
137
138
139
140
141
142
143
144
145
class Circle(Resource):
    """Single campus API circle resource."""

    @property
    def members(self) -> "Circles.Circle.CircleMembers":
        """Get the members resource for this circle."""
        return Circles.Circle.CircleMembers("members", parent=self)

    def delete(self) -> None:
        """Delete this circle."""
        self._delete_json(self.make_path(end_slash=True))

    def get(self) -> campus.model.Circle:
        """Get this circle.

        Returns:
            The Circle.
        """
        body = self._get_json(self.make_path(end_slash=True))
        return campus.model.Circle.from_resource(body)

    def update(
            self,
            *,
            name: str,
            description: "str | None" = None,
    ) -> None:
        """Update this circle.

        The server requires name on every circle PATCH and returns
        422 without it, so it is a required parameter here.
        Description is unchanged when omitted.

        Args:
            name: New circle name (must not collide with an
                existing circle's name)
            description: New description; omitted leaves it unchanged
        """
        payload: dict[str, Any] = {"name": name}
        if description is not None:
            payload["description"] = description
        self._patch_json(self.make_path(end_slash=True), json=payload)
        return None

    class CircleMembers(Resource):
        """Campus API Circle Members resource."""

        def list(self) -> dict[str, int]:
            """Return the circle's {member_id: access_value} mapping."""
            return self._get_json(self.make_path(end_slash=True))

        def add(self, member_id: str, access_value: int) -> None:
            """Add a member with an access value.

            Targets the /members/add action route; adding an
            existing member re-errors server-side. Use set() for
            upsert semantics.
            """
            self._post_json(
                self.make_path("add"),
                json={"member_id": member_id, "access_value": access_value}
            )

        def remove(self, member_id: str) -> None:
            """Remove a member.

            Targets the /members/remove action route.
            """
            self._delete_json(
                self.make_path("remove"),
                json={"member_id": member_id}
            )

        def set(self, member_id: str, access_value: int) -> None:
            """Create or update a member's access value (upsert).

            Unlike add()/remove(), which target the /members/add and
            /members/remove action routes, this PATCHes the members
            collection directly — the server's set semantics apply
            (no error when the access value is unchanged).
            """
            self._patch_json(
                self.make_path(end_slash=True),
                json={"member_id": member_id, "access_value": access_value}
            )
            return None

members: Circles.Circle.CircleMembers property

Get the members resource for this circle.

CircleMembers

Bases: Resource

Campus API Circle Members resource.

Source code in campus_python/api/v1/circles.py
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
136
137
138
139
140
141
142
143
144
145
class CircleMembers(Resource):
    """Campus API Circle Members resource."""

    def list(self) -> dict[str, int]:
        """Return the circle's {member_id: access_value} mapping."""
        return self._get_json(self.make_path(end_slash=True))

    def add(self, member_id: str, access_value: int) -> None:
        """Add a member with an access value.

        Targets the /members/add action route; adding an
        existing member re-errors server-side. Use set() for
        upsert semantics.
        """
        self._post_json(
            self.make_path("add"),
            json={"member_id": member_id, "access_value": access_value}
        )

    def remove(self, member_id: str) -> None:
        """Remove a member.

        Targets the /members/remove action route.
        """
        self._delete_json(
            self.make_path("remove"),
            json={"member_id": member_id}
        )

    def set(self, member_id: str, access_value: int) -> None:
        """Create or update a member's access value (upsert).

        Unlike add()/remove(), which target the /members/add and
        /members/remove action routes, this PATCHes the members
        collection directly — the server's set semantics apply
        (no error when the access value is unchanged).
        """
        self._patch_json(
            self.make_path(end_slash=True),
            json={"member_id": member_id, "access_value": access_value}
        )
        return None
list() -> dict[str, int]

Return the circle's {member_id: access_value} mapping.

Source code in campus_python/api/v1/circles.py
107
108
109
def list(self) -> dict[str, int]:
    """Return the circle's {member_id: access_value} mapping."""
    return self._get_json(self.make_path(end_slash=True))
add(member_id: str, access_value: int) -> None

Add a member with an access value.

Targets the /members/add action route; adding an existing member re-errors server-side. Use set() for upsert semantics.

Source code in campus_python/api/v1/circles.py
111
112
113
114
115
116
117
118
119
120
121
def add(self, member_id: str, access_value: int) -> None:
    """Add a member with an access value.

    Targets the /members/add action route; adding an
    existing member re-errors server-side. Use set() for
    upsert semantics.
    """
    self._post_json(
        self.make_path("add"),
        json={"member_id": member_id, "access_value": access_value}
    )
remove(member_id: str) -> None

Remove a member.

Targets the /members/remove action route.

Source code in campus_python/api/v1/circles.py
123
124
125
126
127
128
129
130
131
def remove(self, member_id: str) -> None:
    """Remove a member.

    Targets the /members/remove action route.
    """
    self._delete_json(
        self.make_path("remove"),
        json={"member_id": member_id}
    )
set(member_id: str, access_value: int) -> None

Create or update a member's access value (upsert).

Unlike add()/remove(), which target the /members/add and /members/remove action routes, this PATCHes the members collection directly — the server's set semantics apply (no error when the access value is unchanged).

Source code in campus_python/api/v1/circles.py
133
134
135
136
137
138
139
140
141
142
143
144
145
def set(self, member_id: str, access_value: int) -> None:
    """Create or update a member's access value (upsert).

    Unlike add()/remove(), which target the /members/add and
    /members/remove action routes, this PATCHes the members
    collection directly — the server's set semantics apply
    (no error when the access value is unchanged).
    """
    self._patch_json(
        self.make_path(end_slash=True),
        json={"member_id": member_id, "access_value": access_value}
    )
    return None

delete() -> None

Delete this circle.

Source code in campus_python/api/v1/circles.py
68
69
70
def delete(self) -> None:
    """Delete this circle."""
    self._delete_json(self.make_path(end_slash=True))

get() -> campus.model.Circle

Get this circle.

Returns:

Type Description
Circle

The Circle.

Source code in campus_python/api/v1/circles.py
72
73
74
75
76
77
78
79
def get(self) -> campus.model.Circle:
    """Get this circle.

    Returns:
        The Circle.
    """
    body = self._get_json(self.make_path(end_slash=True))
    return campus.model.Circle.from_resource(body)

update(*, name: str, description: str | None = None) -> None

Update this circle.

The server requires name on every circle PATCH and returns 422 without it, so it is a required parameter here. Description is unchanged when omitted.

Parameters:

Name Type Description Default
name str

New circle name (must not collide with an existing circle's name)

required
description str | None

New description; omitted leaves it unchanged

None
Source code in campus_python/api/v1/circles.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
def update(
        self,
        *,
        name: str,
        description: "str | None" = None,
) -> None:
    """Update this circle.

    The server requires name on every circle PATCH and returns
    422 without it, so it is a required parameter here.
    Description is unchanged when omitted.

    Args:
        name: New circle name (must not collide with an
            existing circle's name)
        description: New description; omitted leaves it unchanged
    """
    payload: dict[str, Any] = {"name": name}
    if description is not None:
        payload["description"] = description
    self._patch_json(self.make_path(end_slash=True), json=payload)
    return None

__getitem__(circle_id: str) -> Circles.Circle

Get a specific circle resource by ID.

Source code in campus_python/api/v1/circles.py
17
18
19
def __getitem__(self, circle_id: str) -> "Circles.Circle":
    """Get a specific circle resource by ID."""
    return Circles.Circle(circle_id, parent=self)

list() -> list[campus.model.Circle]

List all circles.

Returns:

Type Description
list[Circle]

List of Circle objects.

Source code in campus_python/api/v1/circles.py
21
22
23
24
25
26
27
28
29
30
31
def list(self) -> "list[campus.model.Circle]":
    """List all circles.

    Returns:
        List of Circle objects.
    """
    body = self._get_json(self.make_path())
    return [
        campus.model.Circle.from_resource(item)
        for item in body["data"]
    ]

new(*, name: str, tag: str, description: str = '', parents: dict[str, int] | None = None) -> campus.model.Circle

Create a new circle.

The server rejects a name that collides with an existing circle (409).

Parameters:

Name Type Description Default
name str

Circle name (unique across Campus).

required
tag str

Circle tag (e.g. "project").

required
description str

Circle description.

''
parents dict[str, int] | None

Parent circle ids mapped to access values.

None

Returns:

Type Description
Circle

The created Circle.

Source code in campus_python/api/v1/circles.py
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
def new(
        self,
        *,
        name: str,
        tag: str,
        description: str = "",
        parents: dict[str, int] | None = None,
) -> campus.model.Circle:
    """Create a new circle.

    The server rejects a name that collides with an existing
    circle (409).

    Args:
        name: Circle name (unique across Campus).
        tag: Circle tag (e.g. "project").
        description: Circle description.
        parents: Parent circle ids mapped to access values.

    Returns:
        The created Circle.
    """
    return campus.model.Circle.from_resource(self._post_json(
        self.make_path(),
        json=payload(name=name, tag=tag, description=description, parents=parents)
    ))

Files

The by-slug consumer surface (list / meta / content / upsert / delete). Files are binary blobs addressed by a stable slug (e.g. apps/quest-dom); slugs ride as raw path tails. Methods are inherited from the shared BySlugCollection (see Extending the SDK); records are plain dicts, not campus.model classes.

campus_python.api.v1.files.Files

Bases: BySlugCollection[dict]

Campus API files resource (by-slug + id-based management).

Source code in campus_python/api/v1/files.py
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
class Files(BySlugCollection[dict]):
    """Campus API files resource (by-slug + id-based management)."""
    path = "files"

    def __getitem__(self, file_id: str) -> "Files.File":
        """Get a file record's management surface by id."""
        return Files.File(file_id, parent=self)

    class File(Resource):
        """A single file record addressed by id (management surface).

        Unlike the by-slug upsert, these routes never create —
        replacing or reading content targets an existing record only
        (404 for an unknown id).
        """

        def get(self) -> dict:
            """Get this file's metadata record."""
            return self._get_json(self.make_path(end_slash=True))

        def get_content(self) -> bytes:
            """Get this file's bytes — the CURRENT version.

            Responses carry an ETag and a short max-age cache header.
            Content is returned undecoded — callers decode.
            """
            resp = self.client.get(self.make_path("content", end_slash=True))
            resp.raise_for_status()
            return resp.content

        def put_content(
                self,
                data: bytes | str,
                content_type: str | None = None,
                name: str | None = None,
        ) -> dict:
            """Replace this file's content (never creates).

            Args:
                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 new display name (upstream's PUT ?name=
                    parameter).

            Returns:
                The updated metadata record.
            """
            query = {"name": name} if name else None
            resp = self.client.put_data(
                self.make_path("content", end_slash=True),
                data,
                content_type=content_type,
                query=query,
            )
            resp.raise_for_status()
            return resp.json()

        def delete(self) -> None:
            """Delete this file (its metadata and content)."""
            self._delete_json(self.make_path(end_slash=True))
            return None

path = 'files' class-attribute instance-attribute

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

root: ResourceRoot = root instance-attribute

File

Bases: Resource

A single file record addressed by id (management surface).

Unlike the by-slug upsert, these routes never create — replacing or reading content targets an existing record only (404 for an unknown id).

Source code in campus_python/api/v1/files.py
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
class File(Resource):
    """A single file record addressed by id (management surface).

    Unlike the by-slug upsert, these routes never create —
    replacing or reading content targets an existing record only
    (404 for an unknown id).
    """

    def get(self) -> dict:
        """Get this file's metadata record."""
        return self._get_json(self.make_path(end_slash=True))

    def get_content(self) -> bytes:
        """Get this file's bytes — the CURRENT version.

        Responses carry an ETag and a short max-age cache header.
        Content is returned undecoded — callers decode.
        """
        resp = self.client.get(self.make_path("content", end_slash=True))
        resp.raise_for_status()
        return resp.content

    def put_content(
            self,
            data: bytes | str,
            content_type: str | None = None,
            name: str | None = None,
    ) -> dict:
        """Replace this file's content (never creates).

        Args:
            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 new display name (upstream's PUT ?name=
                parameter).

        Returns:
            The updated metadata record.
        """
        query = {"name": name} if name else None
        resp = self.client.put_data(
            self.make_path("content", end_slash=True),
            data,
            content_type=content_type,
            query=query,
        )
        resp.raise_for_status()
        return resp.json()

    def delete(self) -> None:
        """Delete this file (its metadata and content)."""
        self._delete_json(self.make_path(end_slash=True))
        return None

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

parent: Resource | ResourceCollection = parent instance-attribute

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

get() -> dict

Get this file's metadata record.

Source code in campus_python/api/v1/files.py
35
36
37
def get(self) -> dict:
    """Get this file's metadata record."""
    return self._get_json(self.make_path(end_slash=True))

get_content() -> bytes

Get this file's bytes — the CURRENT version.

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

Source code in campus_python/api/v1/files.py
39
40
41
42
43
44
45
46
47
def get_content(self) -> bytes:
    """Get this file's bytes — the CURRENT version.

    Responses carry an ETag and a short max-age cache header.
    Content is returned undecoded — callers decode.
    """
    resp = self.client.get(self.make_path("content", end_slash=True))
    resp.raise_for_status()
    return resp.content

put_content(data: bytes | str, content_type: str | None = None, name: str | None = None) -> dict

Replace this file's content (never creates).

Parameters:

Name Type Description Default
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 new display name (upstream's PUT ?name= parameter).

None

Returns:

Type Description
dict

The updated metadata record.

Source code in campus_python/api/v1/files.py
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
def put_content(
        self,
        data: bytes | str,
        content_type: str | None = None,
        name: str | None = None,
) -> dict:
    """Replace this file's content (never creates).

    Args:
        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 new display name (upstream's PUT ?name=
            parameter).

    Returns:
        The updated metadata record.
    """
    query = {"name": name} if name else None
    resp = self.client.put_data(
        self.make_path("content", end_slash=True),
        data,
        content_type=content_type,
        query=query,
    )
    resp.raise_for_status()
    return resp.json()

delete() -> None

Delete this file (its metadata and content).

Source code in campus_python/api/v1/files.py
78
79
80
81
def delete(self) -> None:
    """Delete this file (its metadata and content)."""
    self._delete_json(self.make_path(end_slash=True))
    return None

_get_json(path: str, query: JsonDict | None = None) -> Any

GET path and return the parsed JSON body.

Source code in campus_python/interface.py
79
80
81
82
83
84
85
86
def _get_json(self, path: str, query: JsonDict | None = None) -> Any:
    """GET path and return the parsed JSON body."""
    if query is None:
        response = self.client.get(path)
    else:
        response = self.client.get(path, query=query)
    response.raise_for_status()
    return response.json()

_post_json(path: str, json: JsonDict | None = None) -> Any

POST the JSON body and return the parsed JSON response body.

Source code in campus_python/interface.py
88
89
90
91
92
93
94
95
def _post_json(self, path: str, json: JsonDict | None = None) -> Any:
    """POST the JSON body and return the parsed JSON response body."""
    if json is None:
        response = self.client.post(path)
    else:
        response = self.client.post(path, json=json)
    response.raise_for_status()
    return response.json()

_put_json(path: str, json: JsonDict, query: JsonDict | None = None) -> Any

PUT the JSON body (with optional query params) and return the parsed JSON response body.

Source code in campus_python/interface.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def _put_json(
        self,
        path: str,
        json: JsonDict,
        query: JsonDict | None = None
) -> Any:
    """PUT the JSON body (with optional query params) and return the
    parsed JSON response body."""
    if query is None:
        response = self.client.put(path, json=json)
    else:
        response = self.client.put(path, json=json, query=query)
    response.raise_for_status()
    return response.json()

_patch_json(path: str, json: Any, query: JsonDict | None = None) -> Any

PATCH the JSON body (with optional query params) and return the parsed JSON response body.

Source code in campus_python/interface.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def _patch_json(
        self,
        path: str,
        json: Any,
        query: JsonDict | None = None
) -> Any:
    """PATCH the JSON body (with optional query params) and return
    the parsed JSON response body."""
    if query is None:
        response = self.client.patch(path, json=json)
    else:
        response = self.client.patch(path, json=json, query=query)
    response.raise_for_status()
    return response.json()

_delete_json(path: str, json: JsonDict | None = None, query: JsonDict | None = None) -> Any

DELETE (optionally with a JSON body and/or query params) and return the parsed JSON response body.

Source code in campus_python/interface.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def _delete_json(
        self,
        path: str,
        json: JsonDict | None = None,
        query: JsonDict | None = None
) -> Any:
    """DELETE (optionally with a JSON body and/or query params) and
    return the parsed JSON response body."""
    kwargs: JsonDict = {}
    if json is not None:
        kwargs["json"] = json
    if query is not None:
        kwargs["query"] = query
    response = self.client.delete(path, **kwargs)
    response.raise_for_status()
    return response.json()

__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))

__repr__() -> str

Source code in campus_python/interface.py
269
270
def __repr__(self) -> str:
    return f"Resource(client={self.client}, path={self.path})"

__str__() -> str

Source code in campus_python/interface.py
272
273
def __str__(self) -> str:
    return self.path

__getitem__(file_id: str) -> Files.File

Get a file record's management surface by id.

Source code in campus_python/api/v1/files.py
23
24
25
def __getitem__(self, file_id: str) -> "Files.File":
    """Get a file record's management surface by id."""
    return Files.File(file_id, parent=self)

_get_json(path: str, query: JsonDict | None = None) -> Any

GET path and return the parsed JSON body.

Source code in campus_python/interface.py
79
80
81
82
83
84
85
86
def _get_json(self, path: str, query: JsonDict | None = None) -> Any:
    """GET path and return the parsed JSON body."""
    if query is None:
        response = self.client.get(path)
    else:
        response = self.client.get(path, query=query)
    response.raise_for_status()
    return response.json()

_post_json(path: str, json: JsonDict | None = None) -> Any

POST the JSON body and return the parsed JSON response body.

Source code in campus_python/interface.py
88
89
90
91
92
93
94
95
def _post_json(self, path: str, json: JsonDict | None = None) -> Any:
    """POST the JSON body and return the parsed JSON response body."""
    if json is None:
        response = self.client.post(path)
    else:
        response = self.client.post(path, json=json)
    response.raise_for_status()
    return response.json()

_put_json(path: str, json: JsonDict, query: JsonDict | None = None) -> Any

PUT the JSON body (with optional query params) and return the parsed JSON response body.

Source code in campus_python/interface.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def _put_json(
        self,
        path: str,
        json: JsonDict,
        query: JsonDict | None = None
) -> Any:
    """PUT the JSON body (with optional query params) and return the
    parsed JSON response body."""
    if query is None:
        response = self.client.put(path, json=json)
    else:
        response = self.client.put(path, json=json, query=query)
    response.raise_for_status()
    return response.json()

_patch_json(path: str, json: Any, query: JsonDict | None = None) -> Any

PATCH the JSON body (with optional query params) and return the parsed JSON response body.

Source code in campus_python/interface.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def _patch_json(
        self,
        path: str,
        json: Any,
        query: JsonDict | None = None
) -> Any:
    """PATCH the JSON body (with optional query params) and return
    the parsed JSON response body."""
    if query is None:
        response = self.client.patch(path, json=json)
    else:
        response = self.client.patch(path, json=json, query=query)
    response.raise_for_status()
    return response.json()

_delete_json(path: str, json: JsonDict | None = None, query: JsonDict | None = None) -> Any

DELETE (optionally with a JSON body and/or query params) and return the parsed JSON response body.

Source code in campus_python/interface.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def _delete_json(
        self,
        path: str,
        json: JsonDict | None = None,
        query: JsonDict | None = None
) -> Any:
    """DELETE (optionally with a JSON body and/or query params) and
    return the parsed JSON response body."""
    kwargs: JsonDict = {}
    if json is not None:
        kwargs["json"] = json
    if query is not None:
        kwargs["query"] = query
    response = self.client.delete(path, **kwargs)
    response.raise_for_status()
    return response.json()

__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

_from_resource(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.

Source code in campus_python/api/v1/byslug.py
28
29
30
31
32
33
34
35
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

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

_by_slug_path(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.

Source code in campus_python/api/v1/byslug.py
127
128
129
130
131
132
133
134
135
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)

HostedApps

Single-file HTML apps published to Campus app hosting. Content-addressed server-side: replacing content under a slug keeps the slug's address while serving the new version. Methods are inherited from BySlugCollection; records deserialize to campus.model.HostedApp. Unlike files, upstream defaults content to text/html when no content type is given.

campus_python.api.v1.hostedapps.HostedApps

Bases: BySlugCollection[HostedApp]

Campus API hosted-apps resource (consumer-facing by-slug surface).

Source code in campus_python/api/v1/hostedapps.py
20
21
22
23
24
25
class HostedApps(BySlugCollection[campus.model.HostedApp]):
    """Campus API hosted-apps resource (consumer-facing by-slug surface)."""
    path = "hostedapps"

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

path = 'hostedapps' class-attribute instance-attribute

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

root: ResourceRoot = root instance-attribute

_from_resource(record: dict) -> campus.model.HostedApp

Source code in campus_python/api/v1/hostedapps.py
24
25
def _from_resource(self, record: dict) -> campus.model.HostedApp:
    return campus.model.HostedApp.from_resource(record)

_get_json(path: str, query: JsonDict | None = None) -> Any

GET path and return the parsed JSON body.

Source code in campus_python/interface.py
79
80
81
82
83
84
85
86
def _get_json(self, path: str, query: JsonDict | None = None) -> Any:
    """GET path and return the parsed JSON body."""
    if query is None:
        response = self.client.get(path)
    else:
        response = self.client.get(path, query=query)
    response.raise_for_status()
    return response.json()

_post_json(path: str, json: JsonDict | None = None) -> Any

POST the JSON body and return the parsed JSON response body.

Source code in campus_python/interface.py
88
89
90
91
92
93
94
95
def _post_json(self, path: str, json: JsonDict | None = None) -> Any:
    """POST the JSON body and return the parsed JSON response body."""
    if json is None:
        response = self.client.post(path)
    else:
        response = self.client.post(path, json=json)
    response.raise_for_status()
    return response.json()

_put_json(path: str, json: JsonDict, query: JsonDict | None = None) -> Any

PUT the JSON body (with optional query params) and return the parsed JSON response body.

Source code in campus_python/interface.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def _put_json(
        self,
        path: str,
        json: JsonDict,
        query: JsonDict | None = None
) -> Any:
    """PUT the JSON body (with optional query params) and return the
    parsed JSON response body."""
    if query is None:
        response = self.client.put(path, json=json)
    else:
        response = self.client.put(path, json=json, query=query)
    response.raise_for_status()
    return response.json()

_patch_json(path: str, json: Any, query: JsonDict | None = None) -> Any

PATCH the JSON body (with optional query params) and return the parsed JSON response body.

Source code in campus_python/interface.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
def _patch_json(
        self,
        path: str,
        json: Any,
        query: JsonDict | None = None
) -> Any:
    """PATCH the JSON body (with optional query params) and return
    the parsed JSON response body."""
    if query is None:
        response = self.client.patch(path, json=json)
    else:
        response = self.client.patch(path, json=json, query=query)
    response.raise_for_status()
    return response.json()

_delete_json(path: str, json: JsonDict | None = None, query: JsonDict | None = None) -> Any

DELETE (optionally with a JSON body and/or query params) and return the parsed JSON response body.

Source code in campus_python/interface.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
def _delete_json(
        self,
        path: str,
        json: JsonDict | None = None,
        query: JsonDict | None = None
) -> Any:
    """DELETE (optionally with a JSON body and/or query params) and
    return the parsed JSON response body."""
    kwargs: JsonDict = {}
    if json is not None:
        kwargs["json"] = json
    if query is not None:
        kwargs["query"] = query
    response = self.client.delete(path, **kwargs)
    response.raise_for_status()
    return response.json()

__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

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

_by_slug_path(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.

Source code in campus_python/api/v1/byslug.py
127
128
129
130
131
132
133
134
135
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)

Submissions

campus_python.api.v1.submissions.Submissions

Bases: ResourceCollection

Campus API Submissions resource.

Source code in campus_python/api/v1/submissions.py
 11
 12
 13
 14
 15
 16
 17
 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
136
137
138
139
140
141
142
143
144
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
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
class Submissions(ResourceCollection):
    """Campus API Submissions resource."""
    path = "submissions"

    def __getitem__(self, submission_id: str) -> "Submissions.Submission":
        """Get a specific submission resource by ID."""
        return Submissions.Submission(submission_id, parent=self)

    def list(
            self,
            *,
            assignment_id: "str | None" = None,
            student_id: "str | None" = None,
            course_id: "str | None" = None,
    ) -> "list[campus.model.Submission]":
        """List all submissions matching filter requirements.

        Args:
            assignment_id: Filter by assignment
            student_id: Filter by student
            course_id: Filter by Google Classroom course

        Returns:
            List of Submission objects
        """
        query = {}
        if assignment_id:
            query["assignment_id"] = assignment_id
        if student_id:
            query["student_id"] = student_id
        if course_id:
            query["course_id"] = course_id

        body = self._get_json(self.make_path(), query=query)
        return [
            campus.model.Submission.from_resource(item)
            for item in body["data"]
        ]

    def by_assignment(self, assignment_id: str) -> "list[campus.model.Submission]":
        """List submissions for a specific assignment.

        Args:
            assignment_id: Assignment ID

        Returns:
            List of Submission objects
        """
        path = self.make_path(f"by-assignment/{assignment_id}")
        body = self._get_json(path)
        return [
            campus.model.Submission.from_resource(item)
            for item in body["data"]
        ]

    def by_student(self, student_id: str) -> "list[campus.model.Submission]":
        """List submissions from a specific student.

        Args:
            student_id: Student user ID

        Returns:
            List of Submission objects
        """
        path = self.make_path(f"by-student/{student_id}")
        body = self._get_json(path)
        return [
            campus.model.Submission.from_resource(item)
            for item in body["data"]
        ]

    def new(
            self,
            *,
            assignment_id: str,
            student_id: str,
            course_id: str,
            responses: "list[dict] | None" = None,
    ) -> campus.model.Submission:
        """Create a new submission.

        Args:
            assignment_id: Assignment ID
            student_id: Student user ID
            course_id: Google Classroom course ID
            responses: List of response dictionaries

        Returns:
            Created Submission object
        """
        return campus.model.Submission.from_resource(self._post_json(
            self.make_path(),
            json=payload(
                assignment_id=assignment_id,
                student_id=student_id,
                course_id=course_id,
                responses=responses,
            )
        ))

    class Submission(Resource):
        """Single campus API submission resource."""

        @property
        def responses(self) -> "Submissions.Submission.Responses":
            """Get the responses resource for this submission."""
            return Submissions.Submission.Responses("responses", parent=self)

        @property
        def feedback(self) -> "Submissions.Submission.Feedback":
            """Get the feedback resource for this submission."""
            return Submissions.Submission.Feedback("feedback", parent=self)

        def delete(self) -> None:
            """Delete this submission."""
            self._delete_json(self.make_path(end_slash=True))

        def get(self) -> campus.model.Submission:
            """Get this submission."""
            body = self._get_json(self.make_path(end_slash=True))
            return campus.model.Submission.from_resource(body)

        def update(
                self,
                *,
                responses: "list[dict] | None" = None,
                feedback: "list[dict] | None" = None,
                submitted_at: "str | None" = None,
        ) -> None:
            """Update this submission.

            All parameters are optional. Only provided fields will be updated.

            Args:
                responses: New list of responses
                feedback: New list of feedback
                submitted_at: New submitted_at timestamp
            """
            self._patch_json(self.make_path(end_slash=True), json=updates(
                responses=responses,
                feedback=feedback,
                submitted_at=submitted_at,
            ))
            return None

        def submit(self) -> None:
            """Finalize/submit this submission (marks submitted_at timestamp)."""
            path = self.make_path("submit", end_slash=False)
            self._post_json(path)
            return None

        def unsubmit(self) -> None:
            """Clear this submission's submitted_at timestamp.

            The server only clears submitted_at for an explicit null in
            the PATCH body; update() cannot express that because None
            means "omit the field" there.
            """
            self._patch_json(
                self.make_path(end_slash=True), json={"submitted_at": None}
            )
            return None

        class Responses(Resource):
            """Campus API Submission Responses resource."""

            def add(
                    self,
                    *,
                    question_id: str,
                    response_text: str,
            ) -> None:
                """Add or update a response to a question.

                Args:
                    question_id: Question ID
                    response_text: Student's response text
                """
                payload = {
                    "question_id": question_id,
                    "response_text": response_text,
                }
                self._post_json(self.make_path(), json=payload)
                return None

        class Feedback(Resource):
            """Campus API Submission Feedback resource."""

            def add(
                    self,
                    *,
                    question_id: str,
                    feedback_text: str,
            ) -> None:
                """Add feedback on a response to a question.

                Args:
                    question_id: Question ID
                    feedback_text: Teacher's feedback text
                """
                payload = {
                    "question_id": question_id,
                    "feedback_text": feedback_text,
                }
                self._post_json(self.make_path(), json=payload)
                return None

path = 'submissions' class-attribute instance-attribute

Submission

Bases: Resource

Single campus API submission resource.

Source code in campus_python/api/v1/submissions.py
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
136
137
138
139
140
141
142
143
144
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
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
class Submission(Resource):
    """Single campus API submission resource."""

    @property
    def responses(self) -> "Submissions.Submission.Responses":
        """Get the responses resource for this submission."""
        return Submissions.Submission.Responses("responses", parent=self)

    @property
    def feedback(self) -> "Submissions.Submission.Feedback":
        """Get the feedback resource for this submission."""
        return Submissions.Submission.Feedback("feedback", parent=self)

    def delete(self) -> None:
        """Delete this submission."""
        self._delete_json(self.make_path(end_slash=True))

    def get(self) -> campus.model.Submission:
        """Get this submission."""
        body = self._get_json(self.make_path(end_slash=True))
        return campus.model.Submission.from_resource(body)

    def update(
            self,
            *,
            responses: "list[dict] | None" = None,
            feedback: "list[dict] | None" = None,
            submitted_at: "str | None" = None,
    ) -> None:
        """Update this submission.

        All parameters are optional. Only provided fields will be updated.

        Args:
            responses: New list of responses
            feedback: New list of feedback
            submitted_at: New submitted_at timestamp
        """
        self._patch_json(self.make_path(end_slash=True), json=updates(
            responses=responses,
            feedback=feedback,
            submitted_at=submitted_at,
        ))
        return None

    def submit(self) -> None:
        """Finalize/submit this submission (marks submitted_at timestamp)."""
        path = self.make_path("submit", end_slash=False)
        self._post_json(path)
        return None

    def unsubmit(self) -> None:
        """Clear this submission's submitted_at timestamp.

        The server only clears submitted_at for an explicit null in
        the PATCH body; update() cannot express that because None
        means "omit the field" there.
        """
        self._patch_json(
            self.make_path(end_slash=True), json={"submitted_at": None}
        )
        return None

    class Responses(Resource):
        """Campus API Submission Responses resource."""

        def add(
                self,
                *,
                question_id: str,
                response_text: str,
        ) -> None:
            """Add or update a response to a question.

            Args:
                question_id: Question ID
                response_text: Student's response text
            """
            payload = {
                "question_id": question_id,
                "response_text": response_text,
            }
            self._post_json(self.make_path(), json=payload)
            return None

    class Feedback(Resource):
        """Campus API Submission Feedback resource."""

        def add(
                self,
                *,
                question_id: str,
                feedback_text: str,
        ) -> None:
            """Add feedback on a response to a question.

            Args:
                question_id: Question ID
                feedback_text: Teacher's feedback text
            """
            payload = {
                "question_id": question_id,
                "feedback_text": feedback_text,
            }
            self._post_json(self.make_path(), json=payload)
            return None

responses: Submissions.Submission.Responses property

Get the responses resource for this submission.

feedback: Submissions.Submission.Feedback property

Get the feedback resource for this submission.

Responses

Bases: Resource

Campus API Submission Responses resource.

Source code in campus_python/api/v1/submissions.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
class Responses(Resource):
    """Campus API Submission Responses resource."""

    def add(
            self,
            *,
            question_id: str,
            response_text: str,
    ) -> None:
        """Add or update a response to a question.

        Args:
            question_id: Question ID
            response_text: Student's response text
        """
        payload = {
            "question_id": question_id,
            "response_text": response_text,
        }
        self._post_json(self.make_path(), json=payload)
        return None
add(*, question_id: str, response_text: str) -> None

Add or update a response to a question.

Parameters:

Name Type Description Default
question_id str

Question ID

required
response_text str

Student's response text

required
Source code in campus_python/api/v1/submissions.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
def add(
        self,
        *,
        question_id: str,
        response_text: str,
) -> None:
    """Add or update a response to a question.

    Args:
        question_id: Question ID
        response_text: Student's response text
    """
    payload = {
        "question_id": question_id,
        "response_text": response_text,
    }
    self._post_json(self.make_path(), json=payload)
    return None

Feedback

Bases: Resource

Campus API Submission Feedback resource.

Source code in campus_python/api/v1/submissions.py
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
class Feedback(Resource):
    """Campus API Submission Feedback resource."""

    def add(
            self,
            *,
            question_id: str,
            feedback_text: str,
    ) -> None:
        """Add feedback on a response to a question.

        Args:
            question_id: Question ID
            feedback_text: Teacher's feedback text
        """
        payload = {
            "question_id": question_id,
            "feedback_text": feedback_text,
        }
        self._post_json(self.make_path(), json=payload)
        return None
add(*, question_id: str, feedback_text: str) -> None

Add feedback on a response to a question.

Parameters:

Name Type Description Default
question_id str

Question ID

required
feedback_text str

Teacher's feedback text

required
Source code in campus_python/api/v1/submissions.py
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def add(
        self,
        *,
        question_id: str,
        feedback_text: str,
) -> None:
    """Add feedback on a response to a question.

    Args:
        question_id: Question ID
        feedback_text: Teacher's feedback text
    """
    payload = {
        "question_id": question_id,
        "feedback_text": feedback_text,
    }
    self._post_json(self.make_path(), json=payload)
    return None

delete() -> None

Delete this submission.

Source code in campus_python/api/v1/submissions.py
124
125
126
def delete(self) -> None:
    """Delete this submission."""
    self._delete_json(self.make_path(end_slash=True))

get() -> campus.model.Submission

Get this submission.

Source code in campus_python/api/v1/submissions.py
128
129
130
131
def get(self) -> campus.model.Submission:
    """Get this submission."""
    body = self._get_json(self.make_path(end_slash=True))
    return campus.model.Submission.from_resource(body)

update(*, responses: list[dict] | None = None, feedback: list[dict] | None = None, submitted_at: str | None = None) -> None

Update this submission.

All parameters are optional. Only provided fields will be updated.

Parameters:

Name Type Description Default
responses list[dict] | None

New list of responses

None
feedback list[dict] | None

New list of feedback

None
submitted_at str | None

New submitted_at timestamp

None
Source code in campus_python/api/v1/submissions.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
def update(
        self,
        *,
        responses: "list[dict] | None" = None,
        feedback: "list[dict] | None" = None,
        submitted_at: "str | None" = None,
) -> None:
    """Update this submission.

    All parameters are optional. Only provided fields will be updated.

    Args:
        responses: New list of responses
        feedback: New list of feedback
        submitted_at: New submitted_at timestamp
    """
    self._patch_json(self.make_path(end_slash=True), json=updates(
        responses=responses,
        feedback=feedback,
        submitted_at=submitted_at,
    ))
    return None

submit() -> None

Finalize/submit this submission (marks submitted_at timestamp).

Source code in campus_python/api/v1/submissions.py
156
157
158
159
160
def submit(self) -> None:
    """Finalize/submit this submission (marks submitted_at timestamp)."""
    path = self.make_path("submit", end_slash=False)
    self._post_json(path)
    return None

unsubmit() -> None

Clear this submission's submitted_at timestamp.

The server only clears submitted_at for an explicit null in the PATCH body; update() cannot express that because None means "omit the field" there.

Source code in campus_python/api/v1/submissions.py
162
163
164
165
166
167
168
169
170
171
172
def unsubmit(self) -> None:
    """Clear this submission's submitted_at timestamp.

    The server only clears submitted_at for an explicit null in
    the PATCH body; update() cannot express that because None
    means "omit the field" there.
    """
    self._patch_json(
        self.make_path(end_slash=True), json={"submitted_at": None}
    )
    return None

__getitem__(submission_id: str) -> Submissions.Submission

Get a specific submission resource by ID.

Source code in campus_python/api/v1/submissions.py
15
16
17
def __getitem__(self, submission_id: str) -> "Submissions.Submission":
    """Get a specific submission resource by ID."""
    return Submissions.Submission(submission_id, parent=self)

list(*, assignment_id: str | None = None, student_id: str | None = None, course_id: str | None = None) -> list[campus.model.Submission]

List all submissions matching filter requirements.

Parameters:

Name Type Description Default
assignment_id str | None

Filter by assignment

None
student_id str | None

Filter by student

None
course_id str | None

Filter by Google Classroom course

None

Returns:

Type Description
list[Submission]

List of Submission objects

Source code in campus_python/api/v1/submissions.py
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
def list(
        self,
        *,
        assignment_id: "str | None" = None,
        student_id: "str | None" = None,
        course_id: "str | None" = None,
) -> "list[campus.model.Submission]":
    """List all submissions matching filter requirements.

    Args:
        assignment_id: Filter by assignment
        student_id: Filter by student
        course_id: Filter by Google Classroom course

    Returns:
        List of Submission objects
    """
    query = {}
    if assignment_id:
        query["assignment_id"] = assignment_id
    if student_id:
        query["student_id"] = student_id
    if course_id:
        query["course_id"] = course_id

    body = self._get_json(self.make_path(), query=query)
    return [
        campus.model.Submission.from_resource(item)
        for item in body["data"]
    ]

by_assignment(assignment_id: str) -> list[campus.model.Submission]

List submissions for a specific assignment.

Parameters:

Name Type Description Default
assignment_id str

Assignment ID

required

Returns:

Type Description
list[Submission]

List of Submission objects

Source code in campus_python/api/v1/submissions.py
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def by_assignment(self, assignment_id: str) -> "list[campus.model.Submission]":
    """List submissions for a specific assignment.

    Args:
        assignment_id: Assignment ID

    Returns:
        List of Submission objects
    """
    path = self.make_path(f"by-assignment/{assignment_id}")
    body = self._get_json(path)
    return [
        campus.model.Submission.from_resource(item)
        for item in body["data"]
    ]

by_student(student_id: str) -> list[campus.model.Submission]

List submissions from a specific student.

Parameters:

Name Type Description Default
student_id str

Student user ID

required

Returns:

Type Description
list[Submission]

List of Submission objects

Source code in campus_python/api/v1/submissions.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def by_student(self, student_id: str) -> "list[campus.model.Submission]":
    """List submissions from a specific student.

    Args:
        student_id: Student user ID

    Returns:
        List of Submission objects
    """
    path = self.make_path(f"by-student/{student_id}")
    body = self._get_json(path)
    return [
        campus.model.Submission.from_resource(item)
        for item in body["data"]
    ]

new(*, assignment_id: str, student_id: str, course_id: str, responses: list[dict] | None = None) -> campus.model.Submission

Create a new submission.

Parameters:

Name Type Description Default
assignment_id str

Assignment ID

required
student_id str

Student user ID

required
course_id str

Google Classroom course ID

required
responses list[dict] | None

List of response dictionaries

None

Returns:

Type Description
Submission

Created Submission object

Source code in campus_python/api/v1/submissions.py
 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
def new(
        self,
        *,
        assignment_id: str,
        student_id: str,
        course_id: str,
        responses: "list[dict] | None" = None,
) -> campus.model.Submission:
    """Create a new submission.

    Args:
        assignment_id: Assignment ID
        student_id: Student user ID
        course_id: Google Classroom course ID
        responses: List of response dictionaries

    Returns:
        Created Submission object
    """
    return campus.model.Submission.from_resource(self._post_json(
        self.make_path(),
        json=payload(
            assignment_id=assignment_id,
            student_id=student_id,
            course_id=course_id,
            responses=responses,
        )
    ))

Timetable

campus_python.api.v1.timetable.Timetables

Bases: ResourceCollection

Campus API Timetable resource.

Source code in campus_python/api/v1/timetable.py
 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
136
137
138
139
140
141
142
143
144
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
193
194
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
247
248
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
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
class Timetables(ResourceCollection):
    """Campus API Timetable resource."""

    path = "timetable"
    # hardcoded enums
    timeslots = TIMESLOTS
    weekdays = WEEKDAYS

    def __getitem__(self, timetable_id: str) -> "Timetables.Timetable":
        """Get a specific timetable resource by ID."""
        return Timetables.Timetable(timetable_id, parent=self)

    def view(self) -> dict:
        """Get the deployment's timetable pointer state.

        Returns:
            {"current": timetable_id | None, "next": timetable_id |
            None, "effective_from": datetime | None} — the pending
            switch applies at its effective_from without any client
            action (#1075).
        """
        return self._get_json(self.make_path())

    def patch(
        self,
        *,
        current: str | None = None,
        next: str | None = None,  # noqa: A002 - API field name
        effective_from: str | None = None,
    ) -> dict:
        """Patch exactly one timetable pointer (#1075).

        Pass keyword arguments, exactly one of `current` or `next`.

        Args:
            current: Timetable ID to make current, effective now.
            next: Timetable ID to schedule as the pending switch
                (reschedules any previous pending switch).
            effective_from: When the pending switch applies (ISO
                datetime, or YYYY-MM-DD for 07:00 school time). Only
                valid with `next`; absent stages an undated switch
                that flips only when pointed at manually.

        Returns:
            The resulting pointer state (same shape as view()).
        """
        present = {
            key: value
            for key, value in (("current", current), ("next", next))
            if value is not None
        }
        if len(present) != 1:
            raise ValueError(
                "Patch exactly one pointer: pass current=<id> or "
                "next=<id> (use cancel_next() to cancel a pending "
                "switch)"
            )
        body: dict = dict(present)
        if "next" in body and effective_from is not None:
            body["effective_from"] = effective_from
        return self._patch_json(self.make_path(), json=body)

    def cancel_next(self) -> dict:
        """Cancel the pending scheduled switch (API: {next: null}).

        Returns:
            The resulting pointer state.
        """
        return self._patch_json(self.make_path(), json={"next": None})

    def me(self, at: str | None = None) -> dict:
        """Resolve the signed-in user's effective timetable view (#592).

        Returns:
            {"current": {timetable_id, lessongroup_id, source},
            "next": null | {..., effective_from, change_type}}; entries
            render via timetable[id].entries filtered by
            lessongroup_id.
        """
        query = {"at": at} if at else None
        return self._get_json(
            self.make_path("me", end_slash=False), query=query
        )

    def new(self, metadata: dict, data: dict) -> dict:
        """Create a new timetable.

        Args:
            metadata: Metadata for the timetable (e.g., start_date, end_date)
            data: Timetable data; must contain the key `lessongroups`
                (not `lesson_groups` — the server rejects the request
                with 400 otherwise)

        Returns:
            The created timetable resource
        """
        if "lessongroups" not in data:
            raise ValueError(
                "data must contain the key 'lessongroups'"
                + (
                    " ('lesson_groups' found instead — rename it)"
                    if "lesson_groups" in data else ""
                )
            )
        body = self._post_json(
            self.make_path(),
            json={
                "metadata": metadata,
                "data": data
            }
        )
        # POST /timetable/ wraps the created resource in a data envelope
        return body["data"]

    def list_metadata(
        self, **filters: typing.Any
    ) -> "list[campus.model.TimetableMetadata]":
        """List timetable metadata records.

        Args:
            **filters: Arbitrary filter parameters applied to the query.

        Returns:
            list[campus.model.TimetableMetadata]: Matching
            timetable metadata objects. (GET /timetable/ itself now
            serves the pointer state; the list lives at
            /timetable/metadata/.)
        """
        body = self._get_json(
            self.make_path("metadata"), query=filters if filters else None
        )
        return [
            campus.model.TimetableMetadata.from_resource(item) for item in body["data"]
        ]

    class Timetable(Resource):
        """A single timetable with start date."""

        @property
        def entries(self) -> "Timetables.Timetable.Entries":
            """Get the entries resource for this timetable."""
            return Timetables.Timetable.Entries("entries", parent=self)

        @property
        def metadata(self) -> "Timetables.Timetable.Metadata":
            """Get the metadata resource for this timetable."""
            return Timetables.Timetable.Metadata("metadata", parent=self)

        @property
        def attachments(self) -> "Timetables.Timetable.Attachments":
            """Get the attachments resource for this timetable."""
            return Timetables.Timetable.Attachments("attachments", parent=self)

        def delete(self) -> None:
            """Delete this timetable version and all of its rows (#1088).

            DELETE /timetable/<id>/ cascades the version's entries, group
            members and lessongroup rows, then the version row itself.
            The API refuses the version the current or next pointer
            names with a 409 — move/cancel the pointer first. This is
            the cleanup surface for the label-drift create 409.
            """
            self._delete_json(self.make_path(end_slash=True))
            return None

        def get(self) -> campus.model.Timetable:
            """Get this timetable (metadata and labeled entries).

            GET /timetable/<id>/ wraps the resource in a
            ``{"timetable": ...}`` envelope.
            """
            body = self._get_json(self.make_path(end_slash=True))
            return campus.model.Timetable.from_resource(body["timetable"])

        class Entries(Resource):
            """Entries for a single timetable."""

            def list(self) -> "list[campus.model.TimetableEntry]":
                """Return a list of all entries for this timetable."""
                body = self._get_json(self.make_path())
                return [
                    campus.model.TimetableEntry.from_resource(item)
                    for item in body["entries"]
                ]

        class Metadata(Resource):
            """Metadata for a single timetable."""

            def get(self) -> campus.model.TimetableMetadata:
                """Get the metadata for this timetable.

                This does not include timetable entries.
                """
                body = self._get_json(self.make_path())
                return campus.model.TimetableMetadata.from_resource(body)

            def update(
                self,
                *,
                label: str | None = None,
                start_date: str | None = None,
                end_date: str | None = None,
            ) -> campus.model.TimetableMetadata:
                """Update metadata fields for this timetable (subset).

                Only the fields explicitly passed change; call with at
                least one. `label` is the committee's display name —
                to unset it use clear_label() (the API distinguishes
                an absent field from a null one, so this method cannot
                express clearing).

                Args:
                    label: The new display name (omit to leave alone).
                    start_date: New start date (omit to leave alone).
                    end_date: New end date (omit to leave alone).

                Returns:
                    The updated metadata resource.
                """
                body: dict = {}
                if label is not None:
                    body["label"] = label
                if start_date is not None:
                    body["start_date"] = start_date
                if end_date is not None:
                    body["end_date"] = end_date
                if not body:
                    raise ValueError(
                        "Update at least one field: pass label=, "
                        "start_date= or end_date= (use clear_label() "
                        "to unset the label)"
                    )
                response = self._patch_json(self.make_path(), json=body)
                return campus.model.TimetableMetadata.from_resource(response)

            def clear_label(self) -> campus.model.TimetableMetadata:
                """Clear the timetable's display label (label = null).

                Returns:
                    The updated metadata resource.
                """
                response = self._patch_json(self.make_path(), json={"label": None})
                return campus.model.TimetableMetadata.from_resource(response)

        class Attachments(Resource):
            """Attachments for a single timetable (files-backed).

            Each attachment is a files-backend record under the
            version's attachments namespace; the committee's original
            filename rides as the record's display name. Records are
            plain dicts (the files surface's shape), not campus.model
            classes.
            """

            def list(self) -> list[dict]:
                """List this timetable's attachments, newest first.

                Returns:
                    Attachment records (files-backend records); the
                    attachment id is each record's "id".
                """
                body = self._get_json(self.make_path(end_slash=True))
                return list(body["data"])

            def upload(
                self,
                data: bytes,
                filename: str,
                content_type: str | None = None,
            ) -> dict:
                """Attach a file (raw bytes) to this timetable.

                Each upload mints a new attachment — re-uploading the
                same filename adds a second copy (removal is explicit).

                Args:
                    data: Raw file content (the request body).
                    filename: The attachment's display filename (the
                        committee's original filename).
                    content_type: MIME type to store and serve back
                        (e.g. "application/pdf"); the transport
                        default applies when omitted.

                Returns:
                    The created attachment record.
                """
                query = {"filename": filename}
                resp = self.client.post_data(
                    self.make_path(end_slash=True),
                    data,
                    content_type=content_type,
                    query=query,
                )
                resp.raise_for_status()
                return resp.json()

            def get_content(self, attachment_id: str) -> bytes:
                """Get an attachment's bytes — the stored version.

                Args:
                    attachment_id: The attachment id (the record's
                        "id").

                Returns:
                    The content, as stored.
                """
                resp = self.client.get(
                    self.make_path(f"{attachment_id}/content", end_slash=True)
                )
                resp.raise_for_status()
                return resp.content

            def delete(self, attachment_id: str) -> None:
                """Remove an attachment (its record and content).

                Args:
                    attachment_id: The attachment id to remove.
                """
                self._delete_json(self.make_path(attachment_id, end_slash=True))
                return None

path = 'timetable' class-attribute instance-attribute

timeslots = TIMESLOTS class-attribute instance-attribute

weekdays = WEEKDAYS class-attribute instance-attribute

Timetable

Bases: Resource

A single timetable with start date.

Source code in campus_python/api/v1/timetable.py
187
188
189
190
191
192
193
194
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
247
248
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
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
class Timetable(Resource):
    """A single timetable with start date."""

    @property
    def entries(self) -> "Timetables.Timetable.Entries":
        """Get the entries resource for this timetable."""
        return Timetables.Timetable.Entries("entries", parent=self)

    @property
    def metadata(self) -> "Timetables.Timetable.Metadata":
        """Get the metadata resource for this timetable."""
        return Timetables.Timetable.Metadata("metadata", parent=self)

    @property
    def attachments(self) -> "Timetables.Timetable.Attachments":
        """Get the attachments resource for this timetable."""
        return Timetables.Timetable.Attachments("attachments", parent=self)

    def delete(self) -> None:
        """Delete this timetable version and all of its rows (#1088).

        DELETE /timetable/<id>/ cascades the version's entries, group
        members and lessongroup rows, then the version row itself.
        The API refuses the version the current or next pointer
        names with a 409 — move/cancel the pointer first. This is
        the cleanup surface for the label-drift create 409.
        """
        self._delete_json(self.make_path(end_slash=True))
        return None

    def get(self) -> campus.model.Timetable:
        """Get this timetable (metadata and labeled entries).

        GET /timetable/<id>/ wraps the resource in a
        ``{"timetable": ...}`` envelope.
        """
        body = self._get_json(self.make_path(end_slash=True))
        return campus.model.Timetable.from_resource(body["timetable"])

    class Entries(Resource):
        """Entries for a single timetable."""

        def list(self) -> "list[campus.model.TimetableEntry]":
            """Return a list of all entries for this timetable."""
            body = self._get_json(self.make_path())
            return [
                campus.model.TimetableEntry.from_resource(item)
                for item in body["entries"]
            ]

    class Metadata(Resource):
        """Metadata for a single timetable."""

        def get(self) -> campus.model.TimetableMetadata:
            """Get the metadata for this timetable.

            This does not include timetable entries.
            """
            body = self._get_json(self.make_path())
            return campus.model.TimetableMetadata.from_resource(body)

        def update(
            self,
            *,
            label: str | None = None,
            start_date: str | None = None,
            end_date: str | None = None,
        ) -> campus.model.TimetableMetadata:
            """Update metadata fields for this timetable (subset).

            Only the fields explicitly passed change; call with at
            least one. `label` is the committee's display name —
            to unset it use clear_label() (the API distinguishes
            an absent field from a null one, so this method cannot
            express clearing).

            Args:
                label: The new display name (omit to leave alone).
                start_date: New start date (omit to leave alone).
                end_date: New end date (omit to leave alone).

            Returns:
                The updated metadata resource.
            """
            body: dict = {}
            if label is not None:
                body["label"] = label
            if start_date is not None:
                body["start_date"] = start_date
            if end_date is not None:
                body["end_date"] = end_date
            if not body:
                raise ValueError(
                    "Update at least one field: pass label=, "
                    "start_date= or end_date= (use clear_label() "
                    "to unset the label)"
                )
            response = self._patch_json(self.make_path(), json=body)
            return campus.model.TimetableMetadata.from_resource(response)

        def clear_label(self) -> campus.model.TimetableMetadata:
            """Clear the timetable's display label (label = null).

            Returns:
                The updated metadata resource.
            """
            response = self._patch_json(self.make_path(), json={"label": None})
            return campus.model.TimetableMetadata.from_resource(response)

    class Attachments(Resource):
        """Attachments for a single timetable (files-backed).

        Each attachment is a files-backend record under the
        version's attachments namespace; the committee's original
        filename rides as the record's display name. Records are
        plain dicts (the files surface's shape), not campus.model
        classes.
        """

        def list(self) -> list[dict]:
            """List this timetable's attachments, newest first.

            Returns:
                Attachment records (files-backend records); the
                attachment id is each record's "id".
            """
            body = self._get_json(self.make_path(end_slash=True))
            return list(body["data"])

        def upload(
            self,
            data: bytes,
            filename: str,
            content_type: str | None = None,
        ) -> dict:
            """Attach a file (raw bytes) to this timetable.

            Each upload mints a new attachment — re-uploading the
            same filename adds a second copy (removal is explicit).

            Args:
                data: Raw file content (the request body).
                filename: The attachment's display filename (the
                    committee's original filename).
                content_type: MIME type to store and serve back
                    (e.g. "application/pdf"); the transport
                    default applies when omitted.

            Returns:
                The created attachment record.
            """
            query = {"filename": filename}
            resp = self.client.post_data(
                self.make_path(end_slash=True),
                data,
                content_type=content_type,
                query=query,
            )
            resp.raise_for_status()
            return resp.json()

        def get_content(self, attachment_id: str) -> bytes:
            """Get an attachment's bytes — the stored version.

            Args:
                attachment_id: The attachment id (the record's
                    "id").

            Returns:
                The content, as stored.
            """
            resp = self.client.get(
                self.make_path(f"{attachment_id}/content", end_slash=True)
            )
            resp.raise_for_status()
            return resp.content

        def delete(self, attachment_id: str) -> None:
            """Remove an attachment (its record and content).

            Args:
                attachment_id: The attachment id to remove.
            """
            self._delete_json(self.make_path(attachment_id, end_slash=True))
            return None

entries: Timetables.Timetable.Entries property

Get the entries resource for this timetable.

metadata: Timetables.Timetable.Metadata property

Get the metadata resource for this timetable.

attachments: Timetables.Timetable.Attachments property

Get the attachments resource for this timetable.

Entries

Bases: Resource

Entries for a single timetable.

Source code in campus_python/api/v1/timetable.py
226
227
228
229
230
231
232
233
234
235
class Entries(Resource):
    """Entries for a single timetable."""

    def list(self) -> "list[campus.model.TimetableEntry]":
        """Return a list of all entries for this timetable."""
        body = self._get_json(self.make_path())
        return [
            campus.model.TimetableEntry.from_resource(item)
            for item in body["entries"]
        ]
list() -> list[campus.model.TimetableEntry]

Return a list of all entries for this timetable.

Source code in campus_python/api/v1/timetable.py
229
230
231
232
233
234
235
def list(self) -> "list[campus.model.TimetableEntry]":
    """Return a list of all entries for this timetable."""
    body = self._get_json(self.make_path())
    return [
        campus.model.TimetableEntry.from_resource(item)
        for item in body["entries"]
    ]

Metadata

Bases: Resource

Metadata for a single timetable.

Source code in campus_python/api/v1/timetable.py
237
238
239
240
241
242
243
244
245
246
247
248
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
class Metadata(Resource):
    """Metadata for a single timetable."""

    def get(self) -> campus.model.TimetableMetadata:
        """Get the metadata for this timetable.

        This does not include timetable entries.
        """
        body = self._get_json(self.make_path())
        return campus.model.TimetableMetadata.from_resource(body)

    def update(
        self,
        *,
        label: str | None = None,
        start_date: str | None = None,
        end_date: str | None = None,
    ) -> campus.model.TimetableMetadata:
        """Update metadata fields for this timetable (subset).

        Only the fields explicitly passed change; call with at
        least one. `label` is the committee's display name —
        to unset it use clear_label() (the API distinguishes
        an absent field from a null one, so this method cannot
        express clearing).

        Args:
            label: The new display name (omit to leave alone).
            start_date: New start date (omit to leave alone).
            end_date: New end date (omit to leave alone).

        Returns:
            The updated metadata resource.
        """
        body: dict = {}
        if label is not None:
            body["label"] = label
        if start_date is not None:
            body["start_date"] = start_date
        if end_date is not None:
            body["end_date"] = end_date
        if not body:
            raise ValueError(
                "Update at least one field: pass label=, "
                "start_date= or end_date= (use clear_label() "
                "to unset the label)"
            )
        response = self._patch_json(self.make_path(), json=body)
        return campus.model.TimetableMetadata.from_resource(response)

    def clear_label(self) -> campus.model.TimetableMetadata:
        """Clear the timetable's display label (label = null).

        Returns:
            The updated metadata resource.
        """
        response = self._patch_json(self.make_path(), json={"label": None})
        return campus.model.TimetableMetadata.from_resource(response)
get() -> campus.model.TimetableMetadata

Get the metadata for this timetable.

This does not include timetable entries.

Source code in campus_python/api/v1/timetable.py
240
241
242
243
244
245
246
def get(self) -> campus.model.TimetableMetadata:
    """Get the metadata for this timetable.

    This does not include timetable entries.
    """
    body = self._get_json(self.make_path())
    return campus.model.TimetableMetadata.from_resource(body)
update(*, label: str | None = None, start_date: str | None = None, end_date: str | None = None) -> campus.model.TimetableMetadata

Update metadata fields for this timetable (subset).

Only the fields explicitly passed change; call with at least one. label is the committee's display name — to unset it use clear_label() (the API distinguishes an absent field from a null one, so this method cannot express clearing).

Parameters:

Name Type Description Default
label str | None

The new display name (omit to leave alone).

None
start_date str | None

New start date (omit to leave alone).

None
end_date str | None

New end date (omit to leave alone).

None

Returns:

Type Description
TimetableMetadata

The updated metadata resource.

Source code in campus_python/api/v1/timetable.py
248
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
def update(
    self,
    *,
    label: str | None = None,
    start_date: str | None = None,
    end_date: str | None = None,
) -> campus.model.TimetableMetadata:
    """Update metadata fields for this timetable (subset).

    Only the fields explicitly passed change; call with at
    least one. `label` is the committee's display name —
    to unset it use clear_label() (the API distinguishes
    an absent field from a null one, so this method cannot
    express clearing).

    Args:
        label: The new display name (omit to leave alone).
        start_date: New start date (omit to leave alone).
        end_date: New end date (omit to leave alone).

    Returns:
        The updated metadata resource.
    """
    body: dict = {}
    if label is not None:
        body["label"] = label
    if start_date is not None:
        body["start_date"] = start_date
    if end_date is not None:
        body["end_date"] = end_date
    if not body:
        raise ValueError(
            "Update at least one field: pass label=, "
            "start_date= or end_date= (use clear_label() "
            "to unset the label)"
        )
    response = self._patch_json(self.make_path(), json=body)
    return campus.model.TimetableMetadata.from_resource(response)
clear_label() -> campus.model.TimetableMetadata

Clear the timetable's display label (label = null).

Returns:

Type Description
TimetableMetadata

The updated metadata resource.

Source code in campus_python/api/v1/timetable.py
287
288
289
290
291
292
293
294
def clear_label(self) -> campus.model.TimetableMetadata:
    """Clear the timetable's display label (label = null).

    Returns:
        The updated metadata resource.
    """
    response = self._patch_json(self.make_path(), json={"label": None})
    return campus.model.TimetableMetadata.from_resource(response)

Attachments

Bases: Resource

Attachments for a single timetable (files-backed).

Each attachment is a files-backend record under the version's attachments namespace; the committee's original filename rides as the record's display name. Records are plain dicts (the files surface's shape), not campus.model classes.

Source code in campus_python/api/v1/timetable.py
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
class Attachments(Resource):
    """Attachments for a single timetable (files-backed).

    Each attachment is a files-backend record under the
    version's attachments namespace; the committee's original
    filename rides as the record's display name. Records are
    plain dicts (the files surface's shape), not campus.model
    classes.
    """

    def list(self) -> list[dict]:
        """List this timetable's attachments, newest first.

        Returns:
            Attachment records (files-backend records); the
            attachment id is each record's "id".
        """
        body = self._get_json(self.make_path(end_slash=True))
        return list(body["data"])

    def upload(
        self,
        data: bytes,
        filename: str,
        content_type: str | None = None,
    ) -> dict:
        """Attach a file (raw bytes) to this timetable.

        Each upload mints a new attachment — re-uploading the
        same filename adds a second copy (removal is explicit).

        Args:
            data: Raw file content (the request body).
            filename: The attachment's display filename (the
                committee's original filename).
            content_type: MIME type to store and serve back
                (e.g. "application/pdf"); the transport
                default applies when omitted.

        Returns:
            The created attachment record.
        """
        query = {"filename": filename}
        resp = self.client.post_data(
            self.make_path(end_slash=True),
            data,
            content_type=content_type,
            query=query,
        )
        resp.raise_for_status()
        return resp.json()

    def get_content(self, attachment_id: str) -> bytes:
        """Get an attachment's bytes — the stored version.

        Args:
            attachment_id: The attachment id (the record's
                "id").

        Returns:
            The content, as stored.
        """
        resp = self.client.get(
            self.make_path(f"{attachment_id}/content", end_slash=True)
        )
        resp.raise_for_status()
        return resp.content

    def delete(self, attachment_id: str) -> None:
        """Remove an attachment (its record and content).

        Args:
            attachment_id: The attachment id to remove.
        """
        self._delete_json(self.make_path(attachment_id, end_slash=True))
        return None
list() -> list[dict]

List this timetable's attachments, newest first.

Returns:

Type Description
list[dict]

Attachment records (files-backend records); the

list[dict]

attachment id is each record's "id".

Source code in campus_python/api/v1/timetable.py
306
307
308
309
310
311
312
313
314
def list(self) -> list[dict]:
    """List this timetable's attachments, newest first.

    Returns:
        Attachment records (files-backend records); the
        attachment id is each record's "id".
    """
    body = self._get_json(self.make_path(end_slash=True))
    return list(body["data"])
upload(data: bytes, filename: str, content_type: str | None = None) -> dict

Attach a file (raw bytes) to this timetable.

Each upload mints a new attachment — re-uploading the same filename adds a second copy (removal is explicit).

Parameters:

Name Type Description Default
data bytes

Raw file content (the request body).

required
filename str

The attachment's display filename (the committee's original filename).

required
content_type str | None

MIME type to store and serve back (e.g. "application/pdf"); the transport default applies when omitted.

None

Returns:

Type Description
dict

The created attachment record.

Source code in campus_python/api/v1/timetable.py
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
def upload(
    self,
    data: bytes,
    filename: str,
    content_type: str | None = None,
) -> dict:
    """Attach a file (raw bytes) to this timetable.

    Each upload mints a new attachment — re-uploading the
    same filename adds a second copy (removal is explicit).

    Args:
        data: Raw file content (the request body).
        filename: The attachment's display filename (the
            committee's original filename).
        content_type: MIME type to store and serve back
            (e.g. "application/pdf"); the transport
            default applies when omitted.

    Returns:
        The created attachment record.
    """
    query = {"filename": filename}
    resp = self.client.post_data(
        self.make_path(end_slash=True),
        data,
        content_type=content_type,
        query=query,
    )
    resp.raise_for_status()
    return resp.json()
get_content(attachment_id: str) -> bytes

Get an attachment's bytes — the stored version.

Parameters:

Name Type Description Default
attachment_id str

The attachment id (the record's "id").

required

Returns:

Type Description
bytes

The content, as stored.

Source code in campus_python/api/v1/timetable.py
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
def get_content(self, attachment_id: str) -> bytes:
    """Get an attachment's bytes — the stored version.

    Args:
        attachment_id: The attachment id (the record's
            "id").

    Returns:
        The content, as stored.
    """
    resp = self.client.get(
        self.make_path(f"{attachment_id}/content", end_slash=True)
    )
    resp.raise_for_status()
    return resp.content
delete(attachment_id: str) -> None

Remove an attachment (its record and content).

Parameters:

Name Type Description Default
attachment_id str

The attachment id to remove.

required
Source code in campus_python/api/v1/timetable.py
364
365
366
367
368
369
370
371
def delete(self, attachment_id: str) -> None:
    """Remove an attachment (its record and content).

    Args:
        attachment_id: The attachment id to remove.
    """
    self._delete_json(self.make_path(attachment_id, end_slash=True))
    return None

delete() -> None

Delete this timetable version and all of its rows (#1088).

DELETE /timetable// cascades the version's entries, group members and lessongroup rows, then the version row itself. The API refuses the version the current or next pointer names with a 409 — move/cancel the pointer first. This is the cleanup surface for the label-drift create 409.

Source code in campus_python/api/v1/timetable.py
205
206
207
208
209
210
211
212
213
214
215
def delete(self) -> None:
    """Delete this timetable version and all of its rows (#1088).

    DELETE /timetable/<id>/ cascades the version's entries, group
    members and lessongroup rows, then the version row itself.
    The API refuses the version the current or next pointer
    names with a 409 — move/cancel the pointer first. This is
    the cleanup surface for the label-drift create 409.
    """
    self._delete_json(self.make_path(end_slash=True))
    return None

get() -> campus.model.Timetable

Get this timetable (metadata and labeled entries).

GET /timetable// wraps the resource in a {"timetable": ...} envelope.

Source code in campus_python/api/v1/timetable.py
217
218
219
220
221
222
223
224
def get(self) -> campus.model.Timetable:
    """Get this timetable (metadata and labeled entries).

    GET /timetable/<id>/ wraps the resource in a
    ``{"timetable": ...}`` envelope.
    """
    body = self._get_json(self.make_path(end_slash=True))
    return campus.model.Timetable.from_resource(body["timetable"])

__getitem__(timetable_id: str) -> Timetables.Timetable

Get a specific timetable resource by ID.

Source code in campus_python/api/v1/timetable.py
60
61
62
def __getitem__(self, timetable_id: str) -> "Timetables.Timetable":
    """Get a specific timetable resource by ID."""
    return Timetables.Timetable(timetable_id, parent=self)

view() -> dict

Get the deployment's timetable pointer state.

Returns:

Type Description
dict

{"current": timetable_id | None, "next": timetable_id |

dict

None, "effective_from": datetime | None} — the pending

dict

switch applies at its effective_from without any client

dict

action (#1075).

Source code in campus_python/api/v1/timetable.py
64
65
66
67
68
69
70
71
72
73
def view(self) -> dict:
    """Get the deployment's timetable pointer state.

    Returns:
        {"current": timetable_id | None, "next": timetable_id |
        None, "effective_from": datetime | None} — the pending
        switch applies at its effective_from without any client
        action (#1075).
    """
    return self._get_json(self.make_path())

patch(*, current: str | None = None, next: str | None = None, effective_from: str | None = None) -> dict

Patch exactly one timetable pointer (#1075).

Pass keyword arguments, exactly one of current or next.

Parameters:

Name Type Description Default
current str | None

Timetable ID to make current, effective now.

None
next str | None

Timetable ID to schedule as the pending switch (reschedules any previous pending switch).

None
effective_from str | None

When the pending switch applies (ISO datetime, or YYYY-MM-DD for 07:00 school time). Only valid with next; absent stages an undated switch that flips only when pointed at manually.

None

Returns:

Type Description
dict

The resulting pointer state (same shape as view()).

Source code in campus_python/api/v1/timetable.py
 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
def patch(
    self,
    *,
    current: str | None = None,
    next: str | None = None,  # noqa: A002 - API field name
    effective_from: str | None = None,
) -> dict:
    """Patch exactly one timetable pointer (#1075).

    Pass keyword arguments, exactly one of `current` or `next`.

    Args:
        current: Timetable ID to make current, effective now.
        next: Timetable ID to schedule as the pending switch
            (reschedules any previous pending switch).
        effective_from: When the pending switch applies (ISO
            datetime, or YYYY-MM-DD for 07:00 school time). Only
            valid with `next`; absent stages an undated switch
            that flips only when pointed at manually.

    Returns:
        The resulting pointer state (same shape as view()).
    """
    present = {
        key: value
        for key, value in (("current", current), ("next", next))
        if value is not None
    }
    if len(present) != 1:
        raise ValueError(
            "Patch exactly one pointer: pass current=<id> or "
            "next=<id> (use cancel_next() to cancel a pending "
            "switch)"
        )
    body: dict = dict(present)
    if "next" in body and effective_from is not None:
        body["effective_from"] = effective_from
    return self._patch_json(self.make_path(), json=body)

cancel_next() -> dict

Cancel the pending scheduled switch (API: {next: null}).

Returns:

Type Description
dict

The resulting pointer state.

Source code in campus_python/api/v1/timetable.py
114
115
116
117
118
119
120
def cancel_next(self) -> dict:
    """Cancel the pending scheduled switch (API: {next: null}).

    Returns:
        The resulting pointer state.
    """
    return self._patch_json(self.make_path(), json={"next": None})

me(at: str | None = None) -> dict

Resolve the signed-in user's effective timetable view (#592).

Returns:

Type Description
dict

{"current": {timetable_id, lessongroup_id, source},

dict

"next": null | {..., effective_from, change_type}}; entries

dict

render via timetable[id].entries filtered by

dict

lessongroup_id.

Source code in campus_python/api/v1/timetable.py
122
123
124
125
126
127
128
129
130
131
132
133
134
def me(self, at: str | None = None) -> dict:
    """Resolve the signed-in user's effective timetable view (#592).

    Returns:
        {"current": {timetable_id, lessongroup_id, source},
        "next": null | {..., effective_from, change_type}}; entries
        render via timetable[id].entries filtered by
        lessongroup_id.
    """
    query = {"at": at} if at else None
    return self._get_json(
        self.make_path("me", end_slash=False), query=query
    )

new(metadata: dict, data: dict) -> dict

Create a new timetable.

Parameters:

Name Type Description Default
metadata dict

Metadata for the timetable (e.g., start_date, end_date)

required
data dict

Timetable data; must contain the key lessongroups (not lesson_groups — the server rejects the request with 400 otherwise)

required

Returns:

Type Description
dict

The created timetable resource

Source code in campus_python/api/v1/timetable.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def new(self, metadata: dict, data: dict) -> dict:
    """Create a new timetable.

    Args:
        metadata: Metadata for the timetable (e.g., start_date, end_date)
        data: Timetable data; must contain the key `lessongroups`
            (not `lesson_groups` — the server rejects the request
            with 400 otherwise)

    Returns:
        The created timetable resource
    """
    if "lessongroups" not in data:
        raise ValueError(
            "data must contain the key 'lessongroups'"
            + (
                " ('lesson_groups' found instead — rename it)"
                if "lesson_groups" in data else ""
            )
        )
    body = self._post_json(
        self.make_path(),
        json={
            "metadata": metadata,
            "data": data
        }
    )
    # POST /timetable/ wraps the created resource in a data envelope
    return body["data"]

list_metadata(**filters: typing.Any) -> list[campus.model.TimetableMetadata]

List timetable metadata records.

Parameters:

Name Type Description Default
**filters Any

Arbitrary filter parameters applied to the query.

{}

Returns:

Type Description
list[TimetableMetadata]

list[campus.model.TimetableMetadata]: Matching

list[TimetableMetadata]

timetable metadata objects. (GET /timetable/ itself now

list[TimetableMetadata]

serves the pointer state; the list lives at

list[TimetableMetadata]

/timetable/metadata/.)

Source code in campus_python/api/v1/timetable.py
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
def list_metadata(
    self, **filters: typing.Any
) -> "list[campus.model.TimetableMetadata]":
    """List timetable metadata records.

    Args:
        **filters: Arbitrary filter parameters applied to the query.

    Returns:
        list[campus.model.TimetableMetadata]: Matching
        timetable metadata objects. (GET /timetable/ itself now
        serves the pointer state; the list lives at
        /timetable/metadata/.)
    """
    body = self._get_json(
        self.make_path("metadata"), query=filters if filters else None
    )
    return [
        campus.model.TimetableMetadata.from_resource(item) for item in body["data"]
    ]

ApiRoot

campus_python.api.v1.ApiRoot

Bases: ResourceRoot

Campus API resource.

Source code in campus_python/api/v1/__init__.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
class ApiRoot(ResourceRoot):
    """Campus API resource."""
    url_prefix = "/api/v1"

    def __init__(self, json_client: JsonClient):
        super().__init__(json_client=json_client)
        self._clients = None
        self._assignments = None
        self._circles = None
        self._files = None
        self._hostedapps = None
        self._submissions = None
        self._subjcombi = None
        self._timetables = None

    @property
    def assignments(self) -> assignments.Assignments:
        """Get the assignments resource."""
        if not self._assignments:
            self._assignments = assignments.Assignments(root=self)
        return self._assignments

    @property
    def circles(self) -> circles.Circles:
        """Get the circles resource."""
        if not self._circles:
            self._circles = circles.Circles(root=self)
        return self._circles

    @property
    def files(self) -> files.Files:
        """Get the files resource (by-slug + id-based management)."""
        if not self._files:
            self._files = files.Files(root=self)
        return self._files

    @property
    def hostedapps(self) -> hostedapps.HostedApps:
        """Get the hosted-apps resource (by-slug consumer surface)."""
        if not self._hostedapps:
            self._hostedapps = hostedapps.HostedApps(root=self)
        return self._hostedapps

    @property
    def subjcombi(self) -> subjcombi.Subjcombi:
        """Get the subjcombi resource (user→lessongroup mappings)."""
        if not self._subjcombi:
            self._subjcombi = subjcombi.Subjcombi(root=self)
        return self._subjcombi

    @property
    def submissions(self) -> submissions.Submissions:
        """Get the submissions resource."""
        if not self._submissions:
            self._submissions = submissions.Submissions(root=self)
        return self._submissions

    @property
    def timetable(self) -> timetable.Timetables:
        """Get the timetable resource."""
        if not self._timetables:
            self._timetables = timetable.Timetables(root=self)
        return self._timetables

url_prefix = '/api/v1' class-attribute instance-attribute

assignments: assignments.Assignments property

Get the assignments resource.

circles: circles.Circles property

Get the circles resource.

files: files.Files property

Get the files resource (by-slug + id-based management).

hostedapps: hostedapps.HostedApps property

Get the hosted-apps resource (by-slug consumer surface).

subjcombi: subjcombi.Subjcombi property

Get the subjcombi resource (user→lessongroup mappings).

submissions: submissions.Submissions property

Get the submissions resource.

timetable: timetable.Timetables property

Get the timetable resource.

__init__(json_client: JsonClient)

Source code in campus_python/api/v1/__init__.py
22
23
24
25
26
27
28
29
30
31
def __init__(self, json_client: JsonClient):
    super().__init__(json_client=json_client)
    self._clients = None
    self._assignments = None
    self._circles = None
    self._files = None
    self._hostedapps = None
    self._submissions = None
    self._subjcombi = None
    self._timetables = None