dimescheduler 0.1.2b0__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.
- dimescheduler-0.1.2b0/.gitignore +78 -0
- dimescheduler-0.1.2b0/LICENSE +21 -0
- dimescheduler-0.1.2b0/PKG-INFO +109 -0
- dimescheduler-0.1.2b0/README.md +85 -0
- dimescheduler-0.1.2b0/pyproject.toml +57 -0
- dimescheduler-0.1.2b0/src/__init__.py +68 -0
- dimescheduler-0.1.2b0/src/_version.py +3 -0
- dimescheduler-0.1.2b0/src/api/__init__.py +37 -0
- dimescheduler-0.1.2b0/src/api/crud.py +39 -0
- dimescheduler-0.1.2b0/src/api/specialized.py +183 -0
- dimescheduler-0.1.2b0/src/client.py +141 -0
- dimescheduler-0.1.2b0/src/dispatcher.py +69 -0
- dimescheduler-0.1.2b0/src/endpoints/__init__.py +0 -0
- dimescheduler-0.1.2b0/src/environment.py +7 -0
- dimescheduler-0.1.2b0/src/errors.py +155 -0
- dimescheduler-0.1.2b0/src/models/__init__.py +0 -0
- dimescheduler-0.1.2b0/src/result.py +55 -0
- dimescheduler-0.1.2b0/src/retry.py +149 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# OS
|
|
2
|
+
.DS_Store
|
|
3
|
+
Thumbs.db
|
|
4
|
+
desktop.ini
|
|
5
|
+
|
|
6
|
+
# Editor
|
|
7
|
+
.vs/
|
|
8
|
+
.idea/
|
|
9
|
+
*.swp
|
|
10
|
+
*.swo
|
|
11
|
+
*.bak
|
|
12
|
+
*.tmp
|
|
13
|
+
*~
|
|
14
|
+
|
|
15
|
+
# Secrets / env
|
|
16
|
+
.env
|
|
17
|
+
.env.local
|
|
18
|
+
.env.*.local
|
|
19
|
+
.env.development
|
|
20
|
+
!.env.example
|
|
21
|
+
!.env.sample
|
|
22
|
+
|
|
23
|
+
# Logs
|
|
24
|
+
*.log
|
|
25
|
+
npm-debug.log*
|
|
26
|
+
yarn-debug.log*
|
|
27
|
+
yarn-error.log*
|
|
28
|
+
|
|
29
|
+
# .NET
|
|
30
|
+
**/bin/
|
|
31
|
+
**/obj/
|
|
32
|
+
TestResults/
|
|
33
|
+
**/*.DotSettings.user
|
|
34
|
+
**/*.user
|
|
35
|
+
src/packages/
|
|
36
|
+
*.suo
|
|
37
|
+
*.userosscache
|
|
38
|
+
*.sln.docstates
|
|
39
|
+
|
|
40
|
+
# JavaScript / TypeScript
|
|
41
|
+
**/node_modules/
|
|
42
|
+
**/dist/
|
|
43
|
+
*.tgz
|
|
44
|
+
.yarn/cache
|
|
45
|
+
.yarn/build-state.yml
|
|
46
|
+
.yarn/install-state.gz
|
|
47
|
+
.yarn/unplugged
|
|
48
|
+
.pnp.*
|
|
49
|
+
|
|
50
|
+
# Python
|
|
51
|
+
__pycache__/
|
|
52
|
+
*.py[cod]
|
|
53
|
+
*$py.class
|
|
54
|
+
*.egg
|
|
55
|
+
*.egg-info/
|
|
56
|
+
.venv/
|
|
57
|
+
venv/
|
|
58
|
+
.env-py/
|
|
59
|
+
build/
|
|
60
|
+
.pytest_cache/
|
|
61
|
+
.ruff_cache/
|
|
62
|
+
.mypy_cache/
|
|
63
|
+
.tox/
|
|
64
|
+
|
|
65
|
+
# Mock server (regenerated on every run)
|
|
66
|
+
scripts/.cache/
|
|
67
|
+
|
|
68
|
+
# act — local secrets file (template lives at .secrets.example)
|
|
69
|
+
.secrets
|
|
70
|
+
|
|
71
|
+
# Coverage
|
|
72
|
+
coverage/
|
|
73
|
+
htmlcov/
|
|
74
|
+
.coverage
|
|
75
|
+
.coverage.*
|
|
76
|
+
*.lcov
|
|
77
|
+
lcov.info
|
|
78
|
+
*.cover
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Dime Software
|
|
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,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dimescheduler
|
|
3
|
+
Version: 0.1.2b0
|
|
4
|
+
Summary: The official Python SDK for Dime.Scheduler
|
|
5
|
+
Project-URL: Homepage, https://docs.dimescheduler.com
|
|
6
|
+
Project-URL: Repository, https://github.com/dime-scheduler/sdk
|
|
7
|
+
Project-URL: Documentation, https://docs.dimescheduler.com/develop/api
|
|
8
|
+
Author: Dime Software
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: business-central,dynamics-365,planning,power-apps,scheduling
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Requires-Dist: httpx>=0.27
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: openapi-python-client>=0.21; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Dime.Scheduler SDK for Python
|
|
26
|
+
|
|
27
|
+
The official Python SDK for [Dime.Scheduler](https://docs.dimescheduler.com).
|
|
28
|
+
|
|
29
|
+
> **Status:** alpha. The domain-grouped accessor surface is in place; typed entity models are landing.
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install dimescheduler
|
|
35
|
+
# or
|
|
36
|
+
uv add dimescheduler
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Prereleases
|
|
40
|
+
|
|
41
|
+
Alpha / beta / release-candidate builds (e.g. `0.1.1b0` for `0.1.1-beta.0`) ship as PyPI prereleases. To opt into them:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install --pre dimescheduler
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from dimescheduler import DimeSchedulerClient, Environment
|
|
51
|
+
|
|
52
|
+
with DimeSchedulerClient(api_key="MY_API_KEY", environment=Environment.Sandbox) as client:
|
|
53
|
+
result = client.categories.create({
|
|
54
|
+
"name": "INSTALL",
|
|
55
|
+
"displayName": "Install",
|
|
56
|
+
"color": "#22d3ee",
|
|
57
|
+
})
|
|
58
|
+
if not result.ok:
|
|
59
|
+
raise RuntimeError(result.error)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
By default the client targets `Environment.Production`. Switch to `Sandbox` or `Test` for non-prod.
|
|
63
|
+
|
|
64
|
+
## API surface
|
|
65
|
+
|
|
66
|
+
Every entity hangs off a typed accessor on the client. CRUD-shaped entities expose `create` / `update` / `delete` / `get_all`:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
client.categories.create(category)
|
|
70
|
+
client.categories.update(category)
|
|
71
|
+
client.categories.delete(category)
|
|
72
|
+
result = client.categories.get_all()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Endpoints with required parameters expose them directly:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
client.appointments.get(start_date, end_date, resources=["R1", "R2"])
|
|
79
|
+
client.notifications.get(page=1, limit=50, sort="createdAt:desc")
|
|
80
|
+
client.geocoding.geocode_text("221B Baker Street", "GB")
|
|
81
|
+
client.optimization.field_service(request)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Every method returns a `Result`:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
result = client.categories.get_all()
|
|
88
|
+
result.ok # bool — convenience over response.is_success
|
|
89
|
+
result.data # parsed JSON body on success, else None
|
|
90
|
+
result.error # parsed JSON error body on 4xx/5xx, else None
|
|
91
|
+
result.response # underlying httpx.Response
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The accessor names mirror the .NET and JS SDKs 1:1 (with idiomatic `snake_case`), so cross-language code reads the same.
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
This package is part of the [`dime-scheduler/sdk`](https://github.com/dime-scheduler/sdk) monorepo. From `packages/python/`:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv sync --extra dev # install runtime + dev deps
|
|
102
|
+
uv run pytest # run unit tests
|
|
103
|
+
uv run ruff check # lint
|
|
104
|
+
./scripts/codegen.sh # regenerate typed client from ../../spec/openapi.json
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
[MIT](../../LICENSE)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Dime.Scheduler SDK for Python
|
|
2
|
+
|
|
3
|
+
The official Python SDK for [Dime.Scheduler](https://docs.dimescheduler.com).
|
|
4
|
+
|
|
5
|
+
> **Status:** alpha. The domain-grouped accessor surface is in place; typed entity models are landing.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install dimescheduler
|
|
11
|
+
# or
|
|
12
|
+
uv add dimescheduler
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### Prereleases
|
|
16
|
+
|
|
17
|
+
Alpha / beta / release-candidate builds (e.g. `0.1.1b0` for `0.1.1-beta.0`) ship as PyPI prereleases. To opt into them:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install --pre dimescheduler
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from dimescheduler import DimeSchedulerClient, Environment
|
|
27
|
+
|
|
28
|
+
with DimeSchedulerClient(api_key="MY_API_KEY", environment=Environment.Sandbox) as client:
|
|
29
|
+
result = client.categories.create({
|
|
30
|
+
"name": "INSTALL",
|
|
31
|
+
"displayName": "Install",
|
|
32
|
+
"color": "#22d3ee",
|
|
33
|
+
})
|
|
34
|
+
if not result.ok:
|
|
35
|
+
raise RuntimeError(result.error)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
By default the client targets `Environment.Production`. Switch to `Sandbox` or `Test` for non-prod.
|
|
39
|
+
|
|
40
|
+
## API surface
|
|
41
|
+
|
|
42
|
+
Every entity hangs off a typed accessor on the client. CRUD-shaped entities expose `create` / `update` / `delete` / `get_all`:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
client.categories.create(category)
|
|
46
|
+
client.categories.update(category)
|
|
47
|
+
client.categories.delete(category)
|
|
48
|
+
result = client.categories.get_all()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Endpoints with required parameters expose them directly:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
client.appointments.get(start_date, end_date, resources=["R1", "R2"])
|
|
55
|
+
client.notifications.get(page=1, limit=50, sort="createdAt:desc")
|
|
56
|
+
client.geocoding.geocode_text("221B Baker Street", "GB")
|
|
57
|
+
client.optimization.field_service(request)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Every method returns a `Result`:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
result = client.categories.get_all()
|
|
64
|
+
result.ok # bool — convenience over response.is_success
|
|
65
|
+
result.data # parsed JSON body on success, else None
|
|
66
|
+
result.error # parsed JSON error body on 4xx/5xx, else None
|
|
67
|
+
result.response # underlying httpx.Response
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The accessor names mirror the .NET and JS SDKs 1:1 (with idiomatic `snake_case`), so cross-language code reads the same.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
This package is part of the [`dime-scheduler/sdk`](https://github.com/dime-scheduler/sdk) monorepo. From `packages/python/`:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
uv sync --extra dev # install runtime + dev deps
|
|
78
|
+
uv run pytest # run unit tests
|
|
79
|
+
uv run ruff check # lint
|
|
80
|
+
./scripts/codegen.sh # regenerate typed client from ../../spec/openapi.json
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
[MIT](../../LICENSE)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dimescheduler"
|
|
7
|
+
version = "0.1.2b0"
|
|
8
|
+
description = "The official Python SDK for Dime.Scheduler"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Dime Software" }]
|
|
13
|
+
keywords = ["scheduling", "planning", "business-central", "dynamics-365", "power-apps"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
18
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"httpx>=0.27",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
dev = [
|
|
26
|
+
"pytest>=8.0",
|
|
27
|
+
"pytest-asyncio>=0.23",
|
|
28
|
+
"ruff>=0.6",
|
|
29
|
+
"openapi-python-client>=0.21",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://docs.dimescheduler.com"
|
|
34
|
+
Repository = "https://github.com/dime-scheduler/sdk"
|
|
35
|
+
Documentation = "https://docs.dimescheduler.com/develop/api"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
38
|
+
"src" = "dimescheduler"
|
|
39
|
+
|
|
40
|
+
[tool.hatch.build.targets.sdist]
|
|
41
|
+
include = [
|
|
42
|
+
"src/**",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE",
|
|
45
|
+
"pyproject.toml",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 120
|
|
50
|
+
target-version = "py39"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "I", "N", "UP", "B", "SIM", "RUF"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
pythonpath = ["."]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from dimescheduler._version import __version__
|
|
2
|
+
from dimescheduler.api import (
|
|
3
|
+
AppointmentApi,
|
|
4
|
+
AppointmentDependencyApi,
|
|
5
|
+
AppointmentFieldApi,
|
|
6
|
+
CalendarApi,
|
|
7
|
+
ConnectorApi,
|
|
8
|
+
CrudApi,
|
|
9
|
+
GeocodeApi,
|
|
10
|
+
ImportApi,
|
|
11
|
+
MessageApi,
|
|
12
|
+
NotificationApi,
|
|
13
|
+
OptimizationApi,
|
|
14
|
+
RecommendationApi,
|
|
15
|
+
RecurringAppointmentApi,
|
|
16
|
+
ResourceCapacityApi,
|
|
17
|
+
ResourceTypeApi,
|
|
18
|
+
UserApi,
|
|
19
|
+
)
|
|
20
|
+
from dimescheduler.client import DimeSchedulerClient
|
|
21
|
+
from dimescheduler.environment import Environment
|
|
22
|
+
from dimescheduler.errors import (
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
AuthorizationError,
|
|
25
|
+
ConflictError,
|
|
26
|
+
DimeSchedulerError,
|
|
27
|
+
NetworkError,
|
|
28
|
+
NotFoundError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
ServerError,
|
|
31
|
+
ValidationError,
|
|
32
|
+
)
|
|
33
|
+
from dimescheduler.result import Result
|
|
34
|
+
from dimescheduler.retry import RetryConfig, RetryTransport
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"AppointmentApi",
|
|
38
|
+
"AppointmentDependencyApi",
|
|
39
|
+
"AppointmentFieldApi",
|
|
40
|
+
"AuthenticationError",
|
|
41
|
+
"AuthorizationError",
|
|
42
|
+
"CalendarApi",
|
|
43
|
+
"ConflictError",
|
|
44
|
+
"ConnectorApi",
|
|
45
|
+
"CrudApi",
|
|
46
|
+
"DimeSchedulerClient",
|
|
47
|
+
"DimeSchedulerError",
|
|
48
|
+
"Environment",
|
|
49
|
+
"GeocodeApi",
|
|
50
|
+
"ImportApi",
|
|
51
|
+
"MessageApi",
|
|
52
|
+
"NetworkError",
|
|
53
|
+
"NotFoundError",
|
|
54
|
+
"NotificationApi",
|
|
55
|
+
"OptimizationApi",
|
|
56
|
+
"RateLimitError",
|
|
57
|
+
"RecommendationApi",
|
|
58
|
+
"RecurringAppointmentApi",
|
|
59
|
+
"ResourceCapacityApi",
|
|
60
|
+
"ResourceTypeApi",
|
|
61
|
+
"Result",
|
|
62
|
+
"RetryConfig",
|
|
63
|
+
"RetryTransport",
|
|
64
|
+
"ServerError",
|
|
65
|
+
"UserApi",
|
|
66
|
+
"ValidationError",
|
|
67
|
+
"__version__",
|
|
68
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from dimescheduler.api.crud import CrudApi
|
|
2
|
+
from dimescheduler.api.specialized import (
|
|
3
|
+
AppointmentApi,
|
|
4
|
+
AppointmentDependencyApi,
|
|
5
|
+
AppointmentFieldApi,
|
|
6
|
+
CalendarApi,
|
|
7
|
+
ConnectorApi,
|
|
8
|
+
GeocodeApi,
|
|
9
|
+
ImportApi,
|
|
10
|
+
MessageApi,
|
|
11
|
+
NotificationApi,
|
|
12
|
+
OptimizationApi,
|
|
13
|
+
RecommendationApi,
|
|
14
|
+
RecurringAppointmentApi,
|
|
15
|
+
ResourceCapacityApi,
|
|
16
|
+
ResourceTypeApi,
|
|
17
|
+
UserApi,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AppointmentApi",
|
|
22
|
+
"AppointmentDependencyApi",
|
|
23
|
+
"AppointmentFieldApi",
|
|
24
|
+
"CalendarApi",
|
|
25
|
+
"ConnectorApi",
|
|
26
|
+
"CrudApi",
|
|
27
|
+
"GeocodeApi",
|
|
28
|
+
"ImportApi",
|
|
29
|
+
"MessageApi",
|
|
30
|
+
"NotificationApi",
|
|
31
|
+
"OptimizationApi",
|
|
32
|
+
"RecommendationApi",
|
|
33
|
+
"RecurringAppointmentApi",
|
|
34
|
+
"ResourceCapacityApi",
|
|
35
|
+
"ResourceTypeApi",
|
|
36
|
+
"UserApi",
|
|
37
|
+
]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any, Union
|
|
5
|
+
|
|
6
|
+
from dimescheduler.dispatcher import UNSET, Dispatcher, TimeoutT, timeout_kw
|
|
7
|
+
from dimescheduler.result import Result
|
|
8
|
+
|
|
9
|
+
Entity = Mapping[str, Any]
|
|
10
|
+
EntityOrList = Union[Entity, Sequence[Entity]]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CrudApi:
|
|
14
|
+
"""Generic CRUD client. Same call surface for every import-shaped entity.
|
|
15
|
+
|
|
16
|
+
Mirrors the .NET ``CrudApi<T>`` class: ``create`` / ``update`` / ``delete``
|
|
17
|
+
accept either a single entity (mapping) or a list of entities; ``get_all``
|
|
18
|
+
issues a GET against the same route with optional query parameters.
|
|
19
|
+
|
|
20
|
+
Every method accepts a kwarg-only ``timeout`` (the per-request cancellation
|
|
21
|
+
analog): a float in seconds, an ``httpx.Timeout``, or ``None`` to wait
|
|
22
|
+
indefinitely. Omit it to inherit the client-level timeout.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, dispatcher: Dispatcher, route: str) -> None:
|
|
26
|
+
self._dispatcher = dispatcher
|
|
27
|
+
self._route = route
|
|
28
|
+
|
|
29
|
+
def create(self, entity: EntityOrList, *, timeout: TimeoutT = UNSET) -> Result:
|
|
30
|
+
return self._dispatcher.request("POST", self._route, body=entity, **timeout_kw(timeout))
|
|
31
|
+
|
|
32
|
+
def update(self, entity: EntityOrList, *, timeout: TimeoutT = UNSET) -> Result:
|
|
33
|
+
return self._dispatcher.request("PUT", self._route, body=entity, **timeout_kw(timeout))
|
|
34
|
+
|
|
35
|
+
def delete(self, entity: EntityOrList, *, timeout: TimeoutT = UNSET) -> Result:
|
|
36
|
+
return self._dispatcher.request("DELETE", self._route, body=entity, **timeout_kw(timeout))
|
|
37
|
+
|
|
38
|
+
def get_all(self, query: dict[str, Any] | None = None, *, timeout: TimeoutT = UNSET) -> Result:
|
|
39
|
+
return self._dispatcher.request("GET", self._route, params=query, **timeout_kw(timeout))
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from dimescheduler.api.crud import CrudApi
|
|
8
|
+
from dimescheduler.dispatcher import UNSET, Dispatcher, TimeoutT, timeout_kw
|
|
9
|
+
from dimescheduler.result import Result
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _iso(value: datetime | str) -> str:
|
|
13
|
+
return value if isinstance(value, str) else value.isoformat()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AppointmentApi(CrudApi):
|
|
17
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
18
|
+
super().__init__(dispatcher, "/appointment")
|
|
19
|
+
|
|
20
|
+
def get(
|
|
21
|
+
self,
|
|
22
|
+
start_date: datetime | str,
|
|
23
|
+
end_date: datetime | str,
|
|
24
|
+
resources: list[str] | None = None,
|
|
25
|
+
*,
|
|
26
|
+
timeout: TimeoutT = UNSET,
|
|
27
|
+
) -> Result:
|
|
28
|
+
return self._dispatcher.request(
|
|
29
|
+
"GET",
|
|
30
|
+
self._route,
|
|
31
|
+
params={"startDate": _iso(start_date), "endDate": _iso(end_date), "resources": resources},
|
|
32
|
+
**timeout_kw(timeout),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ResourceCapacityApi(CrudApi):
|
|
37
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
38
|
+
super().__init__(dispatcher, "/resourceCapacity")
|
|
39
|
+
|
|
40
|
+
def get(self, start: datetime | str, end: datetime | str, *, timeout: TimeoutT = UNSET) -> Result:
|
|
41
|
+
return self._dispatcher.request(
|
|
42
|
+
"GET",
|
|
43
|
+
self._route,
|
|
44
|
+
params={"start": _iso(start), "end": _iso(end)},
|
|
45
|
+
**timeout_kw(timeout),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NotificationApi(CrudApi):
|
|
50
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
51
|
+
super().__init__(dispatcher, "/notification")
|
|
52
|
+
|
|
53
|
+
def get(
|
|
54
|
+
self,
|
|
55
|
+
page: int,
|
|
56
|
+
limit: int,
|
|
57
|
+
*,
|
|
58
|
+
sort: str | None = None,
|
|
59
|
+
group: str | None = None,
|
|
60
|
+
filter: str | None = None,
|
|
61
|
+
timeout: TimeoutT = UNSET,
|
|
62
|
+
) -> Result:
|
|
63
|
+
return self._dispatcher.request(
|
|
64
|
+
"GET",
|
|
65
|
+
self._route,
|
|
66
|
+
params={"page": page, "limit": limit, "sort": sort, "group": group, "filter": filter},
|
|
67
|
+
**timeout_kw(timeout),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _ReadOnlyApi:
|
|
72
|
+
def __init__(self, dispatcher: Dispatcher, route: str) -> None:
|
|
73
|
+
self._dispatcher = dispatcher
|
|
74
|
+
self._route = route
|
|
75
|
+
|
|
76
|
+
def get_all(self, *, timeout: TimeoutT = UNSET) -> Result:
|
|
77
|
+
return self._dispatcher.request("GET", self._route, **timeout_kw(timeout))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AppointmentDependencyApi(_ReadOnlyApi):
|
|
81
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
82
|
+
super().__init__(dispatcher, "/appointmentDependency")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class CalendarApi(_ReadOnlyApi):
|
|
86
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
87
|
+
super().__init__(dispatcher, "/calendar")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ResourceTypeApi(_ReadOnlyApi):
|
|
91
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
92
|
+
super().__init__(dispatcher, "/resourcetype")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class AppointmentFieldApi(_ReadOnlyApi):
|
|
96
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
97
|
+
super().__init__(dispatcher, "/appointmentField")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class GeocodeApi:
|
|
101
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
102
|
+
self._dispatcher = dispatcher
|
|
103
|
+
|
|
104
|
+
def geocode(self, address: Mapping[str, Any], *, timeout: TimeoutT = UNSET) -> Result:
|
|
105
|
+
return self._dispatcher.request("POST", "/geocode", body=address, **timeout_kw(timeout))
|
|
106
|
+
|
|
107
|
+
def geocode_text(self, address: str, country: str, *, timeout: TimeoutT = UNSET) -> Result:
|
|
108
|
+
return self._dispatcher.request(
|
|
109
|
+
"GET", "/geocode", params={"address": address, "country": country}, **timeout_kw(timeout)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class OptimizationApi:
|
|
114
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
115
|
+
self._dispatcher = dispatcher
|
|
116
|
+
|
|
117
|
+
def field_service(self, request: Mapping[str, Any], *, timeout: TimeoutT = UNSET) -> Result:
|
|
118
|
+
return self._dispatcher.request("POST", "/optimization/fieldService", body=request, **timeout_kw(timeout))
|
|
119
|
+
|
|
120
|
+
def professional_services(self, request: Mapping[str, Any], *, timeout: TimeoutT = UNSET) -> Result:
|
|
121
|
+
return self._dispatcher.request(
|
|
122
|
+
"POST", "/optimization/professionalServices", body=request, **timeout_kw(timeout)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def daily_route(self, request: Mapping[str, Any], *, timeout: TimeoutT = UNSET) -> Result:
|
|
126
|
+
return self._dispatcher.request("POST", "/optimization/dailyRoute", body=request, **timeout_kw(timeout))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class RecommendationApi:
|
|
130
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
131
|
+
self._dispatcher = dispatcher
|
|
132
|
+
|
|
133
|
+
def get(self, request: Mapping[str, Any], *, timeout: TimeoutT = UNSET) -> Result:
|
|
134
|
+
return self._dispatcher.request("POST", "/recommendation", body=request, **timeout_kw(timeout))
|
|
135
|
+
|
|
136
|
+
def scorers(self, *, timeout: TimeoutT = UNSET) -> Result:
|
|
137
|
+
return self._dispatcher.request("GET", "/recommendation/scorers", **timeout_kw(timeout))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class MessageApi:
|
|
141
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
142
|
+
self._dispatcher = dispatcher
|
|
143
|
+
|
|
144
|
+
def send(self, message: Mapping[str, Any], *, timeout: TimeoutT = UNSET) -> Result:
|
|
145
|
+
return self._dispatcher.request("POST", "/message", body=message, **timeout_kw(timeout))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class ConnectorApi:
|
|
149
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
150
|
+
self._dispatcher = dispatcher
|
|
151
|
+
|
|
152
|
+
def create(self, request: Mapping[str, Any], *, timeout: TimeoutT = UNSET) -> Result:
|
|
153
|
+
return self._dispatcher.request("POST", "/connector", body=request, **timeout_kw(timeout))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class UserApi:
|
|
157
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
158
|
+
self._dispatcher = dispatcher
|
|
159
|
+
|
|
160
|
+
def create(self, user: Mapping[str, Any], *, timeout: TimeoutT = UNSET) -> Result:
|
|
161
|
+
return self._dispatcher.request("POST", "/user", body=user, **timeout_kw(timeout))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class RecurringAppointmentApi:
|
|
165
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
166
|
+
self._dispatcher = dispatcher
|
|
167
|
+
|
|
168
|
+
def create(self, request: Mapping[str, Any], *, timeout: TimeoutT = UNSET) -> Result:
|
|
169
|
+
return self._dispatcher.request("POST", "/appointmentRecurring", body=request, **timeout_kw(timeout))
|
|
170
|
+
|
|
171
|
+
def delete(self, request: Mapping[str, Any], *, timeout: TimeoutT = UNSET) -> Result:
|
|
172
|
+
return self._dispatcher.request("DELETE", "/appointmentRecurring", body=request, **timeout_kw(timeout))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class ImportApi:
|
|
176
|
+
def __init__(self, dispatcher: Dispatcher) -> None:
|
|
177
|
+
self._dispatcher = dispatcher
|
|
178
|
+
|
|
179
|
+
def run(self, payload: Mapping[str, Any] | list[Mapping[str, Any]], *, timeout: TimeoutT = UNSET) -> Result:
|
|
180
|
+
return self._dispatcher.request("POST", "/import", body=payload, **timeout_kw(timeout))
|
|
181
|
+
|
|
182
|
+
def setup(self, payload: Mapping[str, Any], *, timeout: TimeoutT = UNSET) -> Result:
|
|
183
|
+
return self._dispatcher.request("POST", "/import/setup", body=payload, **timeout_kw(timeout))
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from dimescheduler._version import __version__
|
|
7
|
+
from dimescheduler.api.crud import CrudApi
|
|
8
|
+
from dimescheduler.api.specialized import (
|
|
9
|
+
AppointmentApi,
|
|
10
|
+
AppointmentDependencyApi,
|
|
11
|
+
AppointmentFieldApi,
|
|
12
|
+
CalendarApi,
|
|
13
|
+
ConnectorApi,
|
|
14
|
+
GeocodeApi,
|
|
15
|
+
ImportApi,
|
|
16
|
+
MessageApi,
|
|
17
|
+
NotificationApi,
|
|
18
|
+
OptimizationApi,
|
|
19
|
+
RecommendationApi,
|
|
20
|
+
RecurringAppointmentApi,
|
|
21
|
+
ResourceCapacityApi,
|
|
22
|
+
ResourceTypeApi,
|
|
23
|
+
UserApi,
|
|
24
|
+
)
|
|
25
|
+
from dimescheduler.dispatcher import Dispatcher
|
|
26
|
+
from dimescheduler.environment import Environment
|
|
27
|
+
from dimescheduler.retry import RetryConfig, RetryTransport
|
|
28
|
+
|
|
29
|
+
USER_AGENT = f"dimescheduler-python/{__version__}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DimeSchedulerClient:
|
|
33
|
+
"""Domain-grouped client for the Dime.Scheduler HTTP API.
|
|
34
|
+
|
|
35
|
+
Every entity hangs off a typed accessor (``client.categories``,
|
|
36
|
+
``client.appointments``, ``client.resources``, …) — the surface mirrors the
|
|
37
|
+
.NET and JS SDKs 1:1 so cross-language code reads the same. CRUD-shaped
|
|
38
|
+
entities expose ``create`` / ``update`` / ``delete`` / ``get_all``;
|
|
39
|
+
specialized endpoints get purpose-built methods.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_key: str,
|
|
45
|
+
environment: Environment = Environment.Production,
|
|
46
|
+
*,
|
|
47
|
+
timeout: float = 30.0,
|
|
48
|
+
headers: dict[str, str] | None = None,
|
|
49
|
+
retry: RetryConfig | None | Literal[False] = None,
|
|
50
|
+
transport: httpx.BaseTransport | None = None,
|
|
51
|
+
**httpx_kwargs: Any,
|
|
52
|
+
) -> None:
|
|
53
|
+
if not api_key:
|
|
54
|
+
raise ValueError("api_key is required")
|
|
55
|
+
|
|
56
|
+
merged_headers: dict[str, str] = {
|
|
57
|
+
"X-API-KEY": api_key,
|
|
58
|
+
"User-Agent": USER_AGENT,
|
|
59
|
+
}
|
|
60
|
+
if headers:
|
|
61
|
+
# Caller-supplied headers win, including a custom User-Agent.
|
|
62
|
+
merged_headers.update(headers)
|
|
63
|
+
|
|
64
|
+
effective_transport = (
|
|
65
|
+
transport if retry is False else RetryTransport(config=retry, inner=transport)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self._http = httpx.Client(
|
|
69
|
+
base_url=environment.value,
|
|
70
|
+
headers=merged_headers,
|
|
71
|
+
timeout=timeout,
|
|
72
|
+
transport=effective_transport,
|
|
73
|
+
**httpx_kwargs,
|
|
74
|
+
)
|
|
75
|
+
self._environment = environment
|
|
76
|
+
d = Dispatcher(self._http)
|
|
77
|
+
|
|
78
|
+
self.action_uris = CrudApi(d, "/actionUri")
|
|
79
|
+
self.appointment_categories = CrudApi(d, "/appointmentCategory")
|
|
80
|
+
self.appointment_contents = CrudApi(d, "/appointmentcontent")
|
|
81
|
+
self.appointment_containers = CrudApi(d, "/appointmentContainer")
|
|
82
|
+
self.appointment_field_values = CrudApi(d, "/appointmentFieldValue")
|
|
83
|
+
self.appointment_importances = CrudApi(d, "/appointmentImportance")
|
|
84
|
+
self.appointment_locked = CrudApi(d, "/appointmentLocked")
|
|
85
|
+
self.appointment_planning_quantities = CrudApi(d, "/appointmentPlanningQuantity")
|
|
86
|
+
self.appointment_time_markers = CrudApi(d, "/appointmentTimeMarker")
|
|
87
|
+
self.appointment_uris = CrudApi(d, "/appointmentUri")
|
|
88
|
+
self.assignments = CrudApi(d, "/assignment")
|
|
89
|
+
self.captions = CrudApi(d, "/caption")
|
|
90
|
+
self.categories = CrudApi(d, "/category")
|
|
91
|
+
self.containers = CrudApi(d, "/container")
|
|
92
|
+
self.filter_groups = CrudApi(d, "/filterGroup")
|
|
93
|
+
self.filter_values = CrudApi(d, "/filterValue")
|
|
94
|
+
self.jobs = CrudApi(d, "/job")
|
|
95
|
+
self.pins = CrudApi(d, "/pin")
|
|
96
|
+
self.resources = CrudApi(d, "/resource")
|
|
97
|
+
self.resource_calendars = CrudApi(d, "/resourceCalendar")
|
|
98
|
+
self.resource_certificates = CrudApi(d, "/resourceCertificate")
|
|
99
|
+
self.resource_filter_values = CrudApi(d, "/resourceFilterValue")
|
|
100
|
+
self.resource_gps_trackings = CrudApi(d, "/resourceGpsTracking")
|
|
101
|
+
self.resource_uris = CrudApi(d, "/resourceUri")
|
|
102
|
+
self.tasks = CrudApi(d, "/task")
|
|
103
|
+
self.task_containers = CrudApi(d, "/taskContainer")
|
|
104
|
+
self.task_filter_values = CrudApi(d, "/taskFilterValue")
|
|
105
|
+
self.task_locked = CrudApi(d, "/taskLocked")
|
|
106
|
+
self.task_uris = CrudApi(d, "/taskUri")
|
|
107
|
+
self.time_markers = CrudApi(d, "/timeMarker")
|
|
108
|
+
|
|
109
|
+
self.appointments = AppointmentApi(d)
|
|
110
|
+
self.appointment_dependencies = AppointmentDependencyApi(d)
|
|
111
|
+
self.appointment_fields = AppointmentFieldApi(d)
|
|
112
|
+
self.notifications = NotificationApi(d)
|
|
113
|
+
self.resource_capacities = ResourceCapacityApi(d)
|
|
114
|
+
self.resource_types = ResourceTypeApi(d)
|
|
115
|
+
self.calendars = CalendarApi(d)
|
|
116
|
+
|
|
117
|
+
self.connectors = ConnectorApi(d)
|
|
118
|
+
self.geocoding = GeocodeApi(d)
|
|
119
|
+
self.imports = ImportApi(d)
|
|
120
|
+
self.messages = MessageApi(d)
|
|
121
|
+
self.optimization = OptimizationApi(d)
|
|
122
|
+
self.recommendation = RecommendationApi(d)
|
|
123
|
+
self.recurring_appointments = RecurringAppointmentApi(d)
|
|
124
|
+
self.users = UserApi(d)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def base_url(self) -> str:
|
|
128
|
+
return str(self._http.base_url).rstrip("/")
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def environment(self) -> Environment:
|
|
132
|
+
return self._environment
|
|
133
|
+
|
|
134
|
+
def close(self) -> None:
|
|
135
|
+
self._http.close()
|
|
136
|
+
|
|
137
|
+
def __enter__(self) -> DimeSchedulerClient:
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __exit__(self, *exc: object) -> None:
|
|
141
|
+
self.close()
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Union
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from dimescheduler.result import Result
|
|
7
|
+
|
|
8
|
+
# Sentinel: distinguishes "caller didn't pass a timeout" (inherit the
|
|
9
|
+
# client-level value) from "caller explicitly passed None" (wait indefinitely).
|
|
10
|
+
# Exported so the API accessors can share it instead of defining their own.
|
|
11
|
+
UNSET: Any = object()
|
|
12
|
+
TimeoutT = Union[float, httpx.Timeout, None]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def timeout_kw(value: Any) -> dict[str, Any]:
|
|
16
|
+
"""Build a kwargs dict that forwards ``timeout`` only when the caller set it."""
|
|
17
|
+
return {} if value is UNSET else {"timeout": value}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Dispatcher:
|
|
21
|
+
"""Internal HTTP dispatcher used by every domain accessor.
|
|
22
|
+
|
|
23
|
+
Wraps an ``httpx.Client`` and converts each call into a :class:`Result`.
|
|
24
|
+
Never raises on non-2xx status — the caller inspects ``result.error`` or
|
|
25
|
+
``result.response.status_code`` and decides how to react.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, http: httpx.Client) -> None:
|
|
29
|
+
self._http = http
|
|
30
|
+
|
|
31
|
+
def request(
|
|
32
|
+
self,
|
|
33
|
+
method: str,
|
|
34
|
+
path: str,
|
|
35
|
+
*,
|
|
36
|
+
body: Any = None,
|
|
37
|
+
params: dict[str, Any] | None = None,
|
|
38
|
+
timeout: Any = UNSET,
|
|
39
|
+
) -> Result:
|
|
40
|
+
kwargs: dict[str, Any] = {}
|
|
41
|
+
if body is not None:
|
|
42
|
+
kwargs["json"] = body
|
|
43
|
+
if params is not None:
|
|
44
|
+
kwargs["params"] = _drop_none(params)
|
|
45
|
+
if timeout is not UNSET:
|
|
46
|
+
kwargs["timeout"] = timeout
|
|
47
|
+
|
|
48
|
+
response = self._http.request(method, path, **kwargs)
|
|
49
|
+
payload = _parse_json(response)
|
|
50
|
+
|
|
51
|
+
if response.is_success:
|
|
52
|
+
return Result(data=payload, error=None, response=response)
|
|
53
|
+
return Result(data=None, error=payload, response=response)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_json(response: httpx.Response) -> Any:
|
|
57
|
+
if not response.content:
|
|
58
|
+
return None
|
|
59
|
+
content_type = response.headers.get("content-type", "")
|
|
60
|
+
if "json" not in content_type:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return response.json()
|
|
64
|
+
except ValueError:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _drop_none(params: dict[str, Any]) -> dict[str, Any]:
|
|
69
|
+
return {k: v for k, v in params.items() if v is not None}
|
|
File without changes
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Typed exception hierarchy for Dime.Scheduler API failures.
|
|
2
|
+
|
|
3
|
+
The dispatcher does not raise these on its own — it surfaces failures via
|
|
4
|
+
:class:`~dimescheduler.result.Result`. Call :meth:`Result.raise_for_error` to
|
|
5
|
+
opt into idiomatic Python exception handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import email.utils
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DimeSchedulerError(Exception):
|
|
18
|
+
"""Base class for every typed error raised by the SDK.
|
|
19
|
+
|
|
20
|
+
Pattern-match on the concrete subclass (:class:`RateLimitError`,
|
|
21
|
+
:class:`NotFoundError`, …) to react to specific failure categories instead
|
|
22
|
+
of inspecting status codes.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
message: str,
|
|
28
|
+
*,
|
|
29
|
+
status_code: int,
|
|
30
|
+
body: Any = None,
|
|
31
|
+
response: httpx.Response | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
self.body = body
|
|
36
|
+
self.response = response
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ValidationError(DimeSchedulerError):
|
|
40
|
+
"""400 / 422 — request body or query params failed server-side validation."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AuthenticationError(DimeSchedulerError):
|
|
44
|
+
"""401 — credentials are missing, malformed, or revoked."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AuthorizationError(DimeSchedulerError):
|
|
48
|
+
"""403 — credentials are valid but lack permission for the resource."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NotFoundError(DimeSchedulerError):
|
|
52
|
+
"""404 — the requested resource does not exist."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ConflictError(DimeSchedulerError):
|
|
56
|
+
"""409 — the request conflicts with the current state of the resource."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RateLimitError(DimeSchedulerError):
|
|
60
|
+
"""429 — rate limit exceeded.
|
|
61
|
+
|
|
62
|
+
``retry_after`` mirrors the server's ``Retry-After`` header (in seconds)
|
|
63
|
+
when one was sent. Sleeping for that long before retrying is the
|
|
64
|
+
documented recovery path.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
message: str,
|
|
70
|
+
*,
|
|
71
|
+
status_code: int,
|
|
72
|
+
body: Any = None,
|
|
73
|
+
response: httpx.Response | None = None,
|
|
74
|
+
retry_after: float | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(message, status_code=status_code, body=body, response=response)
|
|
77
|
+
self.retry_after = retry_after
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ServerError(DimeSchedulerError):
|
|
81
|
+
"""5xx — server-side failure."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class NetworkError(DimeSchedulerError):
|
|
85
|
+
"""Transport-level failure (DNS, TCP, TLS, timeout, etc.). No HTTP response was received."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, message: str, *, cause: BaseException | None = None) -> None:
|
|
88
|
+
super().__init__(message, status_code=0, body=None, response=None)
|
|
89
|
+
self.__cause__ = cause
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def classify(response: httpx.Response, body: Any = None) -> DimeSchedulerError:
|
|
93
|
+
"""Build the most specific :class:`DimeSchedulerError` for ``response``.
|
|
94
|
+
|
|
95
|
+
``body`` is the parsed JSON payload (when present) — passed in by the
|
|
96
|
+
dispatcher so we don't double-parse.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
status = response.status_code
|
|
100
|
+
message = _message_from(body, default=f"HTTP {status}")
|
|
101
|
+
|
|
102
|
+
if status in (400, 422):
|
|
103
|
+
return ValidationError(message, status_code=status, body=body, response=response)
|
|
104
|
+
if status == 401:
|
|
105
|
+
return AuthenticationError(message, status_code=status, body=body, response=response)
|
|
106
|
+
if status == 403:
|
|
107
|
+
return AuthorizationError(message, status_code=status, body=body, response=response)
|
|
108
|
+
if status == 404:
|
|
109
|
+
return NotFoundError(message, status_code=status, body=body, response=response)
|
|
110
|
+
if status == 409:
|
|
111
|
+
return ConflictError(message, status_code=status, body=body, response=response)
|
|
112
|
+
if status == 429:
|
|
113
|
+
return RateLimitError(
|
|
114
|
+
message,
|
|
115
|
+
status_code=status,
|
|
116
|
+
body=body,
|
|
117
|
+
response=response,
|
|
118
|
+
retry_after=parse_retry_after(response.headers.get("retry-after")),
|
|
119
|
+
)
|
|
120
|
+
if 500 <= status < 600:
|
|
121
|
+
return ServerError(message, status_code=status, body=body, response=response)
|
|
122
|
+
return DimeSchedulerError(message, status_code=status, body=body, response=response)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def parse_retry_after(value: str | None) -> float | None:
|
|
126
|
+
"""Parse a ``Retry-After`` header value (seconds or HTTP-date) into seconds."""
|
|
127
|
+
|
|
128
|
+
if not value:
|
|
129
|
+
return None
|
|
130
|
+
value = value.strip()
|
|
131
|
+
try:
|
|
132
|
+
return max(0.0, float(value))
|
|
133
|
+
except ValueError:
|
|
134
|
+
pass
|
|
135
|
+
try:
|
|
136
|
+
target = email.utils.parsedate_to_datetime(value)
|
|
137
|
+
except (TypeError, ValueError):
|
|
138
|
+
return None
|
|
139
|
+
if target is None:
|
|
140
|
+
return None
|
|
141
|
+
if target.tzinfo is None:
|
|
142
|
+
target = target.replace(tzinfo=timezone.utc)
|
|
143
|
+
delta = (target - datetime.now(timezone.utc)).total_seconds()
|
|
144
|
+
return max(0.0, delta)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _message_from(body: Any, *, default: str) -> str:
|
|
148
|
+
if isinstance(body, dict):
|
|
149
|
+
for key in ("message", "error", "detail", "title"):
|
|
150
|
+
value = body.get(key)
|
|
151
|
+
if isinstance(value, str) and value:
|
|
152
|
+
return value
|
|
153
|
+
if isinstance(body, str) and body:
|
|
154
|
+
return body
|
|
155
|
+
return default
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from dimescheduler.errors import DimeSchedulerError, classify
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Result:
|
|
12
|
+
"""Outcome of a Dime.Scheduler API call.
|
|
13
|
+
|
|
14
|
+
Mirrors the openapi-fetch ``{ data, error, response }`` shape used by the
|
|
15
|
+
JS SDK and the ``Result<T>`` type used by the .NET SDK. ``data`` holds the
|
|
16
|
+
parsed JSON body on success, ``error`` holds the parsed JSON error body on
|
|
17
|
+
a 4xx/5xx, and ``response`` is the underlying ``httpx.Response`` for cases
|
|
18
|
+
where you need headers, status code, or the raw bytes.
|
|
19
|
+
|
|
20
|
+
Use :meth:`raise_for_error` if you'd rather opt into typed exceptions than
|
|
21
|
+
branch on :attr:`ok`.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
data: Any
|
|
25
|
+
error: Any
|
|
26
|
+
response: httpx.Response
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def ok(self) -> bool:
|
|
30
|
+
return self.response.is_success
|
|
31
|
+
|
|
32
|
+
def raise_for_error(self) -> None:
|
|
33
|
+
"""Raise a typed :class:`DimeSchedulerError` if the call failed.
|
|
34
|
+
|
|
35
|
+
Mirrors :meth:`httpx.Response.raise_for_status` but yields the right
|
|
36
|
+
:class:`~dimescheduler.errors.DimeSchedulerError` subclass
|
|
37
|
+
(:class:`~dimescheduler.errors.RateLimitError`,
|
|
38
|
+
:class:`~dimescheduler.errors.NotFoundError`, …) so callers can write
|
|
39
|
+
``except RateLimitError`` instead of inspecting status codes.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
if self.ok:
|
|
43
|
+
return
|
|
44
|
+
raise classify(self.response, self.error)
|
|
45
|
+
|
|
46
|
+
def to_error(self) -> DimeSchedulerError | None:
|
|
47
|
+
"""Return the typed error for a failed call, or ``None`` on success.
|
|
48
|
+
|
|
49
|
+
Useful when you want to inspect / branch on the error category without
|
|
50
|
+
actually raising it.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
if self.ok:
|
|
54
|
+
return None
|
|
55
|
+
return classify(self.response, self.error)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Retry policy shared by every Dime.Scheduler request.
|
|
2
|
+
|
|
3
|
+
The wrapped transport retries idempotent transport-level failures and a small
|
|
4
|
+
set of "the server is having a bad time" status codes. Defaults match the JS
|
|
5
|
+
and .NET SDKs so behaviour is identical regardless of runtime.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import email.utils
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Callable
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
# Status codes that trigger a retry by default.
|
|
20
|
+
DEFAULT_RETRY_STATUS_CODES: tuple[int, ...] = (408, 429, 500, 502, 503, 504)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RetryConfig:
|
|
25
|
+
"""Retry policy.
|
|
26
|
+
|
|
27
|
+
Set ``max_retries=0`` to effectively disable retrying without removing the
|
|
28
|
+
transport wrapper.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
max_retries: int = 3
|
|
32
|
+
initial_delay_s: float = 0.5
|
|
33
|
+
max_delay_s: float = 30.0
|
|
34
|
+
backoff_multiplier: float = 2.0
|
|
35
|
+
retry_status_codes: tuple[int, ...] = field(default_factory=lambda: DEFAULT_RETRY_STATUS_CODES)
|
|
36
|
+
respect_retry_after: bool = True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RetryTransport(httpx.BaseTransport):
|
|
40
|
+
"""httpx transport that wraps an inner transport with retry behaviour.
|
|
41
|
+
|
|
42
|
+
Retries on:
|
|
43
|
+
- ``ConnectError``, ``ReadError``, ``WriteError``, ``ConnectTimeout``,
|
|
44
|
+
``ReadTimeout``, ``WriteTimeout``, ``PoolTimeout`` (transient I/O).
|
|
45
|
+
- HTTP responses with status codes in ``config.retry_status_codes``.
|
|
46
|
+
|
|
47
|
+
Honours ``Retry-After`` (delta-seconds or HTTP-date) on retryable responses
|
|
48
|
+
when ``config.respect_retry_after`` is true. The ``Retry-After`` value is
|
|
49
|
+
capped by ``config.max_delay_s``.
|
|
50
|
+
|
|
51
|
+
Backoff is "full jitter": ``random() * min(initial * multiplier**n, cap)``.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
_RETRYABLE_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
55
|
+
httpx.ConnectError,
|
|
56
|
+
httpx.ReadError,
|
|
57
|
+
httpx.WriteError,
|
|
58
|
+
httpx.ConnectTimeout,
|
|
59
|
+
httpx.ReadTimeout,
|
|
60
|
+
httpx.WriteTimeout,
|
|
61
|
+
httpx.PoolTimeout,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
config: RetryConfig | None = None,
|
|
67
|
+
inner: httpx.BaseTransport | None = None,
|
|
68
|
+
*,
|
|
69
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
70
|
+
rng: Callable[[], float] = random.random,
|
|
71
|
+
now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
|
72
|
+
) -> None:
|
|
73
|
+
self._config = config or RetryConfig()
|
|
74
|
+
self._inner = inner or httpx.HTTPTransport()
|
|
75
|
+
self._sleep = sleep
|
|
76
|
+
self._rng = rng
|
|
77
|
+
self._now = now
|
|
78
|
+
|
|
79
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
80
|
+
cfg = self._config
|
|
81
|
+
last_exc: BaseException | None = None
|
|
82
|
+
last_response: httpx.Response | None = None
|
|
83
|
+
|
|
84
|
+
for attempt in range(cfg.max_retries + 1):
|
|
85
|
+
try:
|
|
86
|
+
response = self._inner.handle_request(request)
|
|
87
|
+
except self._RETRYABLE_EXCEPTIONS as exc:
|
|
88
|
+
last_exc = exc
|
|
89
|
+
if attempt == cfg.max_retries:
|
|
90
|
+
raise
|
|
91
|
+
self._sleep(self._compute_backoff(attempt))
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
if attempt == cfg.max_retries or response.status_code not in cfg.retry_status_codes:
|
|
95
|
+
return response
|
|
96
|
+
|
|
97
|
+
# Drain so the connection can be reused.
|
|
98
|
+
response.read()
|
|
99
|
+
response.close()
|
|
100
|
+
last_response = response
|
|
101
|
+
|
|
102
|
+
delay = self._delay_for(response, attempt)
|
|
103
|
+
self._sleep(delay)
|
|
104
|
+
|
|
105
|
+
# Loop body always returns or raises on the final attempt, so this is
|
|
106
|
+
# unreachable. Belt-and-braces guard for static analysers.
|
|
107
|
+
if last_response is not None:
|
|
108
|
+
return last_response
|
|
109
|
+
if last_exc is not None:
|
|
110
|
+
raise last_exc
|
|
111
|
+
raise RuntimeError("retry loop exited without a result")
|
|
112
|
+
|
|
113
|
+
def close(self) -> None:
|
|
114
|
+
self._inner.close()
|
|
115
|
+
|
|
116
|
+
def _delay_for(self, response: httpx.Response, attempt: int) -> float:
|
|
117
|
+
if self._config.respect_retry_after:
|
|
118
|
+
ra = response.headers.get("retry-after")
|
|
119
|
+
parsed = self._parse_retry_after(ra) if ra else None
|
|
120
|
+
if parsed is not None:
|
|
121
|
+
return min(parsed, self._config.max_delay_s)
|
|
122
|
+
return self._compute_backoff(attempt)
|
|
123
|
+
|
|
124
|
+
def _compute_backoff(self, attempt: int) -> float:
|
|
125
|
+
cfg = self._config
|
|
126
|
+
exponential = cfg.initial_delay_s * (cfg.backoff_multiplier ** attempt)
|
|
127
|
+
capped = min(exponential, cfg.max_delay_s)
|
|
128
|
+
# Full jitter — random in [0, capped].
|
|
129
|
+
return self._rng() * capped
|
|
130
|
+
|
|
131
|
+
def _parse_retry_after(self, value: str) -> float | None:
|
|
132
|
+
value = value.strip()
|
|
133
|
+
if not value:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
seconds = float(value)
|
|
137
|
+
return max(0.0, seconds)
|
|
138
|
+
except ValueError:
|
|
139
|
+
pass
|
|
140
|
+
try:
|
|
141
|
+
target = email.utils.parsedate_to_datetime(value)
|
|
142
|
+
except (TypeError, ValueError):
|
|
143
|
+
return None
|
|
144
|
+
if target is None:
|
|
145
|
+
return None
|
|
146
|
+
if target.tzinfo is None:
|
|
147
|
+
target = target.replace(tzinfo=timezone.utc)
|
|
148
|
+
delta = (target - self._now()).total_seconds()
|
|
149
|
+
return max(0.0, delta)
|