isdayoff-api 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
isdayoff/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ __version__ = "1.0.0"
2
+
3
+ from .isdayoff import ProdCalendar, SyncProdCalendar
4
+ from .typingapi import DateType
5
+
6
+ __all__ = ["ProdCalendar", "SyncProdCalendar", "DateType", "__version__"]
isdayoff/isdayoff.py ADDED
@@ -0,0 +1,319 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from .typingapi import (
9
+ DataError,
10
+ DateType,
11
+ ProdCalendarParams,
12
+ ServiceNotRespond,
13
+ )
14
+
15
+ _LOCALES = ("ru", "kz", "by", "us", "uz", "tr", "lv")
16
+ _DELIMITER = "%7C"
17
+ _FORMAT_DATE = "%Y%m%d"
18
+
19
+
20
+ # ── shared helpers ───────────────────────────────────────────────────────────
21
+
22
+
23
+ def _validate_locale(locale: str) -> str:
24
+ if locale not in _LOCALES:
25
+ msg = f"locale must be one of {_LOCALES}, got {locale!r}"
26
+ raise ValueError(msg)
27
+ return locale
28
+
29
+
30
+ def _format_result(
31
+ date_format: str, date: datetime.date, result: list[str]
32
+ ) -> dict[str, DateType]:
33
+ return {
34
+ (date + datetime.timedelta(days=day)).strftime(date_format): DateType(int(value))
35
+ for day, value in enumerate(result)
36
+ }
37
+
38
+
39
+ def _build_params(locale: str, **kwargs: Any) -> dict[str, Any]:
40
+ params = ProdCalendarParams(**kwargs)
41
+ api_params = params.to_api_params()
42
+ api_params.setdefault("cc", locale)
43
+ return api_params
44
+
45
+
46
+ # ── async client ─────────────────────────────────────────────────────────────
47
+
48
+
49
+ class ProdCalendar:
50
+ """Async production calendar client using httpx.AsyncClient."""
51
+
52
+ __version__ = "1.0.0"
53
+
54
+ def __init__(
55
+ self,
56
+ locale: str = "ru",
57
+ base_url: str = "https://isdayoff.ru",
58
+ date_format: str = "%Y-%m-%d",
59
+ ) -> None:
60
+ self.date_format = date_format
61
+ self.locale = _validate_locale(locale)
62
+ self.base_url = base_url.rstrip("/")
63
+ self._client: httpx.AsyncClient | None = None
64
+
65
+ async def _get_client(self) -> httpx.AsyncClient:
66
+ if self._client is None or self._client.is_closed:
67
+ self._client = httpx.AsyncClient(
68
+ headers={
69
+ "User-Agent": (
70
+ f"isdayoff/{self.__version__} "
71
+ "Contact: wg7831@gmail.com"
72
+ )
73
+ },
74
+ )
75
+ return self._client
76
+
77
+ async def _get(
78
+ self, url: str, params: dict[str, Any] | None = None
79
+ ) -> str:
80
+ client = await self._get_client()
81
+ response = await client.get(self.base_url + url, params=params)
82
+ if response.status_code == 400:
83
+ raise DataError("Date error")
84
+ if response.status_code != 200:
85
+ raise ServiceNotRespond("No data found")
86
+ return response.text
87
+
88
+ async def _get_date_work(
89
+ self,
90
+ data: datetime.date,
91
+ is_day: bool = True,
92
+ is_month: bool = True,
93
+ **kwargs: Any,
94
+ ) -> str:
95
+ params = _build_params(self.locale, **kwargs)
96
+ params["year"] = data.year
97
+ if is_month:
98
+ params["month"] = data.month
99
+ if is_day:
100
+ params["day"] = data.day
101
+ if not (is_month and is_day):
102
+ params["delimeter"] = _DELIMITER
103
+ return await self._get("/api/getdata", params=params)
104
+
105
+ async def _get_range_date_work(
106
+ self,
107
+ start_date: datetime.date,
108
+ end_date: datetime.date,
109
+ **kwargs: Any,
110
+ ) -> str:
111
+ params = _build_params(self.locale, **kwargs)
112
+ params["date1"] = start_date.strftime(_FORMAT_DATE)
113
+ params["date2"] = end_date.strftime(_FORMAT_DATE)
114
+ params["delimeter"] = _DELIMITER
115
+ return await self._get("/api/getdata", params=params)
116
+
117
+ async def _get_date_as_type(
118
+ self, date: datetime.date, **kwargs: Any
119
+ ) -> DateType:
120
+ raw = await self._get_date_work(date, **kwargs)
121
+ return DateType(int(raw))
122
+
123
+ async def range_date(
124
+ self,
125
+ start_date: datetime.date,
126
+ end_date: datetime.date,
127
+ **kwargs: Any,
128
+ ) -> dict[str, DateType]:
129
+ result = (await self._get_range_date_work(start_date, end_date, **kwargs)).split(
130
+ _DELIMITER,
131
+ )
132
+ return _format_result(self.date_format, start_date, result)
133
+
134
+ async def month(
135
+ self, date: datetime.date, **kwargs: Any
136
+ ) -> dict[str, DateType]:
137
+ result = (
138
+ await self._get_date_work(date, is_day=False, **kwargs)
139
+ ).split(_DELIMITER)
140
+ return _format_result(
141
+ self.date_format, datetime.date(date.year, date.month, 1), result,
142
+ )
143
+
144
+ async def year(
145
+ self, date: datetime.date, **kwargs: Any
146
+ ) -> dict[str, DateType]:
147
+ result = (
148
+ await self._get_date_work(date, is_day=False, is_month=False, **kwargs)
149
+ ).split(_DELIMITER)
150
+ return _format_result(self.date_format, datetime.date(date.year, 1, 1), result)
151
+
152
+ async def date(self, date: datetime.date, **kwargs: Any) -> DateType:
153
+ return await self._get_date_as_type(date, **kwargs)
154
+
155
+ async def tomorrow(self, **kwargs: Any) -> DateType:
156
+ return await self._get_date_as_type(
157
+ datetime.date.today() + datetime.timedelta(days=1),
158
+ **kwargs,
159
+ )
160
+
161
+ async def today(self, **kwargs: Any) -> DateType:
162
+ return await self._get_date_as_type(datetime.date.today(), **kwargs)
163
+
164
+ @staticmethod
165
+ def is_leap(date: datetime.date) -> bool:
166
+ return date.year % 4 == 0 and date.year % 100 != 0 or date.year % 400 == 0
167
+
168
+ async def close(self) -> None:
169
+ if self._client is not None and not self._client.is_closed:
170
+ await self._client.aclose()
171
+
172
+ async def __aenter__(self) -> ProdCalendar:
173
+ await self._get_client()
174
+ return self
175
+
176
+ async def __aexit__(
177
+ self,
178
+ exc_type: type[BaseException] | None,
179
+ exc_val: BaseException | None,
180
+ exc_tb: Any,
181
+ ) -> None:
182
+ await self.close()
183
+
184
+
185
+ # ── sync client ──────────────────────────────────────────────────────────────
186
+
187
+
188
+ class SyncProdCalendar:
189
+ """Sync production calendar client using httpx.Client."""
190
+
191
+ __version__ = "1.0.0"
192
+
193
+ def __init__(
194
+ self,
195
+ locale: str = "ru",
196
+ base_url: str = "https://isdayoff.ru",
197
+ date_format: str = "%Y-%m-%d",
198
+ ) -> None:
199
+ self.date_format = date_format
200
+ self.locale = _validate_locale(locale)
201
+ self.base_url = base_url.rstrip("/")
202
+ self._client: httpx.Client | None = None
203
+
204
+ def _get_client(self) -> httpx.Client:
205
+ if self._client is None or self._client.is_closed:
206
+ self._client = httpx.Client(
207
+ headers={
208
+ "User-Agent": (
209
+ f"isdayoff/{self.__version__} "
210
+ "Contact: wg7831@gmail.com"
211
+ )
212
+ },
213
+ )
214
+ return self._client
215
+
216
+ def _get(
217
+ self, url: str, params: dict[str, Any] | None = None
218
+ ) -> str:
219
+ client = self._get_client()
220
+ response = client.get(self.base_url + url, params=params)
221
+ if response.status_code == 400:
222
+ raise DataError("Date error")
223
+ if response.status_code != 200:
224
+ raise ServiceNotRespond("No data found")
225
+ return response.text
226
+
227
+ def _get_date_work(
228
+ self,
229
+ data: datetime.date,
230
+ is_day: bool = True,
231
+ is_month: bool = True,
232
+ **kwargs: Any,
233
+ ) -> str:
234
+ params = _build_params(self.locale, **kwargs)
235
+ params["year"] = data.year
236
+ if is_month:
237
+ params["month"] = data.month
238
+ if is_day:
239
+ params["day"] = data.day
240
+ if not (is_month and is_day):
241
+ params["delimeter"] = _DELIMITER
242
+ return self._get("/api/getdata", params=params)
243
+
244
+ def _get_range_date_work(
245
+ self,
246
+ start_date: datetime.date,
247
+ end_date: datetime.date,
248
+ **kwargs: Any,
249
+ ) -> str:
250
+ params = _build_params(self.locale, **kwargs)
251
+ params["date1"] = start_date.strftime(_FORMAT_DATE)
252
+ params["date2"] = end_date.strftime(_FORMAT_DATE)
253
+ params["delimeter"] = _DELIMITER
254
+ return self._get("/api/getdata", params=params)
255
+
256
+ def _get_date_as_type(
257
+ self, date: datetime.date, **kwargs: Any
258
+ ) -> DateType:
259
+ raw = self._get_date_work(date, **kwargs)
260
+ return DateType(int(raw))
261
+
262
+ def range_date(
263
+ self,
264
+ start_date: datetime.date,
265
+ end_date: datetime.date,
266
+ **kwargs: Any,
267
+ ) -> dict[str, DateType]:
268
+ result = self._get_range_date_work(start_date, end_date, **kwargs).split(
269
+ _DELIMITER,
270
+ )
271
+ return _format_result(self.date_format, start_date, result)
272
+
273
+ def month(
274
+ self, date: datetime.date, **kwargs: Any
275
+ ) -> dict[str, DateType]:
276
+ result = self._get_date_work(date, is_day=False, **kwargs).split(_DELIMITER)
277
+ return _format_result(
278
+ self.date_format, datetime.date(date.year, date.month, 1), result,
279
+ )
280
+
281
+ def year(
282
+ self, date: datetime.date, **kwargs: Any
283
+ ) -> dict[str, DateType]:
284
+ result = self._get_date_work(
285
+ date, is_day=False, is_month=False, **kwargs
286
+ ).split(_DELIMITER)
287
+ return _format_result(self.date_format, datetime.date(date.year, 1, 1), result)
288
+
289
+ def date(self, date: datetime.date, **kwargs: Any) -> DateType:
290
+ return self._get_date_as_type(date, **kwargs)
291
+
292
+ def tomorrow(self, **kwargs: Any) -> DateType:
293
+ return self._get_date_as_type(
294
+ datetime.date.today() + datetime.timedelta(days=1),
295
+ **kwargs,
296
+ )
297
+
298
+ def today(self, **kwargs: Any) -> DateType:
299
+ return self._get_date_as_type(datetime.date.today(), **kwargs)
300
+
301
+ @staticmethod
302
+ def is_leap(date: datetime.date) -> bool:
303
+ return date.year % 4 == 0 and date.year % 100 != 0 or date.year % 400 == 0
304
+
305
+ def close(self) -> None:
306
+ if self._client is not None and not self._client.is_closed:
307
+ self._client.close()
308
+
309
+ def __enter__(self) -> SyncProdCalendar:
310
+ self._get_client()
311
+ return self
312
+
313
+ def __exit__(
314
+ self,
315
+ exc_type: type[BaseException] | None,
316
+ exc_val: BaseException | None,
317
+ exc_tb: Any,
318
+ ) -> None:
319
+ self.close()
isdayoff/typingapi.py ADDED
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import IntEnum
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, field_validator
7
+
8
+
9
+ class ServiceNotRespond(Exception):
10
+ """The API service did not respond or returned an unexpected status."""
11
+
12
+
13
+ class DataError(Exception):
14
+ """Invalid date data passed to the API (400 Bad Request)."""
15
+
16
+
17
+ class DateType(IntEnum):
18
+ WORKING = 0
19
+ NOT_WORKING = 1
20
+ SHORTENED = 2
21
+ WORKING_DAY = 4
22
+
23
+
24
+ _LOCALES = ("ru", "kz", "by", "us", "uz", "tr", "lv")
25
+
26
+
27
+ class ProdCalendarParams(BaseModel):
28
+ """Validated parameters for ProdCalendar API methods."""
29
+
30
+ locale: str | None = None
31
+ pre: bool = False
32
+ sd: bool = False
33
+ covid: bool = False
34
+
35
+ @field_validator("locale")
36
+ @classmethod
37
+ def _validate_locale(cls, v: str | None) -> str | None:
38
+ if v is not None and v not in _LOCALES:
39
+ msg = f"locale must be one of {_LOCALES}, got {v!r}"
40
+ raise ValueError(msg)
41
+ return v
42
+
43
+ def to_api_params(self) -> dict[str, Any]:
44
+ """Convert to API query parameters, omitting falsy bools."""
45
+ params: dict[str, Any] = {}
46
+ if self.locale is not None:
47
+ params["cc"] = self.locale
48
+ if self.pre:
49
+ params["pre"] = 1
50
+ if self.sd:
51
+ params["sd"] = 1
52
+ if self.covid:
53
+ params["covid"] = 1
54
+ return params
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: isdayoff-api
3
+ Version: 1.0.0
4
+ Summary: Checking the date for belonging to a non-working day, according to official decrees and orders.
5
+ Project-URL: Homepage, https://github.com/alexbevz/isdayoff
6
+ Project-URL: Source, https://github.com/alexbevz/isdayoff
7
+ Author-email: Aleksandr Bevz <as-bivz@yandex.ru>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.28
20
+ Requires-Dist: pydantic>=2
21
+ Description-Content-Type: text/markdown
22
+
23
+ # isdayoff
24
+
25
+ Production Calendar API
26
+
27
+ Description:
28
+ * Checking the date for belonging to a non-working day, according to official decrees and orders.
29
+
30
+ Official API website — https://isdayoff.ru
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install isdayoff-api
36
+ ```
37
+
38
+ Requires Python 3.11+.
39
+
40
+ ## Supported locales
41
+
42
+ | Code | Country |
43
+ |------|---------|
44
+ | `ru` | Russia |
45
+ | `kz` | Kazakhstan |
46
+ | `by` | Belarus |
47
+ | `us` | USA |
48
+ | `uz` | Uzbekistan |
49
+ | `tr` | Turkey |
50
+ | `lv` | Latvia |
51
+
52
+ ## Quick start
53
+
54
+ ### Async (recommended)
55
+
56
+ ```python
57
+ import asyncio
58
+ from datetime import date
59
+
60
+ from isdayoff import DateType, ProdCalendar
61
+
62
+
63
+ async def main():
64
+ async with ProdCalendar(locale="us") as calendar:
65
+ if await calendar.today() == DateType.WORKING:
66
+ print("Today is a working day")
67
+ else:
68
+ print("Today is a day off")
69
+
70
+
71
+ asyncio.run(main())
72
+ ```
73
+
74
+ ### Sync
75
+
76
+ ```python
77
+ from datetime import date
78
+
79
+ from isdayoff import DateType, SyncProdCalendar
80
+
81
+
82
+ with SyncProdCalendar(locale="us") as calendar:
83
+ if calendar.today() == DateType.WORKING:
84
+ print("Today is a working day")
85
+ else:
86
+ print("Today is a day off")
87
+ ```
88
+
89
+ ## API
90
+
91
+ All methods are available on both `ProdCalendar` (async) and `SyncProdCalendar` (sync).
92
+
93
+ ### Parameters
94
+
95
+ | Parameter | Type | Default | Description |
96
+ |---|---|---|---|
97
+ | `locale` | `str` | `"ru"` | Country code (see table above) |
98
+ | `pre` | `bool` | `False` | Mark shortened working days |
99
+ | `covid` | `bool` | `False` | Mark working days due to COVID-19 |
100
+ | `sd` | `bool` | `False` | Consider 6-day work week |
101
+
102
+ ### Methods
103
+
104
+ ```python
105
+ # Async
106
+ await calendar.today(locale="ru", pre=True, covid=True, sd=True)
107
+ await calendar.tomorrow()
108
+ await calendar.date(date(2024, 8, 25))
109
+ await calendar.month(date(2024, 8, 1))
110
+ await calendar.year(date(2024, 1, 1))
111
+ await calendar.range_date(date(2024, 1, 1), date(2024, 5, 1))
112
+ calendar.is_leap(date(2024, 1, 1))
113
+
114
+ # Sync
115
+ calendar.today(locale="ru", pre=True, covid=True, sd=True)
116
+ calendar.tomorrow()
117
+ calendar.date(date(2024, 8, 25))
118
+ calendar.month(date(2024, 8, 1))
119
+ calendar.year(date(2024, 1, 1))
120
+ calendar.range_date(date(2024, 1, 1), date(2024, 5, 1))
121
+ calendar.is_leap(date(2024, 1, 1))
122
+ ```
123
+
124
+ ### Return types
125
+
126
+ | Method | Returns |
127
+ |---|---|
128
+ | `today()` / `tomorrow()` / `date()` | `DateType` enum |
129
+ | `month()` / `year()` / `range_date()` | `dict[str, DateType]` — ISO date → type |
130
+ | `is_leap()` | `bool` |
131
+
132
+ ### DateType values
133
+
134
+ | Value | Meaning |
135
+ |---|---|
136
+ | `DateType.WORKING` (0) | Working day |
137
+ | `DateType.NOT_WORKING` (1) | Day off / holiday |
138
+ | `DateType.SHORTENED` (2) | Shortened pre-holiday day |
139
+ | `DateType.WORKING_DAY` (4) | Working day (special period) |
140
+
141
+ ## Full example
142
+
143
+ ```python
144
+ import asyncio
145
+ from datetime import date
146
+
147
+ from isdayoff import DateType, ProdCalendar
148
+
149
+
150
+ async def main():
151
+ async with ProdCalendar(locale="us") as calendar:
152
+ res = await calendar.month(date(2024, 8, 1), locale="ru")
153
+ days_off = sum(
154
+ 1 for v in res.values() if v == DateType.NOT_WORKING
155
+ )
156
+ print(f"Days off in August 2024: {days_off}")
157
+
158
+
159
+ asyncio.run(main())
160
+ ```
161
+
162
+ ## Development
163
+
164
+ ```bash
165
+ # Install dependencies
166
+ uv sync
167
+
168
+ # Run tests
169
+ uv run pytest
170
+
171
+ # Build
172
+ uv build
173
+ ```
@@ -0,0 +1,7 @@
1
+ isdayoff/__init__.py,sha256=xP969JjlZSrveTACJGRMImZjWson1yz37gsVYaFbqhs,189
2
+ isdayoff/isdayoff.py,sha256=CrKoRgxrMclMNOFRjIG04_kF0vn6qTiyBHcfhkK349k,10670
3
+ isdayoff/typingapi.py,sha256=1oB_hz0yu3iKzI4xOtlrdNyms5VF2kMQP5UstQjEwf8,1429
4
+ isdayoff_api-1.0.0.dist-info/METADATA,sha256=yDQZyyZnK7fjNweTneNw4mHzGJqFlQxF24GiHPUUJt4,4081
5
+ isdayoff_api-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
6
+ isdayoff_api-1.0.0.dist-info/licenses/LICENSE,sha256=MT7HsanLpO6HfbA4_2EyVpRPW275P9_UCyWPKdrptEU,1113
7
+ isdayoff_api-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021 Максим Кобылинский
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.