clockster 0.1.0__tar.gz

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,65 @@
1
+ name: CI
2
+
3
+ on:
4
+ # Every branch, so work is checked before a pull request exists. Not tags: a release tag is the
5
+ # release workflow's, and it runs these same checks itself.
6
+ push:
7
+ branches: ['**']
8
+ pull_request:
9
+
10
+ # Nothing here reads or writes the repository beyond checking it out.
11
+ permissions:
12
+ contents: read
13
+
14
+ concurrency:
15
+ group: ${{ github.workflow }}-${{ github.ref }}
16
+ cancel-in-progress: true
17
+
18
+ jobs:
19
+ check:
20
+ # A pull request from this repository duplicates the push that its own branch already made;
21
+ # one from a fork has no such push, and is the only case the pull_request event has to cover.
22
+ if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
23
+ runs-on: ubuntu-latest
24
+ strategy:
25
+ fail-fast: false
26
+ matrix:
27
+ python: ['3.10', '3.11', '3.12', '3.13', '3.14']
28
+ steps:
29
+ - uses: actions/checkout@v5
30
+
31
+ - uses: actions/setup-python@v6
32
+ with:
33
+ python-version: ${{ matrix.python }}
34
+ cache: pip
35
+
36
+ - run: pip install -e ".[dev]"
37
+
38
+ - run: ruff check .
39
+
40
+ - run: ruff format --check .
41
+
42
+ - run: mypy
43
+
44
+ - run: pytest -q
45
+
46
+ # Every operation in the specification must be reachable; a name collision merges two silently.
47
+ - run: python scripts/check_operations.py
48
+
49
+ generated:
50
+ if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - uses: actions/checkout@v5
54
+
55
+ - uses: actions/setup-python@v6
56
+ with:
57
+ python-version: '3.14'
58
+ cache: pip
59
+
60
+ - run: pip install -e ".[dev]"
61
+
62
+ # The committed client must be what the committed specification produces.
63
+ - run: python scripts/generate.py
64
+
65
+ - run: git diff --exit-code -- src/clockster/_generated || (echo '::error::src/clockster/_generated is stale. Run `make generate`.' && exit 1)
@@ -0,0 +1,42 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ['v*']
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ contents: read
13
+ # Trusted publishing: PyPI accepts this run's OIDC token instead of a stored API token.
14
+ id-token: write
15
+ steps:
16
+ - uses: actions/checkout@v5
17
+
18
+ - uses: actions/setup-python@v6
19
+ with:
20
+ python-version: '3.14'
21
+ cache: pip
22
+
23
+ - run: pip install -e ".[dev]" build
24
+
25
+ - run: ruff check .
26
+
27
+ - run: mypy
28
+
29
+ - run: pytest -q
30
+
31
+ - run: python scripts/check_operations.py
32
+
33
+ # The tag and the manifest must agree, or the tag names a version nobody can install.
34
+ - name: Check the tag matches pyproject.toml
35
+ run: |
36
+ tagged="${GITHUB_REF_NAME#v}"
37
+ declared="$(python -c 'import tomllib, pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')"
38
+ test "$tagged" = "$declared" || { echo "::error::Tag $tagged does not match pyproject.toml $declared."; exit 1; }
39
+
40
+ - run: python -m build
41
+
42
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,23 @@
1
+ name: Specification drift
2
+
3
+ # Nightly rather than per pull request: the API deploys on its own schedule, and a change there
4
+ # would otherwise turn unrelated pull requests red.
5
+ on:
6
+ schedule:
7
+ - cron: '0 3 * * *'
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ drift:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v5
18
+
19
+ - uses: actions/setup-python@v6
20
+ with:
21
+ python-version: '3.14'
22
+
23
+ - run: python scripts/sync_spec.py --check
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .mypy_cache/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Clockster Pte. Ltd. and its affiliates.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,22 @@
1
+ .PHONY: spec generate check test lint types operations
2
+
3
+ spec:
4
+ python scripts/sync_spec.py
5
+
6
+ generate:
7
+ python scripts/generate.py
8
+
9
+ operations:
10
+ python scripts/check_operations.py
11
+
12
+ lint:
13
+ ruff check .
14
+ ruff format --check .
15
+
16
+ types:
17
+ mypy
18
+
19
+ test:
20
+ pytest -q
21
+
22
+ check: lint types test operations
@@ -0,0 +1,239 @@
1
+ Metadata-Version: 2.5
2
+ Name: clockster
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Clockster Company API
5
+ Project-URL: Homepage, https://github.com/clockster/sdk-python#readme
6
+ Project-URL: Source, https://github.com/clockster/sdk-python
7
+ Project-URL: Issues, https://github.com/clockster/sdk-python/issues
8
+ Project-URL: Documentation, https://api.clockster.com/openapi/v3.json
9
+ Author: Clockster Pte. Ltd.
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api-client,attendance,clockster,hr,openapi,payroll,sdk,workforce-management
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: httpx>=0.27
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy<2,>=1.11; extra == 'dev'
26
+ Requires-Dist: pytest<9,>=8; extra == 'dev'
27
+ Requires-Dist: ruff<0.13,>=0.6; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # clockster
31
+
32
+ Official Python SDK for the [Clockster Company API](https://api.clockster.com/openapi/v3.json).
33
+
34
+ Server-to-server client for a company's employees, structure, schedules, attendance, tasks and
35
+ documents. Typed from the API's OpenAPI document. One dependency, `httpx`.
36
+
37
+ ```bash
38
+ pip install clockster
39
+ ```
40
+
41
+ Requires Python 3.10 or newer.
42
+
43
+ ## Quickstart
44
+
45
+ One token authenticates one company. Create it under Settings → API in the web application.
46
+
47
+ ```python
48
+ import os
49
+
50
+ from clockster import Clockster
51
+
52
+ clockster = Clockster(token=os.environ["CLOCKSTER_TOKEN"])
53
+
54
+ me = clockster.me()
55
+
56
+ locations = clockster.locations.upsert({"items": [{"external_id": "HQ", "title": "Head office"}]})
57
+
58
+ clockster.users.upsert(
59
+ {
60
+ "users": [
61
+ {
62
+ "external_id": "HR-1",
63
+ "first_name": "Aisulu",
64
+ "role": "employee",
65
+ "location_id": locations["data"][0]["id"],
66
+ }
67
+ ]
68
+ }
69
+ )
70
+
71
+ timesheets = clockster.timesheets.list(date_from="2026-08-01", date_to="2026-08-31")
72
+ ```
73
+
74
+ A method answers the parsed body, so rows are `response["data"]`. Nothing is validated on the way
75
+ in: the answer is the JSON as it arrived, and a field we add tomorrow reaches your code today.
76
+
77
+ ## Refusals
78
+
79
+ A refusal is raised, never returned.
80
+
81
+ ```python
82
+ from clockster import ClocksterError, RateLimitError, ValidationError
83
+
84
+ try:
85
+ clockster.users.upsert({"users": [{"first_name": "Aisulu"}]})
86
+ except ValidationError as error:
87
+ print(error.code, error.errors) # validation_failed {'users.0.role': [...]}
88
+ except RateLimitError as error:
89
+ print(error.retry_after) # seconds, from Retry-After
90
+ except ClocksterError as error:
91
+ print(error.code, error.request_id)
92
+ ```
93
+
94
+ `code` is what to branch on; `message` is for a log; quote `request_id` when asking us about a
95
+ call. `AuthenticationError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ValidationError`,
96
+ `RateLimitError` and `ServerError` all descend from `ClocksterError`, so catching that one catches
97
+ everything.
98
+
99
+ ## What is available
100
+
101
+ | Group | Operations |
102
+ | --- | --- |
103
+ | `me()` | — |
104
+ | `users` | `list` `get` `upsert` `dismiss` |
105
+ | `locations`, `departments`, `positions`, `user_filters` | `list` `get` `upsert` `delete` |
106
+ | `schedules` | `create` `get` `delete` |
107
+ | `attendance` | `list` `record` |
108
+ | `timesheets` | `list` |
109
+ | `tasks` | `list` `get` `upsert` |
110
+ | `documents` | `list` `get` `upsert` `delete` |
111
+ | `files` | `upload` |
112
+ | `payroll.payslips` | `list` |
113
+ | `user_requests` | `list` `get` |
114
+ | `webhooks` | `list` `get` `create` `update` `delete` `rotate_secret` |
115
+ | `webhooks.deliveries` | `list` `get` `redeliver` |
116
+ | `webhooks.events` | `list` |
117
+
118
+ ## Paging
119
+
120
+ Listings are cursor-paged. `paginate` walks the pages and yields the rows:
121
+
122
+ ```python
123
+ from clockster import paginate
124
+
125
+ for user in paginate(clockster.users.list, per_page=100):
126
+ print(user["external_id"] or user["id"])
127
+ ```
128
+
129
+ Filters go where you would put them anyway; the cursor is the helper's business:
130
+
131
+ ```python
132
+ for mark in paginate(clockster.attendance.list, date_from="2026-08-01", date_to="2026-08-31"):
133
+ print(mark["id"])
134
+ ```
135
+
136
+ A refused page raises where it was refused — a half-read listing is not a result. A cursor is bound
137
+ to the filters it was issued under; change them and start again.
138
+
139
+ Listings answer oldest first, so a first call on a long-lived company lands years back. Ask with
140
+ `updated_since` when you want recent activity rather than all of it.
141
+
142
+ ## Relations
143
+
144
+ A related object is absent unless `include` names it, and its type says so: `location` on an
145
+ employee, `user` on a mark.
146
+
147
+ ```python
148
+ users = clockster.users.list(include=["location", "department"])
149
+
150
+ print(users["data"][0]["location"]["title"])
151
+ ```
152
+
153
+ Every list parameter takes a list and travels comma-separated: `include`, `ids`, `locations` and
154
+ the rest.
155
+
156
+ An absent key is not the same as a null one. `null` means we know the value is empty; absent means
157
+ you did not ask.
158
+
159
+ ## Types
160
+
161
+ The shapes are `TypedDict`s, so a type checker sees the fields of a row while your code keeps
162
+ plain dictionaries:
163
+
164
+ ```python
165
+ from clockster.models import UsersListRow
166
+
167
+ user: UsersListRow
168
+ for user in paginate(clockster.users.list):
169
+ print(user["first_name"])
170
+ ```
171
+
172
+ ## Async
173
+
174
+ `AsyncClockster` mirrors the whole surface.
175
+
176
+ ```python
177
+ from clockster import AsyncClockster, paginate_async
178
+
179
+ async with AsyncClockster(token=os.environ["CLOCKSTER_TOKEN"]) as clockster:
180
+ timesheets = await clockster.timesheets.list(date_from="2026-08-01", date_to="2026-08-31")
181
+
182
+ async for user in paginate_async(clockster.users.list, per_page=100):
183
+ print(user["id"])
184
+ ```
185
+
186
+ ## Webhooks
187
+
188
+ `verify_webhook` takes the body as received and answers the event, so the only path to the event
189
+ runs through the check.
190
+
191
+ ```python
192
+ from clockster import WebhookVerificationError, verify_webhook
193
+
194
+
195
+ @app.post("/clockster")
196
+ async def receive(request: Request) -> Response:
197
+ try:
198
+ event = verify_webhook(
199
+ body=await request.body(),
200
+ signature=request.headers.get("X-Clockster-Signature"),
201
+ timestamp=request.headers.get("X-Clockster-Timestamp"),
202
+ secret=os.environ["CLOCKSTER_WEBHOOK_SECRET"],
203
+ )
204
+ except WebhookVerificationError:
205
+ return Response(status_code=400)
206
+
207
+ queue.put(event)
208
+
209
+ return Response(status_code=202)
210
+ ```
211
+
212
+ - Pass the **raw bytes**. Re-serialising a parsed object does not reproduce what was signed.
213
+ - Answer **2xx quickly** and do the work afterwards; a timeout is retried.
214
+ - Deduplicate on **`id`**. The same event may arrive twice.
215
+
216
+ Deliveries older than five minutes are refused as replays; `tolerance_seconds` changes that.
217
+
218
+ ## Versioning
219
+
220
+ Semver, independent of the API version. This package targets Company API v3; a new API version is a
221
+ major release here, not a second package.
222
+
223
+ ## Development
224
+
225
+ `src/clockster/_generated` is produced from `openapi/company-v3.json` and committed, so an API
226
+ change appears in review as the lines of the client it moves.
227
+
228
+ ```bash
229
+ make spec # refresh the specification from the deployed API
230
+ make generate # regenerate the client from it
231
+ make check # ruff, mypy and the tests
232
+ ```
233
+
234
+ CI checks that the committed client is what the committed specification produces, and that every
235
+ operation is reachable on it. Drift against the deployed API is checked nightly.
236
+
237
+ ## Licence
238
+
239
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,210 @@
1
+ # clockster
2
+
3
+ Official Python SDK for the [Clockster Company API](https://api.clockster.com/openapi/v3.json).
4
+
5
+ Server-to-server client for a company's employees, structure, schedules, attendance, tasks and
6
+ documents. Typed from the API's OpenAPI document. One dependency, `httpx`.
7
+
8
+ ```bash
9
+ pip install clockster
10
+ ```
11
+
12
+ Requires Python 3.10 or newer.
13
+
14
+ ## Quickstart
15
+
16
+ One token authenticates one company. Create it under Settings → API in the web application.
17
+
18
+ ```python
19
+ import os
20
+
21
+ from clockster import Clockster
22
+
23
+ clockster = Clockster(token=os.environ["CLOCKSTER_TOKEN"])
24
+
25
+ me = clockster.me()
26
+
27
+ locations = clockster.locations.upsert({"items": [{"external_id": "HQ", "title": "Head office"}]})
28
+
29
+ clockster.users.upsert(
30
+ {
31
+ "users": [
32
+ {
33
+ "external_id": "HR-1",
34
+ "first_name": "Aisulu",
35
+ "role": "employee",
36
+ "location_id": locations["data"][0]["id"],
37
+ }
38
+ ]
39
+ }
40
+ )
41
+
42
+ timesheets = clockster.timesheets.list(date_from="2026-08-01", date_to="2026-08-31")
43
+ ```
44
+
45
+ A method answers the parsed body, so rows are `response["data"]`. Nothing is validated on the way
46
+ in: the answer is the JSON as it arrived, and a field we add tomorrow reaches your code today.
47
+
48
+ ## Refusals
49
+
50
+ A refusal is raised, never returned.
51
+
52
+ ```python
53
+ from clockster import ClocksterError, RateLimitError, ValidationError
54
+
55
+ try:
56
+ clockster.users.upsert({"users": [{"first_name": "Aisulu"}]})
57
+ except ValidationError as error:
58
+ print(error.code, error.errors) # validation_failed {'users.0.role': [...]}
59
+ except RateLimitError as error:
60
+ print(error.retry_after) # seconds, from Retry-After
61
+ except ClocksterError as error:
62
+ print(error.code, error.request_id)
63
+ ```
64
+
65
+ `code` is what to branch on; `message` is for a log; quote `request_id` when asking us about a
66
+ call. `AuthenticationError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ValidationError`,
67
+ `RateLimitError` and `ServerError` all descend from `ClocksterError`, so catching that one catches
68
+ everything.
69
+
70
+ ## What is available
71
+
72
+ | Group | Operations |
73
+ | --- | --- |
74
+ | `me()` | — |
75
+ | `users` | `list` `get` `upsert` `dismiss` |
76
+ | `locations`, `departments`, `positions`, `user_filters` | `list` `get` `upsert` `delete` |
77
+ | `schedules` | `create` `get` `delete` |
78
+ | `attendance` | `list` `record` |
79
+ | `timesheets` | `list` |
80
+ | `tasks` | `list` `get` `upsert` |
81
+ | `documents` | `list` `get` `upsert` `delete` |
82
+ | `files` | `upload` |
83
+ | `payroll.payslips` | `list` |
84
+ | `user_requests` | `list` `get` |
85
+ | `webhooks` | `list` `get` `create` `update` `delete` `rotate_secret` |
86
+ | `webhooks.deliveries` | `list` `get` `redeliver` |
87
+ | `webhooks.events` | `list` |
88
+
89
+ ## Paging
90
+
91
+ Listings are cursor-paged. `paginate` walks the pages and yields the rows:
92
+
93
+ ```python
94
+ from clockster import paginate
95
+
96
+ for user in paginate(clockster.users.list, per_page=100):
97
+ print(user["external_id"] or user["id"])
98
+ ```
99
+
100
+ Filters go where you would put them anyway; the cursor is the helper's business:
101
+
102
+ ```python
103
+ for mark in paginate(clockster.attendance.list, date_from="2026-08-01", date_to="2026-08-31"):
104
+ print(mark["id"])
105
+ ```
106
+
107
+ A refused page raises where it was refused — a half-read listing is not a result. A cursor is bound
108
+ to the filters it was issued under; change them and start again.
109
+
110
+ Listings answer oldest first, so a first call on a long-lived company lands years back. Ask with
111
+ `updated_since` when you want recent activity rather than all of it.
112
+
113
+ ## Relations
114
+
115
+ A related object is absent unless `include` names it, and its type says so: `location` on an
116
+ employee, `user` on a mark.
117
+
118
+ ```python
119
+ users = clockster.users.list(include=["location", "department"])
120
+
121
+ print(users["data"][0]["location"]["title"])
122
+ ```
123
+
124
+ Every list parameter takes a list and travels comma-separated: `include`, `ids`, `locations` and
125
+ the rest.
126
+
127
+ An absent key is not the same as a null one. `null` means we know the value is empty; absent means
128
+ you did not ask.
129
+
130
+ ## Types
131
+
132
+ The shapes are `TypedDict`s, so a type checker sees the fields of a row while your code keeps
133
+ plain dictionaries:
134
+
135
+ ```python
136
+ from clockster.models import UsersListRow
137
+
138
+ user: UsersListRow
139
+ for user in paginate(clockster.users.list):
140
+ print(user["first_name"])
141
+ ```
142
+
143
+ ## Async
144
+
145
+ `AsyncClockster` mirrors the whole surface.
146
+
147
+ ```python
148
+ from clockster import AsyncClockster, paginate_async
149
+
150
+ async with AsyncClockster(token=os.environ["CLOCKSTER_TOKEN"]) as clockster:
151
+ timesheets = await clockster.timesheets.list(date_from="2026-08-01", date_to="2026-08-31")
152
+
153
+ async for user in paginate_async(clockster.users.list, per_page=100):
154
+ print(user["id"])
155
+ ```
156
+
157
+ ## Webhooks
158
+
159
+ `verify_webhook` takes the body as received and answers the event, so the only path to the event
160
+ runs through the check.
161
+
162
+ ```python
163
+ from clockster import WebhookVerificationError, verify_webhook
164
+
165
+
166
+ @app.post("/clockster")
167
+ async def receive(request: Request) -> Response:
168
+ try:
169
+ event = verify_webhook(
170
+ body=await request.body(),
171
+ signature=request.headers.get("X-Clockster-Signature"),
172
+ timestamp=request.headers.get("X-Clockster-Timestamp"),
173
+ secret=os.environ["CLOCKSTER_WEBHOOK_SECRET"],
174
+ )
175
+ except WebhookVerificationError:
176
+ return Response(status_code=400)
177
+
178
+ queue.put(event)
179
+
180
+ return Response(status_code=202)
181
+ ```
182
+
183
+ - Pass the **raw bytes**. Re-serialising a parsed object does not reproduce what was signed.
184
+ - Answer **2xx quickly** and do the work afterwards; a timeout is retried.
185
+ - Deduplicate on **`id`**. The same event may arrive twice.
186
+
187
+ Deliveries older than five minutes are refused as replays; `tolerance_seconds` changes that.
188
+
189
+ ## Versioning
190
+
191
+ Semver, independent of the API version. This package targets Company API v3; a new API version is a
192
+ major release here, not a second package.
193
+
194
+ ## Development
195
+
196
+ `src/clockster/_generated` is produced from `openapi/company-v3.json` and committed, so an API
197
+ change appears in review as the lines of the client it moves.
198
+
199
+ ```bash
200
+ make spec # refresh the specification from the deployed API
201
+ make generate # regenerate the client from it
202
+ make check # ruff, mypy and the tests
203
+ ```
204
+
205
+ CI checks that the committed client is what the committed specification produces, and that every
206
+ operation is reachable on it. Drift against the deployed API is checked nightly.
207
+
208
+ ## Licence
209
+
210
+ MIT. See [LICENSE](LICENSE).