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.
@@ -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,3 @@
1
+ # Kept in sync with the [project] version field in pyproject.toml by the manual
2
+ # release workflow (.github/workflows/release.yml). Do not edit by hand.
3
+ __version__ = "0.1.2b0"
@@ -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,7 @@
1
+ from enum import Enum
2
+
3
+
4
+ class Environment(str, Enum):
5
+ Test = "https://test.api.dimescheduler.com"
6
+ Sandbox = "https://sandbox.api.dimescheduler.com"
7
+ Production = "https://api.dimescheduler.com"
@@ -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)