isdayoff-api 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ .qwen
2
+
3
+ # Byte-compiled / optimized / DLL files
4
+ __pycache__/
5
+ *.py[cod]
6
+ *$py.class
7
+
8
+ # C extensions
9
+ *.so
10
+
11
+ # Distribution / packaging
12
+ .Python
13
+ build/
14
+ develop-eggs/
15
+ dist/
16
+ downloads/
17
+ eggs/
18
+ .eggs/
19
+ lib/
20
+ lib64/
21
+ parts/
22
+ sdist/
23
+ var/
24
+ wheels/
25
+ pip-wheel-metadata/
26
+ share/python-wheels/
27
+ *.egg-info/
28
+ .installed.cfg
29
+ *.egg
30
+ MANIFEST
31
+
32
+ # PyInstaller
33
+ # Usually these files are written by a python script from a template
34
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
35
+ *.manifest
36
+ *.spec
37
+
38
+ # Installer logs
39
+ pip-log.txt
40
+ pip-delete-this-directory.txt
41
+
42
+ # Unit test / coverage reports
43
+ htmlcov/
44
+ .tox/
45
+ .nox/
46
+ .coverage
47
+ .coverage.*
48
+ .cache
49
+ nosetests.xml
50
+ coverage.xml
51
+ *.cover
52
+ *.py,cover
53
+ .hypothesis/
54
+ .pytest_cache/
55
+
56
+ # Translations
57
+ *.mo
58
+ *.pot
59
+
60
+ # Django stuff:
61
+ *.log
62
+ local_settings.py
63
+ db.sqlite3
64
+ db.sqlite3-journal
65
+
66
+ # Flask stuff:
67
+ instance/
68
+ .webassets-cache
69
+
70
+ # Scrapy stuff:
71
+ .scrapy
72
+
73
+ # Sphinx documentation
74
+ docs/_build/
75
+
76
+ # PyBuilder
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ .python-version
88
+
89
+ # pipenv
90
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
91
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
92
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
93
+ # install all needed dependencies.
94
+ #Pipfile.lock
95
+
96
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
97
+ __pypackages__/
98
+
99
+ # Celery stuff
100
+ celerybeat-schedule
101
+ celerybeat.pid
102
+
103
+ # SageMath parsed files
104
+ *.sage.py
105
+
106
+ # Environments
107
+ .env
108
+ .venv
109
+ env/
110
+ venv/
111
+ ENV/
112
+ env.bak/
113
+ venv.bak/
114
+
115
+ # Spyder project settings
116
+ .spyderproject
117
+ .spyproject
118
+
119
+ # Rope project settings
120
+ .ropeproject
121
+
122
+ # mkdocs documentation
123
+ /site
124
+
125
+ # mypy
126
+ .mypy_cache/
127
+ .dmypy.json
128
+ dmypy.json
129
+
130
+ # Pyre type checker
131
+ .pyre/
@@ -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.
@@ -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,151 @@
1
+ # isdayoff
2
+
3
+ Production Calendar API
4
+
5
+ Description:
6
+ * Checking the date for belonging to a non-working day, according to official decrees and orders.
7
+
8
+ Official API website — https://isdayoff.ru
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install isdayoff-api
14
+ ```
15
+
16
+ Requires Python 3.11+.
17
+
18
+ ## Supported locales
19
+
20
+ | Code | Country |
21
+ |------|---------|
22
+ | `ru` | Russia |
23
+ | `kz` | Kazakhstan |
24
+ | `by` | Belarus |
25
+ | `us` | USA |
26
+ | `uz` | Uzbekistan |
27
+ | `tr` | Turkey |
28
+ | `lv` | Latvia |
29
+
30
+ ## Quick start
31
+
32
+ ### Async (recommended)
33
+
34
+ ```python
35
+ import asyncio
36
+ from datetime import date
37
+
38
+ from isdayoff import DateType, ProdCalendar
39
+
40
+
41
+ async def main():
42
+ async with ProdCalendar(locale="us") as calendar:
43
+ if await calendar.today() == DateType.WORKING:
44
+ print("Today is a working day")
45
+ else:
46
+ print("Today is a day off")
47
+
48
+
49
+ asyncio.run(main())
50
+ ```
51
+
52
+ ### Sync
53
+
54
+ ```python
55
+ from datetime import date
56
+
57
+ from isdayoff import DateType, SyncProdCalendar
58
+
59
+
60
+ with SyncProdCalendar(locale="us") as calendar:
61
+ if calendar.today() == DateType.WORKING:
62
+ print("Today is a working day")
63
+ else:
64
+ print("Today is a day off")
65
+ ```
66
+
67
+ ## API
68
+
69
+ All methods are available on both `ProdCalendar` (async) and `SyncProdCalendar` (sync).
70
+
71
+ ### Parameters
72
+
73
+ | Parameter | Type | Default | Description |
74
+ |---|---|---|---|
75
+ | `locale` | `str` | `"ru"` | Country code (see table above) |
76
+ | `pre` | `bool` | `False` | Mark shortened working days |
77
+ | `covid` | `bool` | `False` | Mark working days due to COVID-19 |
78
+ | `sd` | `bool` | `False` | Consider 6-day work week |
79
+
80
+ ### Methods
81
+
82
+ ```python
83
+ # Async
84
+ await calendar.today(locale="ru", pre=True, covid=True, sd=True)
85
+ await calendar.tomorrow()
86
+ await calendar.date(date(2024, 8, 25))
87
+ await calendar.month(date(2024, 8, 1))
88
+ await calendar.year(date(2024, 1, 1))
89
+ await calendar.range_date(date(2024, 1, 1), date(2024, 5, 1))
90
+ calendar.is_leap(date(2024, 1, 1))
91
+
92
+ # Sync
93
+ calendar.today(locale="ru", pre=True, covid=True, sd=True)
94
+ calendar.tomorrow()
95
+ calendar.date(date(2024, 8, 25))
96
+ calendar.month(date(2024, 8, 1))
97
+ calendar.year(date(2024, 1, 1))
98
+ calendar.range_date(date(2024, 1, 1), date(2024, 5, 1))
99
+ calendar.is_leap(date(2024, 1, 1))
100
+ ```
101
+
102
+ ### Return types
103
+
104
+ | Method | Returns |
105
+ |---|---|
106
+ | `today()` / `tomorrow()` / `date()` | `DateType` enum |
107
+ | `month()` / `year()` / `range_date()` | `dict[str, DateType]` — ISO date → type |
108
+ | `is_leap()` | `bool` |
109
+
110
+ ### DateType values
111
+
112
+ | Value | Meaning |
113
+ |---|---|
114
+ | `DateType.WORKING` (0) | Working day |
115
+ | `DateType.NOT_WORKING` (1) | Day off / holiday |
116
+ | `DateType.SHORTENED` (2) | Shortened pre-holiday day |
117
+ | `DateType.WORKING_DAY` (4) | Working day (special period) |
118
+
119
+ ## Full example
120
+
121
+ ```python
122
+ import asyncio
123
+ from datetime import date
124
+
125
+ from isdayoff import DateType, ProdCalendar
126
+
127
+
128
+ async def main():
129
+ async with ProdCalendar(locale="us") as calendar:
130
+ res = await calendar.month(date(2024, 8, 1), locale="ru")
131
+ days_off = sum(
132
+ 1 for v in res.values() if v == DateType.NOT_WORKING
133
+ )
134
+ print(f"Days off in August 2024: {days_off}")
135
+
136
+
137
+ asyncio.run(main())
138
+ ```
139
+
140
+ ## Development
141
+
142
+ ```bash
143
+ # Install dependencies
144
+ uv sync
145
+
146
+ # Run tests
147
+ uv run pytest
148
+
149
+ # Build
150
+ uv build
151
+ ```
@@ -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__"]
@@ -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()
@@ -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,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "isdayoff-api"
7
+ description = "Checking the date for belonging to a non-working day, according to official decrees and orders."
8
+ readme = "README.md"
9
+ license = { text = "MIT" }
10
+ requires-python = ">=3.11"
11
+ version = "1.0.0"
12
+ authors = [
13
+ { name = "Aleksandr Bevz", email = "as-bivz@yandex.ru" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 5 - Production/Stable",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Libraries",
24
+ ]
25
+ dependencies = [
26
+ "httpx >= 0.28",
27
+ "pydantic >= 2",
28
+ ]
29
+ urls = { Homepage = "https://github.com/alexbevz/isdayoff", Source = "https://github.com/alexbevz/isdayoff" }
30
+
31
+ [tool.hatch.build.targets.sdist]
32
+ include = ["/isdayoff", "/tests"]
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["isdayoff"]
36
+
37
+ [dependency-groups]
38
+ dev = [
39
+ "pytest >= 8",
40
+ "pytest-asyncio >= 0.24",
41
+ ]
42
+
43
+ [tool.pytest.ini_options]
44
+ asyncio_mode = "auto"
45
+ testpaths = ["tests"]
File without changes
@@ -0,0 +1,327 @@
1
+ from __future__ import annotations
2
+
3
+ import datetime
4
+ from unittest.mock import ANY, AsyncMock, patch
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from isdayoff import DateType, ProdCalendar, SyncProdCalendar
10
+ from isdayoff.typingapi import DataError, ServiceNotRespond
11
+
12
+
13
+ # ── fixtures ─────────────────────────────────────────────────────────────────
14
+
15
+
16
+ @pytest.fixture
17
+ def calendar() -> ProdCalendar:
18
+ return ProdCalendar(locale="ru")
19
+
20
+
21
+ @pytest.fixture
22
+ def sync_calendar() -> SyncProdCalendar:
23
+ return SyncProdCalendar(locale="ru")
24
+
25
+
26
+ # ── helper: mock httpx response ──────────────────────────────────────────────
27
+
28
+
29
+ def _mock_httpx_response(text: str = "0", status_code: int = 200) -> httpx.Response:
30
+ """Build a minimal httpx.Response with the given text/status."""
31
+ return httpx.Response(status_code=status_code, text=text)
32
+
33
+
34
+ # ═══════════════════════════════════════════════════════════════════════════════
35
+ # Async ProdCalendar tests
36
+ # ═══════════════════════════════════════════════════════════════════════════════
37
+
38
+
39
+ class TestProdCalendar:
40
+ """Async client tests."""
41
+
42
+ # ── single-date methods ──────────────────────────────────────────────
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_today_working(self, calendar: ProdCalendar) -> None:
46
+ with patch.object(calendar, "_get", return_value="0"):
47
+ result = await calendar.today()
48
+ assert result == DateType.WORKING
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_today_not_working(self, calendar: ProdCalendar) -> None:
52
+ with patch.object(calendar, "_get", return_value="1"):
53
+ result = await calendar.today()
54
+ assert result == DateType.NOT_WORKING
55
+
56
+ @pytest.mark.asyncio
57
+ async def test_today_shortened(self, calendar: ProdCalendar) -> None:
58
+ with patch.object(calendar, "_get", return_value="2"):
59
+ result = await calendar.today()
60
+ assert result == DateType.SHORTENED
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_today_working_covid(self, calendar: ProdCalendar) -> None:
64
+ with patch.object(calendar, "_get", return_value="4"):
65
+ result = await calendar.today()
66
+ assert result == DateType.WORKING_DAY
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_date_specific(self, calendar: ProdCalendar) -> None:
70
+ with patch.object(calendar, "_get", return_value="1"):
71
+ result = await calendar.date(datetime.date(2024, 1, 1))
72
+ assert result == DateType.NOT_WORKING
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_tomorrow(self, calendar: ProdCalendar) -> None:
76
+ with patch.object(calendar, "_get", return_value="0"):
77
+ result = await calendar.tomorrow()
78
+ assert result == DateType.WORKING
79
+
80
+ # ── range methods ───────────────────────────────────────────────────
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_month(self, calendar: ProdCalendar) -> None:
84
+ with patch.object(calendar, "_get", return_value="0%7C1%7C0%7C1%7C0%7C1%7C0"):
85
+ result = await calendar.month(datetime.date(2024, 1, 1))
86
+ assert isinstance(result, dict)
87
+ first_key = min(result.keys())
88
+ assert first_key == "2024-01-01"
89
+ assert result["2024-01-01"] == DateType.WORKING
90
+ assert result["2024-01-02"] == DateType.NOT_WORKING
91
+
92
+ @pytest.mark.asyncio
93
+ async def test_year(self, calendar: ProdCalendar) -> None:
94
+ with patch.object(calendar, "_get", return_value="0%7C0%7C0"):
95
+ result = await calendar.year(datetime.date(2024, 1, 1))
96
+ first_key = min(result.keys())
97
+ assert first_key == "2024-01-01"
98
+ assert len(result) == 3
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_range_date(self, calendar: ProdCalendar) -> None:
102
+ with patch.object(calendar, "_get", return_value="0%7C1%7C0"):
103
+ result = await calendar.range_date(
104
+ datetime.date(2024, 1, 1), datetime.date(2024, 1, 3),
105
+ )
106
+ assert len(result) == 3
107
+ assert result["2024-01-01"] == DateType.WORKING
108
+ assert result["2024-01-02"] == DateType.NOT_WORKING
109
+
110
+ # ── locale ──────────────────────────────────────────────────────────
111
+
112
+ @pytest.mark.asyncio
113
+ async def test_locale_us_working(self) -> None:
114
+ cal = ProdCalendar(locale="us")
115
+ with patch.object(cal, "_get", return_value="0"):
116
+ result = await cal.today()
117
+ assert result == DateType.WORKING
118
+
119
+ # ── error handling ──────────────────────────────────────────────────
120
+
121
+ @pytest.mark.asyncio
122
+ async def test_data_error(self, calendar: ProdCalendar) -> None:
123
+ with patch.object(calendar, "_get", side_effect=DataError("Date error")):
124
+ with pytest.raises(DataError, match="Date error"):
125
+ await calendar.today()
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_service_not_respond(self, calendar: ProdCalendar) -> None:
129
+ with patch.object(
130
+ calendar, "_get", side_effect=ServiceNotRespond("No data found")
131
+ ):
132
+ with pytest.raises(ServiceNotRespond, match="No data found"):
133
+ await calendar.today()
134
+
135
+ # ── context manager ────────────────────────────────────────────────
136
+
137
+ @pytest.mark.asyncio
138
+ async def test_async_context_manager(self) -> None:
139
+ async with ProdCalendar(locale="ru") as cal:
140
+ assert isinstance(cal, ProdCalendar)
141
+ assert cal._client is not None
142
+ assert cal._client.is_closed
143
+
144
+ # ── date format ─────────────────────────────────────────────────────
145
+
146
+ @pytest.mark.asyncio
147
+ async def test_custom_date_format(self) -> None:
148
+ cal = ProdCalendar(locale="ru", date_format="%d.%m.%Y")
149
+ with patch.object(cal, "_get", return_value="0%7C1"):
150
+ result = await cal.month(datetime.date(2024, 1, 1))
151
+ first_key = min(result.keys())
152
+ assert first_key == "01.01.2024"
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_default_date_format_is_iso(self) -> None:
156
+ cal = ProdCalendar(locale="ru")
157
+ with patch.object(cal, "_get", return_value="0"):
158
+ result = await cal.month(datetime.date(2024, 1, 1))
159
+ first_key = min(result.keys())
160
+ assert first_key == "2024-01-01"
161
+
162
+ # ── kwargs ──────────────────────────────────────────────────────────
163
+
164
+ @pytest.mark.asyncio
165
+ async def test_kwargs_passed(self, calendar: ProdCalendar) -> None:
166
+ with patch.object(calendar, "_get", return_value="0") as mock_get:
167
+ result = await calendar.today(pre=True, sd=True, covid=True)
168
+ assert result == DateType.WORKING
169
+
170
+
171
+ # ═══════════════════════════════════════════════════════════════════════════════
172
+ # Sync SyncProdCalendar tests
173
+ # ═══════════════════════════════════════════════════════════════════════════════
174
+
175
+
176
+ class TestSyncProdCalendar:
177
+ """Sync client tests."""
178
+
179
+ # ── single-date methods ──────────────────────────────────────────────
180
+
181
+ def test_today_working(self, sync_calendar: SyncProdCalendar) -> None:
182
+ with patch.object(sync_calendar, "_get", return_value="0"):
183
+ result = sync_calendar.today()
184
+ assert result == DateType.WORKING
185
+
186
+ def test_today_not_working(self, sync_calendar: SyncProdCalendar) -> None:
187
+ with patch.object(sync_calendar, "_get", return_value="1"):
188
+ result = sync_calendar.today()
189
+ assert result == DateType.NOT_WORKING
190
+
191
+ def test_today_shortened(self, sync_calendar: SyncProdCalendar) -> None:
192
+ with patch.object(sync_calendar, "_get", return_value="2"):
193
+ result = sync_calendar.today()
194
+ assert result == DateType.SHORTENED
195
+
196
+ def test_today_working_covid(self, sync_calendar: SyncProdCalendar) -> None:
197
+ with patch.object(sync_calendar, "_get", return_value="4"):
198
+ result = sync_calendar.today()
199
+ assert result == DateType.WORKING_DAY
200
+
201
+ def test_date_specific(self, sync_calendar: SyncProdCalendar) -> None:
202
+ with patch.object(sync_calendar, "_get", return_value="1"):
203
+ result = sync_calendar.date(datetime.date(2024, 1, 1))
204
+ assert result == DateType.NOT_WORKING
205
+
206
+ def test_tomorrow(self, sync_calendar: SyncProdCalendar) -> None:
207
+ with patch.object(sync_calendar, "_get", return_value="0"):
208
+ result = sync_calendar.tomorrow()
209
+ assert result == DateType.WORKING
210
+
211
+ # ── range methods ───────────────────────────────────────────────────
212
+
213
+ def test_month(self, sync_calendar: SyncProdCalendar) -> None:
214
+ with patch.object(sync_calendar, "_get", return_value="0%7C1%7C0%7C1%7C0%7C1%7C0"):
215
+ result = sync_calendar.month(datetime.date(2024, 1, 1))
216
+ assert isinstance(result, dict)
217
+ first_key = min(result.keys())
218
+ assert first_key == "2024-01-01"
219
+ assert result["2024-01-01"] == DateType.WORKING
220
+ assert result["2024-01-02"] == DateType.NOT_WORKING
221
+
222
+ def test_year(self, sync_calendar: SyncProdCalendar) -> None:
223
+ with patch.object(sync_calendar, "_get", return_value="0%7C0%7C0"):
224
+ result = sync_calendar.year(datetime.date(2024, 1, 1))
225
+ first_key = min(result.keys())
226
+ assert first_key == "2024-01-01"
227
+ assert len(result) == 3
228
+
229
+ def test_range_date(self, sync_calendar: SyncProdCalendar) -> None:
230
+ with patch.object(sync_calendar, "_get", return_value="0%7C1%7C0"):
231
+ result = sync_calendar.range_date(
232
+ datetime.date(2024, 1, 1), datetime.date(2024, 1, 3),
233
+ )
234
+ assert len(result) == 3
235
+ assert result["2024-01-01"] == DateType.WORKING
236
+ assert result["2024-01-02"] == DateType.NOT_WORKING
237
+
238
+ # ── locale ──────────────────────────────────────────────────────────
239
+
240
+ def test_locale_us_working(self) -> None:
241
+ cal = SyncProdCalendar(locale="us")
242
+ with patch.object(cal, "_get", return_value="0"):
243
+ result = cal.today()
244
+ assert result == DateType.WORKING
245
+
246
+ # ── error handling ──────────────────────────────────────────────────
247
+
248
+ def test_data_error(self, sync_calendar: SyncProdCalendar) -> None:
249
+ with patch.object(
250
+ sync_calendar, "_get", side_effect=DataError("Date error")
251
+ ):
252
+ with pytest.raises(DataError, match="Date error"):
253
+ sync_calendar.today()
254
+
255
+ def test_service_not_respond(self, sync_calendar: SyncProdCalendar) -> None:
256
+ with patch.object(
257
+ sync_calendar, "_get", side_effect=ServiceNotRespond("No data found")
258
+ ):
259
+ with pytest.raises(ServiceNotRespond, match="No data found"):
260
+ sync_calendar.today()
261
+
262
+ # ── context manager ────────────────────────────────────────────────
263
+
264
+ def test_sync_context_manager(self) -> None:
265
+ with SyncProdCalendar(locale="ru") as cal:
266
+ assert isinstance(cal, SyncProdCalendar)
267
+ assert cal._client is not None
268
+ assert cal._client.is_closed
269
+
270
+ # ── date format ─────────────────────────────────────────────────────
271
+
272
+ def test_custom_date_format(self) -> None:
273
+ cal = SyncProdCalendar(locale="ru", date_format="%d.%m.%Y")
274
+ with patch.object(cal, "_get", return_value="0%7C1"):
275
+ result = cal.month(datetime.date(2024, 1, 1))
276
+ first_key = min(result.keys())
277
+ assert first_key == "01.01.2024"
278
+
279
+ def test_default_date_format_is_iso(self) -> None:
280
+ cal = SyncProdCalendar(locale="ru")
281
+ with patch.object(cal, "_get", return_value="0"):
282
+ result = cal.month(datetime.date(2024, 1, 1))
283
+ first_key = min(result.keys())
284
+ assert first_key == "2024-01-01"
285
+
286
+ # ── kwargs ──────────────────────────────────────────────────────────
287
+
288
+ def test_kwargs_passed(self, sync_calendar: SyncProdCalendar) -> None:
289
+ with patch.object(sync_calendar, "_get", return_value="0") as mock_get:
290
+ result = sync_calendar.today(pre=True, sd=True, covid=True)
291
+ assert result == DateType.WORKING
292
+
293
+
294
+ # ═══════════════════════════════════════════════════════════════════════════════
295
+ # Shared tests (both clients)
296
+ # ═══════════════════════════════════════════════════════════════════════════════
297
+
298
+
299
+ class TestCommon:
300
+ """Tests that apply to both sync and async clients."""
301
+
302
+ def test_invalid_locale(self) -> None:
303
+ with pytest.raises(ValueError, match="locale must be one of"):
304
+ ProdCalendar(locale="fr")
305
+ with pytest.raises(ValueError, match="locale must be one of"):
306
+ SyncProdCalendar(locale="fr")
307
+
308
+ @pytest.mark.parametrize(
309
+ ("year", "expected"),
310
+ [
311
+ (2020, True),
312
+ (2021, False),
313
+ (1900, False),
314
+ (2000, True),
315
+ (2024, True),
316
+ (2025, False),
317
+ ],
318
+ )
319
+ def test_is_leap(self, year: int, expected: bool) -> None:
320
+ assert ProdCalendar.is_leap(datetime.date(year, 1, 1)) is expected
321
+ assert SyncProdCalendar.is_leap(datetime.date(year, 1, 1)) is expected
322
+
323
+ def test_locale_is_valid_in_constructor(self) -> None:
324
+ cal = ProdCalendar(locale="tr")
325
+ assert cal.locale == "tr"
326
+ cal2 = SyncProdCalendar(locale="uz")
327
+ assert cal2.locale == "uz"