calendry-client 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ """Calendry API client library.
2
+
3
+ A thin, dependency-light client (:class:`CalendryClient`) plus a set of pure
4
+ functions per resource (persons, groups, rooms, offerings, ...) that build
5
+ request bodies matching the Calendry OpenAPI schema (see ``swagger.json`` in
6
+ the repository root) and call the generic CRUD/relation routes.
7
+ """
8
+
9
+ from .client import CalendryClient
10
+ from .exceptions import CalendryAPIError
11
+
12
+ from .access_roles import add_access_role, delete_access_role, get_access_role, list_access_roles, update_access_role
13
+ from .calendar_periods import add_calendar_period, delete_calendar_period, get_calendar_period, list_calendar_periods, update_calendar_period
14
+ from .constraints import (
15
+ add_constraint,
16
+ delete_constraint,
17
+ get_constraint,
18
+ get_constraint_scopes,
19
+ list_constraints,
20
+ set_constraint_scopes,
21
+ update_constraint,
22
+ )
23
+ from .equipment import add_equipment, delete_equipment, get_equipment, list_equipment, update_equipment
24
+ from .groups import (
25
+ add_group,
26
+ add_subgroup,
27
+ delete_group,
28
+ get_group,
29
+ get_group_availability,
30
+ get_group_sources,
31
+ get_group_terms,
32
+ list_groups,
33
+ set_group_availability,
34
+ set_group_sources,
35
+ set_group_terms,
36
+ update_group,
37
+ )
38
+ from .offerings import (
39
+ add_offering,
40
+ delete_offering,
41
+ get_offering,
42
+ get_offering_equipment,
43
+ get_offering_groups,
44
+ get_offering_lecturers,
45
+ list_offerings,
46
+ set_offering_equipment,
47
+ set_offering_groups,
48
+ set_offering_lecturers,
49
+ update_offering,
50
+ )
51
+ from .persons import (
52
+ add_lecturer,
53
+ add_person,
54
+ delete_person,
55
+ get_person,
56
+ get_person_access_roles,
57
+ get_person_groups,
58
+ get_person_roles,
59
+ list_persons,
60
+ set_person_access_roles,
61
+ set_person_groups,
62
+ set_person_roles,
63
+ update_person,
64
+ )
65
+ from .roles import add_role, delete_role, get_role, list_roles, update_role
66
+ from .rooms import add_room, delete_room, get_room, get_room_equipment, list_rooms, set_room_equipment, update_room
67
+ from .session_kinds import add_session_kind, delete_session_kind, get_session_kind, list_session_kinds, update_session_kind
68
+ from .terms import add_term, delete_term, get_term, list_terms, update_term
69
+ from .time_grids import (
70
+ add_time_grid,
71
+ delete_time_grid,
72
+ get_time_grid,
73
+ get_time_grid_breaks,
74
+ list_time_grids,
75
+ set_time_grid_breaks,
76
+ update_time_grid,
77
+ )
78
+
79
+ __all__ = [
80
+ "CalendryClient",
81
+ "CalendryAPIError",
82
+ # access-roles
83
+ "add_access_role",
84
+ "delete_access_role",
85
+ "get_access_role",
86
+ "list_access_roles",
87
+ "update_access_role",
88
+ # calendar-periods
89
+ "add_calendar_period",
90
+ "delete_calendar_period",
91
+ "get_calendar_period",
92
+ "list_calendar_periods",
93
+ "update_calendar_period",
94
+ # constraints
95
+ "add_constraint",
96
+ "delete_constraint",
97
+ "get_constraint",
98
+ "get_constraint_scopes",
99
+ "list_constraints",
100
+ "set_constraint_scopes",
101
+ "update_constraint",
102
+ # equipment
103
+ "add_equipment",
104
+ "delete_equipment",
105
+ "get_equipment",
106
+ "list_equipment",
107
+ "update_equipment",
108
+ # groups
109
+ "add_group",
110
+ "add_subgroup",
111
+ "delete_group",
112
+ "get_group",
113
+ "get_group_availability",
114
+ "get_group_sources",
115
+ "get_group_terms",
116
+ "list_groups",
117
+ "set_group_availability",
118
+ "set_group_sources",
119
+ "set_group_terms",
120
+ "update_group",
121
+ # offerings
122
+ "add_offering",
123
+ "delete_offering",
124
+ "get_offering",
125
+ "get_offering_equipment",
126
+ "get_offering_groups",
127
+ "get_offering_lecturers",
128
+ "list_offerings",
129
+ "set_offering_equipment",
130
+ "set_offering_groups",
131
+ "set_offering_lecturers",
132
+ "update_offering",
133
+ # persons
134
+ "add_lecturer",
135
+ "add_person",
136
+ "delete_person",
137
+ "get_person",
138
+ "get_person_access_roles",
139
+ "get_person_groups",
140
+ "get_person_roles",
141
+ "list_persons",
142
+ "set_person_access_roles",
143
+ "set_person_groups",
144
+ "set_person_roles",
145
+ "update_person",
146
+ # roles
147
+ "add_role",
148
+ "delete_role",
149
+ "get_role",
150
+ "list_roles",
151
+ "update_role",
152
+ # rooms
153
+ "add_room",
154
+ "delete_room",
155
+ "get_room",
156
+ "get_room_equipment",
157
+ "list_rooms",
158
+ "set_room_equipment",
159
+ "update_room",
160
+ # session-kinds
161
+ "add_session_kind",
162
+ "delete_session_kind",
163
+ "get_session_kind",
164
+ "list_session_kinds",
165
+ "update_session_kind",
166
+ # terms
167
+ "add_term",
168
+ "delete_term",
169
+ "get_term",
170
+ "list_terms",
171
+ "update_term",
172
+ # time-grids
173
+ "add_time_grid",
174
+ "delete_time_grid",
175
+ "get_time_grid",
176
+ "get_time_grid_breaks",
177
+ "list_time_grids",
178
+ "set_time_grid_breaks",
179
+ "update_time_grid",
180
+ ]
@@ -0,0 +1,64 @@
1
+ """Generic helpers shared by every resource module.
2
+
3
+ These mirror the generic ``/api/{resource}`` and ``/api/{resource}/{id}``
4
+ and ``/api/{resource}/{id}/{relation}`` routes documented in swagger.json.
5
+ Resource-specific modules build their pure functions on top of these.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Iterable, Optional
11
+
12
+ from .client import CalendryClient
13
+
14
+
15
+ def compact(**kwargs: Any) -> dict:
16
+ """Drop keys whose value is None, so optional fields are simply omitted."""
17
+ return {k: v for k, v in kwargs.items() if v is not None}
18
+
19
+
20
+ def list_rows(
21
+ client: CalendryClient,
22
+ resource: str,
23
+ *,
24
+ limit: Optional[int] = None,
25
+ offset: Optional[int] = None,
26
+ q: Optional[str] = None,
27
+ ids: Optional[Iterable[str]] = None,
28
+ **filters: Any,
29
+ ) -> Any:
30
+ params = compact(
31
+ limit=limit,
32
+ offset=offset,
33
+ q=q,
34
+ ids=",".join(ids) if ids is not None else None,
35
+ **filters,
36
+ )
37
+ return client.get(f"/api/{resource}", params=params or None)
38
+
39
+
40
+ def get_row(client: CalendryClient, resource: str, row_id: str) -> Any:
41
+ return client.get(f"/api/{resource}/{row_id}")
42
+
43
+
44
+ def create_row(client: CalendryClient, resource: str, body: dict) -> Any:
45
+ return client.post(f"/api/{resource}", json=body)
46
+
47
+
48
+ def update_row(client: CalendryClient, resource: str, row_id: str, body: dict) -> Any:
49
+ return client.patch(f"/api/{resource}/{row_id}", json=body)
50
+
51
+
52
+ def delete_row(client: CalendryClient, resource: str, row_id: str) -> Any:
53
+ return client.delete(f"/api/{resource}/{row_id}")
54
+
55
+
56
+ def get_relation(client: CalendryClient, resource: str, row_id: str, relation: str) -> Any:
57
+ return client.get(f"/api/{resource}/{row_id}/{relation}")
58
+
59
+
60
+ def set_relation(
61
+ client: CalendryClient, resource: str, row_id: str, relation: str, items: list
62
+ ) -> Any:
63
+ """Replace the entire membership set of a relation (PUT, not additive)."""
64
+ return client.put(f"/api/{resource}/{row_id}/{relation}", json=items)
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,56 @@
1
+ """Pure functions for the ``access-roles`` resource (authorization roles, not scheduling roles)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable, Optional
6
+
7
+ from ._generic import compact, create_row, delete_row, get_row, list_rows, update_row
8
+ from .client import CalendryClient
9
+
10
+ RESOURCE = "access-roles"
11
+
12
+
13
+ def list_access_roles(client: CalendryClient, *, limit=None, offset=None, q=None, ids: Optional[Iterable[str]] = None) -> Any:
14
+ return list_rows(client, RESOURCE, limit=limit, offset=offset, q=q, ids=ids)
15
+
16
+
17
+ def get_access_role(client: CalendryClient, access_role_id: str) -> Any:
18
+ return get_row(client, RESOURCE, access_role_id)
19
+
20
+
21
+ def add_access_role(
22
+ client: CalendryClient,
23
+ key: str,
24
+ name: str,
25
+ permission_keys: Iterable[str],
26
+ *,
27
+ description: Optional[str] = None,
28
+ ) -> Any:
29
+ """Requires ``access_role.manage`` on the server side."""
30
+ body = {
31
+ "key": key,
32
+ "name": name,
33
+ "permissions": [{"permissionKey": p} for p in permission_keys],
34
+ }
35
+ body.update(compact(description=description))
36
+ return create_row(client, RESOURCE, body)
37
+
38
+
39
+ def update_access_role(
40
+ client: CalendryClient,
41
+ access_role_id: str,
42
+ *,
43
+ name: Optional[str] = None,
44
+ description: Optional[str] = None,
45
+ permission_keys: Optional[Iterable[str]] = None,
46
+ ) -> Any:
47
+ body = compact(
48
+ name=name,
49
+ description=description,
50
+ permissions=[{"permissionKey": p} for p in permission_keys] if permission_keys is not None else None,
51
+ )
52
+ return update_row(client, RESOURCE, access_role_id, body)
53
+
54
+
55
+ def delete_access_role(client: CalendryClient, access_role_id: str) -> Any:
56
+ return delete_row(client, RESOURCE, access_role_id)
@@ -0,0 +1,48 @@
1
+ """Pure functions for the ``calendar-periods`` resource (holidays, breaks, exam periods)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable, Optional
6
+
7
+ from ._generic import compact, create_row, delete_row, get_row, list_rows, update_row
8
+ from .client import CalendryClient
9
+
10
+ RESOURCE = "calendar-periods"
11
+
12
+
13
+ def list_calendar_periods(client: CalendryClient, *, limit=None, offset=None, q=None, ids: Optional[Iterable[str]] = None) -> Any:
14
+ return list_rows(client, RESOURCE, limit=limit, offset=offset, q=q, ids=ids)
15
+
16
+
17
+ def get_calendar_period(client: CalendryClient, period_id: str) -> Any:
18
+ return get_row(client, RESOURCE, period_id)
19
+
20
+
21
+ def add_calendar_period(
22
+ client: CalendryClient,
23
+ term_id: str,
24
+ kind: str,
25
+ name: str,
26
+ start_date: str,
27
+ end_date: str,
28
+ ) -> Any:
29
+ """``kind`` is one of HOLIDAY, BREAK, EXAM. Dates are ISO 8601."""
30
+ body = {"termId": term_id, "kind": kind, "name": name, "startDate": start_date, "endDate": end_date}
31
+ return create_row(client, RESOURCE, body)
32
+
33
+
34
+ def update_calendar_period(
35
+ client: CalendryClient,
36
+ period_id: str,
37
+ *,
38
+ kind: Optional[str] = None,
39
+ name: Optional[str] = None,
40
+ start_date: Optional[str] = None,
41
+ end_date: Optional[str] = None,
42
+ ) -> Any:
43
+ body = compact(kind=kind, name=name, startDate=start_date, endDate=end_date)
44
+ return update_row(client, RESOURCE, period_id, body)
45
+
46
+
47
+ def delete_calendar_period(client: CalendryClient, period_id: str) -> Any:
48
+ return delete_row(client, RESOURCE, period_id)
@@ -0,0 +1,113 @@
1
+ """HTTP client for the Calendry API.
2
+
3
+ The server URL and the authentication token are both configurable on the
4
+ client instance (constructor args) and, via :meth:`CalendryClient.from_env`,
5
+ through environment variables. Nothing about the target server or the
6
+ credentials is hard-coded.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from typing import Any, Optional
13
+
14
+ import requests
15
+
16
+ from .exceptions import CalendryAPIError
17
+
18
+ DEFAULT_TIMEOUT = 30.0
19
+
20
+
21
+ class CalendryClient:
22
+ """Thin HTTP wrapper around the Calendry REST API.
23
+
24
+ Parameters
25
+ ----------
26
+ base_url:
27
+ Root URL of the Calendry server, e.g. ``https://calendry.example.com``.
28
+ token:
29
+ Bearer token used for the ``Authorization`` header. ``None`` means
30
+ unauthenticated requests are sent (useful against a server that
31
+ relies on a different auth mechanism, or for the public routes).
32
+ timeout:
33
+ Per-request timeout in seconds.
34
+ session:
35
+ Optional pre-configured :class:`requests.Session` (mainly for tests).
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ base_url: str,
41
+ token: Optional[str] = None,
42
+ *,
43
+ timeout: float = DEFAULT_TIMEOUT,
44
+ session: Optional[requests.Session] = None,
45
+ ) -> None:
46
+ if not base_url:
47
+ raise ValueError("base_url is required")
48
+ self.base_url = base_url.rstrip("/")
49
+ self.token = token
50
+ self.timeout = timeout
51
+ self.session = session or requests.Session()
52
+
53
+ @classmethod
54
+ def from_env(
55
+ cls,
56
+ *,
57
+ url_var: str = "CALENDRY_SERVER_URL",
58
+ token_var: str = "CALENDRY_API_TOKEN",
59
+ **kwargs: Any,
60
+ ) -> "CalendryClient":
61
+ """Build a client from environment variables.
62
+
63
+ Raises ``RuntimeError`` if the URL variable is not set.
64
+ """
65
+ base_url = os.environ.get(url_var)
66
+ if not base_url:
67
+ raise RuntimeError(f"Environment variable {url_var} is not set")
68
+ token = os.environ.get(token_var)
69
+ return cls(base_url, token, **kwargs)
70
+
71
+ def _headers(self) -> dict:
72
+ headers = {"Accept": "application/json"}
73
+ if self.token:
74
+ headers["Authorization"] = f"Bearer {self.token}"
75
+ return headers
76
+
77
+ def request(
78
+ self,
79
+ method: str,
80
+ path: str,
81
+ *,
82
+ params: Optional[dict] = None,
83
+ json: Any = None,
84
+ ) -> Any:
85
+ url = f"{self.base_url}{path}"
86
+ response = self.session.request(
87
+ method,
88
+ url,
89
+ params=params,
90
+ json=json,
91
+ headers=self._headers(),
92
+ timeout=self.timeout,
93
+ )
94
+ if not response.ok:
95
+ raise CalendryAPIError.from_response(response)
96
+ if response.status_code == 204 or not response.content:
97
+ return None
98
+ return response.json()
99
+
100
+ def get(self, path: str, *, params: Optional[dict] = None) -> Any:
101
+ return self.request("GET", path, params=params)
102
+
103
+ def post(self, path: str, *, json: Any = None) -> Any:
104
+ return self.request("POST", path, json=json)
105
+
106
+ def patch(self, path: str, *, json: Any = None) -> Any:
107
+ return self.request("PATCH", path, json=json)
108
+
109
+ def put(self, path: str, *, json: Any = None) -> Any:
110
+ return self.request("PUT", path, json=json)
111
+
112
+ def delete(self, path: str) -> Any:
113
+ return self.request("DELETE", path)
@@ -0,0 +1,80 @@
1
+ """Pure functions for the ``constraints`` resource (solver rules)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable, Optional
6
+
7
+ from ._generic import compact, create_row, delete_row, get_relation, get_row, list_rows, set_relation, update_row
8
+ from .client import CalendryClient
9
+
10
+ RESOURCE = "constraints"
11
+
12
+
13
+ def list_constraints(client: CalendryClient, *, limit=None, offset=None, q=None, ids: Optional[Iterable[str]] = None) -> Any:
14
+ return list_rows(client, RESOURCE, limit=limit, offset=offset, q=q, ids=ids)
15
+
16
+
17
+ def get_constraint(client: CalendryClient, constraint_id: str) -> Any:
18
+ return get_row(client, RESOURCE, constraint_id)
19
+
20
+
21
+ def add_constraint(
22
+ client: CalendryClient,
23
+ constraint_type: str,
24
+ name: str,
25
+ severity: str,
26
+ *,
27
+ weight: Optional[int] = None,
28
+ params: Optional[dict] = None,
29
+ is_enabled: Optional[bool] = None,
30
+ time_grid_id: Optional[str] = None,
31
+ scopes: Optional[Iterable[str]] = None,
32
+ members: Optional[Iterable[str]] = None,
33
+ ) -> Any:
34
+ """``severity`` is HARD or SOFT. ``scopes``/``members`` are id lists (kindIds / offeringIds)."""
35
+ body = {"type": constraint_type, "name": name, "severity": severity}
36
+ body.update(
37
+ compact(
38
+ weight=weight,
39
+ params=params,
40
+ isEnabled=is_enabled,
41
+ timeGridId=time_grid_id,
42
+ scopes=[{"kindId": k} for k in scopes] if scopes is not None else None,
43
+ members=[{"offeringId": o} for o in members] if members is not None else None,
44
+ )
45
+ )
46
+ return create_row(client, RESOURCE, body)
47
+
48
+
49
+ def update_constraint(
50
+ client: CalendryClient,
51
+ constraint_id: str,
52
+ *,
53
+ name: Optional[str] = None,
54
+ severity: Optional[str] = None,
55
+ weight: Optional[int] = None,
56
+ params: Optional[dict] = None,
57
+ is_enabled: Optional[bool] = None,
58
+ time_grid_id: Optional[str] = None,
59
+ ) -> Any:
60
+ body = compact(
61
+ name=name, severity=severity, weight=weight, params=params, isEnabled=is_enabled, timeGridId=time_grid_id
62
+ )
63
+ return update_row(client, RESOURCE, constraint_id, body)
64
+
65
+
66
+ def delete_constraint(client: CalendryClient, constraint_id: str) -> Any:
67
+ return delete_row(client, RESOURCE, constraint_id)
68
+
69
+
70
+ def get_constraint_scopes(client: CalendryClient, constraint_id: str) -> Any:
71
+ return get_relation(client, RESOURCE, constraint_id, "scopes")
72
+
73
+
74
+ def set_constraint_scopes(client: CalendryClient, constraint_id: str, scopes: Iterable[dict]) -> Any:
75
+ """Replace the scopes narrowing this constraint.
76
+
77
+ Each item is a dict with optional ``offering_id`` and/or ``kind_id`` (at least one required).
78
+ """
79
+ items = [compact(offeringId=s.get("offering_id"), kindId=s.get("kind_id")) for s in scopes]
80
+ return set_relation(client, RESOURCE, constraint_id, "scopes", items)
@@ -0,0 +1,32 @@
1
+ """Pure functions for the ``equipment`` resource."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable, Optional
6
+
7
+ from ._generic import compact, create_row, delete_row, get_row, list_rows, update_row
8
+ from .client import CalendryClient
9
+
10
+ RESOURCE = "equipment"
11
+
12
+
13
+ def list_equipment(client: CalendryClient, *, limit=None, offset=None, q=None, ids: Optional[Iterable[str]] = None) -> Any:
14
+ return list_rows(client, RESOURCE, limit=limit, offset=offset, q=q, ids=ids)
15
+
16
+
17
+ def get_equipment(client: CalendryClient, equipment_id: str) -> Any:
18
+ return get_row(client, RESOURCE, equipment_id)
19
+
20
+
21
+ def add_equipment(client: CalendryClient, key: str, name: str, *, description: Optional[str] = None) -> Any:
22
+ body = {"key": key, "name": name}
23
+ body.update(compact(description=description))
24
+ return create_row(client, RESOURCE, body)
25
+
26
+
27
+ def update_equipment(client: CalendryClient, equipment_id: str, *, name: Optional[str] = None, description: Optional[str] = None) -> Any:
28
+ return update_row(client, RESOURCE, equipment_id, compact(name=name, description=description))
29
+
30
+
31
+ def delete_equipment(client: CalendryClient, equipment_id: str) -> Any:
32
+ return delete_row(client, RESOURCE, equipment_id)
@@ -0,0 +1,26 @@
1
+ """Exceptions raised by the Calendry client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class CalendryAPIError(Exception):
9
+ """Raised when the Calendry API returns a non-2xx response."""
10
+
11
+ def __init__(self, status_code: int, message: str, payload: Any = None):
12
+ super().__init__(f"Calendry API error {status_code}: {message}")
13
+ self.status_code = status_code
14
+ self.payload = payload
15
+
16
+ @classmethod
17
+ def from_response(cls, response) -> "CalendryAPIError":
18
+ message = response.text
19
+ payload = None
20
+ try:
21
+ payload = response.json()
22
+ if isinstance(payload, dict):
23
+ message = payload.get("message") or payload.get("error") or message
24
+ except ValueError:
25
+ pass
26
+ return cls(response.status_code, message, payload)