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.
- clockster-0.1.0/.github/workflows/ci.yml +65 -0
- clockster-0.1.0/.github/workflows/release.yml +42 -0
- clockster-0.1.0/.github/workflows/spec-drift.yml +23 -0
- clockster-0.1.0/.gitignore +10 -0
- clockster-0.1.0/LICENSE +21 -0
- clockster-0.1.0/Makefile +22 -0
- clockster-0.1.0/PKG-INFO +239 -0
- clockster-0.1.0/README.md +210 -0
- clockster-0.1.0/openapi/company-v3.json +15599 -0
- clockster-0.1.0/pyproject.toml +58 -0
- clockster-0.1.0/scripts/check_operations.py +69 -0
- clockster-0.1.0/scripts/generate.py +464 -0
- clockster-0.1.0/scripts/sync_spec.py +54 -0
- clockster-0.1.0/src/clockster/__init__.py +52 -0
- clockster-0.1.0/src/clockster/_client.py +96 -0
- clockster-0.1.0/src/clockster/_generated/__init__.py +1 -0
- clockster-0.1.0/src/clockster/_generated/api.py +2420 -0
- clockster-0.1.0/src/clockster/_generated/models.py +1489 -0
- clockster-0.1.0/src/clockster/_transport.py +221 -0
- clockster-0.1.0/src/clockster/errors.py +85 -0
- clockster-0.1.0/src/clockster/models.py +12 -0
- clockster-0.1.0/src/clockster/pagination.py +76 -0
- clockster-0.1.0/src/clockster/py.typed +0 -0
- clockster-0.1.0/src/clockster/webhooks.py +116 -0
- clockster-0.1.0/tests/test_client.py +204 -0
- clockster-0.1.0/tests/test_pagination.py +119 -0
- clockster-0.1.0/tests/test_webhooks.py +160 -0
|
@@ -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
|
clockster-0.1.0/LICENSE
ADDED
|
@@ -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.
|
clockster-0.1.0/Makefile
ADDED
|
@@ -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
|
clockster-0.1.0/PKG-INFO
ADDED
|
@@ -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).
|