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.
- isdayoff_api-1.0.0/.gitignore +131 -0
- isdayoff_api-1.0.0/LICENSE +21 -0
- isdayoff_api-1.0.0/PKG-INFO +173 -0
- isdayoff_api-1.0.0/README.md +151 -0
- isdayoff_api-1.0.0/isdayoff/__init__.py +6 -0
- isdayoff_api-1.0.0/isdayoff/isdayoff.py +319 -0
- isdayoff_api-1.0.0/isdayoff/typingapi.py +54 -0
- isdayoff_api-1.0.0/pyproject.toml +45 -0
- isdayoff_api-1.0.0/tests/__init__.py +0 -0
- isdayoff_api-1.0.0/tests/test_isdayoff.py +327 -0
|
@@ -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,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"
|