prozorro-cli 0.1.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.
- prozorro_cli-0.1.0/LICENSE +22 -0
- prozorro_cli-0.1.0/PKG-INFO +111 -0
- prozorro_cli-0.1.0/README.md +89 -0
- prozorro_cli-0.1.0/pyproject.toml +38 -0
- prozorro_cli-0.1.0/setup.cfg +4 -0
- prozorro_cli-0.1.0/src/prozorro_cli/__init__.py +4 -0
- prozorro_cli-0.1.0/src/prozorro_cli/__main__.py +6 -0
- prozorro_cli-0.1.0/src/prozorro_cli/cli.py +141 -0
- prozorro_cli-0.1.0/src/prozorro_cli/client.py +297 -0
- prozorro_cli-0.1.0/src/prozorro_cli.egg-info/PKG-INFO +111 -0
- prozorro_cli-0.1.0/src/prozorro_cli.egg-info/SOURCES.txt +14 -0
- prozorro_cli-0.1.0/src/prozorro_cli.egg-info/dependency_links.txt +1 -0
- prozorro_cli-0.1.0/src/prozorro_cli.egg-info/entry_points.txt +2 -0
- prozorro_cli-0.1.0/src/prozorro_cli.egg-info/top_level.txt +1 -0
- prozorro_cli-0.1.0/tests/test_cli.py +178 -0
- prozorro_cli-0.1.0/tests/test_client.py +240 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 prozorro-cli contributors
|
|
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.
|
|
22
|
+
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: prozorro-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Small command-line client for public Prozorro tender data
|
|
5
|
+
Author: prozorro-cli contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/radimbig2/prozorro-cli
|
|
8
|
+
Project-URL: Issues, https://github.com/radimbig2/prozorro-cli/issues
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# prozorro-cli
|
|
24
|
+
|
|
25
|
+
Невелика CLI для отримання публічних даних про тендери Prozorro за UA-ID,
|
|
26
|
+
внутрішнім GUID, стандартним UUID або посиланням.
|
|
27
|
+
|
|
28
|
+
## Встановлення
|
|
29
|
+
|
|
30
|
+
```powershell
|
|
31
|
+
pip install prozorro-cli
|
|
32
|
+
prozorro-cli --help
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Для ізольованого глобального встановлення CLI також можна використати
|
|
36
|
+
`pipx install prozorro-cli`.
|
|
37
|
+
|
|
38
|
+
## Команди
|
|
39
|
+
|
|
40
|
+
```powershell
|
|
41
|
+
# Посилання на повний JSON у Public API
|
|
42
|
+
prozorro-cli tender UA-2026-06-15-003439-a --link
|
|
43
|
+
|
|
44
|
+
# Посилання на HTML-сторінку тендера
|
|
45
|
+
prozorro-cli tender UA-2026-06-15-003439-a --link-html
|
|
46
|
+
|
|
47
|
+
# Надрукувати й відкрити JSON Public API у браузері
|
|
48
|
+
prozorro-cli tender UA-2026-06-15-003439-a --link --open
|
|
49
|
+
|
|
50
|
+
# Надрукувати й відкрити HTML-сторінку у браузері
|
|
51
|
+
prozorro-cli tender UA-2026-06-15-003439-a --link-html --open
|
|
52
|
+
|
|
53
|
+
# Скорочений alias для --link-html
|
|
54
|
+
prozorro-cli tender UA-2026-06-15-003439-a --linkhtml --open
|
|
55
|
+
|
|
56
|
+
# Внутрішній id Prozorro
|
|
57
|
+
prozorro-cli tender UA-2026-06-15-003439-a --guid
|
|
58
|
+
|
|
59
|
+
# Той самий id у стандартному форматі UUID
|
|
60
|
+
prozorro-cli tender UA-2026-06-15-003439-a --guid-normal
|
|
61
|
+
|
|
62
|
+
# Повний JSON із public-api.prozorro.gov.ua
|
|
63
|
+
prozorro-cli tender UA-2026-06-15-003439-a
|
|
64
|
+
|
|
65
|
+
# Повний JSON за GUID без дефісів
|
|
66
|
+
prozorro-cli tender 5d2590ef8a1b455f8d09ceeae474b21f
|
|
67
|
+
|
|
68
|
+
# Повний JSON за стандартним UUID
|
|
69
|
+
prozorro-cli tender 5d2590ef-8a1b-455f-8d09-ceeae474b21f
|
|
70
|
+
|
|
71
|
+
# Повний JSON за посиланням на сторінку тендера
|
|
72
|
+
prozorro-cli tender https://prozorro.gov.ua/tender/UA-2026-06-15-003439-a
|
|
73
|
+
|
|
74
|
+
# Повний JSON за посиланням Public API
|
|
75
|
+
prozorro-cli tender https://public-api.prozorro.gov.ua/api/2.5/tenders/5d2590ef8a1b455f8d09ceeae474b21f
|
|
76
|
+
|
|
77
|
+
# Завантажити всі файли з data.documents за посиланням Public API
|
|
78
|
+
prozorro-cli documents https://public-api.prozorro.gov.ua/api/2.5/tenders/5d2590ef8a1b455f8d09ceeae474b21f --output /temp
|
|
79
|
+
|
|
80
|
+
# Те саме за UA-ID
|
|
81
|
+
prozorro-cli documents UA-2026-06-15-003439-a --output /temp
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Для UA-ID і посилання на сторінку CLI спочатку отримує внутрішній `id` через
|
|
85
|
+
публічний endpoint `https://prozorro.gov.ua/api/tenders/<UA-ID>/summary`.
|
|
86
|
+
Для GUID, UUID і посилання Public API цей крок пропускається. Повний JSON
|
|
87
|
+
завантажується з `https://public-api.prozorro.gov.ua/api/2.5/tenders/<id>`.
|
|
88
|
+
|
|
89
|
+
Команда `documents` створює каталог із `--output`, якщо його ще немає, і
|
|
90
|
+
завантажує туди всі файли з `data.documents`. Імена беруться з `title`;
|
|
91
|
+
однакові імена не перезаписуються, а отримують суфікси `(2)`, `(3)` тощо.
|
|
92
|
+
|
|
93
|
+
## Розробка
|
|
94
|
+
|
|
95
|
+
```powershell
|
|
96
|
+
$env:PYTHONPATH = "$PWD\src"
|
|
97
|
+
python -m unittest discover -s tests -v
|
|
98
|
+
python -m prozorro_cli tender UA-2026-06-15-003439-a --guid
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Реліз
|
|
102
|
+
|
|
103
|
+
1. Оновіть `version` у `pyproject.toml` та `__version__` у
|
|
104
|
+
`src/prozorro_cli/__init__.py`.
|
|
105
|
+
2. Створіть і опублікуйте GitHub Release з тегом тієї ж версії, наприклад
|
|
106
|
+
`v0.1.0`.
|
|
107
|
+
3. GitHub Actions перевірить тести, збере wheel і sdist, додасть їх до Release
|
|
108
|
+
та опублікує пакет у PyPI.
|
|
109
|
+
|
|
110
|
+
Для публікації потрібен GitHub Actions secret `PYPI_API_TOKEN` з API-токеном
|
|
111
|
+
PyPI. Job публікації запускається в GitHub environment `pypi`.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# prozorro-cli
|
|
2
|
+
|
|
3
|
+
Невелика CLI для отримання публічних даних про тендери Prozorro за UA-ID,
|
|
4
|
+
внутрішнім GUID, стандартним UUID або посиланням.
|
|
5
|
+
|
|
6
|
+
## Встановлення
|
|
7
|
+
|
|
8
|
+
```powershell
|
|
9
|
+
pip install prozorro-cli
|
|
10
|
+
prozorro-cli --help
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Для ізольованого глобального встановлення CLI також можна використати
|
|
14
|
+
`pipx install prozorro-cli`.
|
|
15
|
+
|
|
16
|
+
## Команди
|
|
17
|
+
|
|
18
|
+
```powershell
|
|
19
|
+
# Посилання на повний JSON у Public API
|
|
20
|
+
prozorro-cli tender UA-2026-06-15-003439-a --link
|
|
21
|
+
|
|
22
|
+
# Посилання на HTML-сторінку тендера
|
|
23
|
+
prozorro-cli tender UA-2026-06-15-003439-a --link-html
|
|
24
|
+
|
|
25
|
+
# Надрукувати й відкрити JSON Public API у браузері
|
|
26
|
+
prozorro-cli tender UA-2026-06-15-003439-a --link --open
|
|
27
|
+
|
|
28
|
+
# Надрукувати й відкрити HTML-сторінку у браузері
|
|
29
|
+
prozorro-cli tender UA-2026-06-15-003439-a --link-html --open
|
|
30
|
+
|
|
31
|
+
# Скорочений alias для --link-html
|
|
32
|
+
prozorro-cli tender UA-2026-06-15-003439-a --linkhtml --open
|
|
33
|
+
|
|
34
|
+
# Внутрішній id Prozorro
|
|
35
|
+
prozorro-cli tender UA-2026-06-15-003439-a --guid
|
|
36
|
+
|
|
37
|
+
# Той самий id у стандартному форматі UUID
|
|
38
|
+
prozorro-cli tender UA-2026-06-15-003439-a --guid-normal
|
|
39
|
+
|
|
40
|
+
# Повний JSON із public-api.prozorro.gov.ua
|
|
41
|
+
prozorro-cli tender UA-2026-06-15-003439-a
|
|
42
|
+
|
|
43
|
+
# Повний JSON за GUID без дефісів
|
|
44
|
+
prozorro-cli tender 5d2590ef8a1b455f8d09ceeae474b21f
|
|
45
|
+
|
|
46
|
+
# Повний JSON за стандартним UUID
|
|
47
|
+
prozorro-cli tender 5d2590ef-8a1b-455f-8d09-ceeae474b21f
|
|
48
|
+
|
|
49
|
+
# Повний JSON за посиланням на сторінку тендера
|
|
50
|
+
prozorro-cli tender https://prozorro.gov.ua/tender/UA-2026-06-15-003439-a
|
|
51
|
+
|
|
52
|
+
# Повний JSON за посиланням Public API
|
|
53
|
+
prozorro-cli tender https://public-api.prozorro.gov.ua/api/2.5/tenders/5d2590ef8a1b455f8d09ceeae474b21f
|
|
54
|
+
|
|
55
|
+
# Завантажити всі файли з data.documents за посиланням Public API
|
|
56
|
+
prozorro-cli documents https://public-api.prozorro.gov.ua/api/2.5/tenders/5d2590ef8a1b455f8d09ceeae474b21f --output /temp
|
|
57
|
+
|
|
58
|
+
# Те саме за UA-ID
|
|
59
|
+
prozorro-cli documents UA-2026-06-15-003439-a --output /temp
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Для UA-ID і посилання на сторінку CLI спочатку отримує внутрішній `id` через
|
|
63
|
+
публічний endpoint `https://prozorro.gov.ua/api/tenders/<UA-ID>/summary`.
|
|
64
|
+
Для GUID, UUID і посилання Public API цей крок пропускається. Повний JSON
|
|
65
|
+
завантажується з `https://public-api.prozorro.gov.ua/api/2.5/tenders/<id>`.
|
|
66
|
+
|
|
67
|
+
Команда `documents` створює каталог із `--output`, якщо його ще немає, і
|
|
68
|
+
завантажує туди всі файли з `data.documents`. Імена беруться з `title`;
|
|
69
|
+
однакові імена не перезаписуються, а отримують суфікси `(2)`, `(3)` тощо.
|
|
70
|
+
|
|
71
|
+
## Розробка
|
|
72
|
+
|
|
73
|
+
```powershell
|
|
74
|
+
$env:PYTHONPATH = "$PWD\src"
|
|
75
|
+
python -m unittest discover -s tests -v
|
|
76
|
+
python -m prozorro_cli tender UA-2026-06-15-003439-a --guid
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Реліз
|
|
80
|
+
|
|
81
|
+
1. Оновіть `version` у `pyproject.toml` та `__version__` у
|
|
82
|
+
`src/prozorro_cli/__init__.py`.
|
|
83
|
+
2. Створіть і опублікуйте GitHub Release з тегом тієї ж версії, наприклад
|
|
84
|
+
`v0.1.0`.
|
|
85
|
+
3. GitHub Actions перевірить тести, збере wheel і sdist, додасть їх до Release
|
|
86
|
+
та опублікує пакет у PyPI.
|
|
87
|
+
|
|
88
|
+
Для публікації потрібен GitHub Actions secret `PYPI_API_TOKEN` з API-токеном
|
|
89
|
+
PyPI. Job публікації запускається в GitHub environment `pypi`.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "prozorro-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Small command-line client for public Prozorro tender data"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "prozorro-cli contributors" }]
|
|
14
|
+
dependencies = []
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Environment :: Console",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.10",
|
|
21
|
+
"Programming Language :: Python :: 3.11",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/radimbig2/prozorro-cli"
|
|
29
|
+
Issues = "https://github.com/radimbig2/prozorro-cli/issues"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
prozorro-cli = "prozorro_cli.cli:main"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools]
|
|
35
|
+
package-dir = { "" = "src" }
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["src"]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import webbrowser
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from prozorro_cli.client import (
|
|
10
|
+
ProzorroError,
|
|
11
|
+
download_documents,
|
|
12
|
+
fetch_tender,
|
|
13
|
+
normal_guid,
|
|
14
|
+
public_api_link,
|
|
15
|
+
resolve_guid,
|
|
16
|
+
tender_link,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def configure_windows_streams() -> None:
|
|
21
|
+
for stream in (sys.stdout, sys.stderr):
|
|
22
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
23
|
+
if reconfigure is not None:
|
|
24
|
+
reconfigure(encoding="utf-8")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def print_link(url: str, *, open_in_browser: bool) -> None:
|
|
28
|
+
print(url)
|
|
29
|
+
if open_in_browser and not webbrowser.open(url, new=2):
|
|
30
|
+
raise ProzorroError("Не вдалося відкрити посилання у браузері.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="prozorro-cli",
|
|
36
|
+
description="Отримання публічних даних про тендери Prozorro.",
|
|
37
|
+
)
|
|
38
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
39
|
+
|
|
40
|
+
tender_parser = subparsers.add_parser(
|
|
41
|
+
"tender",
|
|
42
|
+
help="отримати посилання, GUID або повний JSON тендера",
|
|
43
|
+
)
|
|
44
|
+
tender_parser.add_argument(
|
|
45
|
+
"reference",
|
|
46
|
+
metavar="REFERENCE",
|
|
47
|
+
help="UA-ID, GUID, UUID або посилання на тендер Prozorro",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
output_group = tender_parser.add_mutually_exclusive_group()
|
|
51
|
+
output_group.add_argument(
|
|
52
|
+
"--link",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="вивести посилання на повний JSON у Public API",
|
|
55
|
+
)
|
|
56
|
+
output_group.add_argument(
|
|
57
|
+
"--link-html",
|
|
58
|
+
"--linkhtml",
|
|
59
|
+
dest="link_html",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="вивести посилання на HTML-сторінку тендера",
|
|
62
|
+
)
|
|
63
|
+
output_group.add_argument(
|
|
64
|
+
"--guid",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="вивести внутрішній id Prozorro без дефісів",
|
|
67
|
+
)
|
|
68
|
+
output_group.add_argument(
|
|
69
|
+
"--guid-normal",
|
|
70
|
+
action="store_true",
|
|
71
|
+
help="вивести внутрішній id у стандартному форматі UUID",
|
|
72
|
+
)
|
|
73
|
+
tender_parser.add_argument(
|
|
74
|
+
"--open",
|
|
75
|
+
action="store_true",
|
|
76
|
+
help="відкрити посилання з --link або --link-html у браузері",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
documents_parser = subparsers.add_parser(
|
|
80
|
+
"documents",
|
|
81
|
+
help="завантажити всі файли з data.documents тендера",
|
|
82
|
+
)
|
|
83
|
+
documents_parser.add_argument(
|
|
84
|
+
"reference",
|
|
85
|
+
metavar="REFERENCE",
|
|
86
|
+
help="UA-ID, GUID, UUID або посилання на тендер Prozorro",
|
|
87
|
+
)
|
|
88
|
+
documents_parser.add_argument(
|
|
89
|
+
"--output",
|
|
90
|
+
required=True,
|
|
91
|
+
metavar="DIRECTORY",
|
|
92
|
+
help="каталог для завантажених документів",
|
|
93
|
+
)
|
|
94
|
+
return parser
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
98
|
+
configure_windows_streams()
|
|
99
|
+
parser = build_parser()
|
|
100
|
+
args = parser.parse_args(argv)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
if args.command == "tender":
|
|
104
|
+
if args.open and not (args.link or args.link_html):
|
|
105
|
+
parser.error("--open потребує --link або --link-html.")
|
|
106
|
+
|
|
107
|
+
if args.link:
|
|
108
|
+
print_link(
|
|
109
|
+
public_api_link(args.reference),
|
|
110
|
+
open_in_browser=args.open,
|
|
111
|
+
)
|
|
112
|
+
return 0
|
|
113
|
+
|
|
114
|
+
if args.link_html:
|
|
115
|
+
print_link(
|
|
116
|
+
tender_link(args.reference),
|
|
117
|
+
open_in_browser=args.open,
|
|
118
|
+
)
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
if args.guid or args.guid_normal:
|
|
122
|
+
guid = resolve_guid(args.reference)
|
|
123
|
+
print(normal_guid(guid) if args.guid_normal else guid)
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
payload = fetch_tender(args.reference)
|
|
127
|
+
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
|
|
128
|
+
sys.stdout.write("\n")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
if args.command == "documents":
|
|
132
|
+
downloaded = download_documents(args.reference, args.output)
|
|
133
|
+
for path in downloaded:
|
|
134
|
+
print(path)
|
|
135
|
+
print(f"Завантажено документів: {len(downloaded)}")
|
|
136
|
+
return 0
|
|
137
|
+
except ProzorroError as error:
|
|
138
|
+
parser.exit(1, f"Помилка: {error}\n")
|
|
139
|
+
|
|
140
|
+
parser.error("Невідома команда.")
|
|
141
|
+
return 2
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.error import HTTPError, URLError
|
|
10
|
+
from urllib.parse import quote, unquote, urlsplit
|
|
11
|
+
from urllib.request import Request, urlopen
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SUMMARY_URL = "https://prozorro.gov.ua/api/tenders/{tender_id}/summary"
|
|
15
|
+
PUBLIC_API_URL = "https://public-api.prozorro.gov.ua/api/2.5/tenders/{guid}"
|
|
16
|
+
TENDER_PAGE_URL = "https://prozorro.gov.ua/tender/{tender_id}"
|
|
17
|
+
|
|
18
|
+
TENDER_ID_PATTERN = re.compile(r"^UA-\d{4}-\d{2}-\d{2}-\d{6}-[a-z]$")
|
|
19
|
+
GUID_PATTERN = re.compile(r"^[0-9a-fA-F]{32}$")
|
|
20
|
+
NORMAL_GUID_PATTERN = re.compile(
|
|
21
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
|
22
|
+
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
23
|
+
)
|
|
24
|
+
SUPPORTED_HOSTS = {
|
|
25
|
+
"prozorro.gov.ua",
|
|
26
|
+
"www.prozorro.gov.ua",
|
|
27
|
+
"public-api.prozorro.gov.ua",
|
|
28
|
+
}
|
|
29
|
+
INVALID_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
|
30
|
+
WINDOWS_RESERVED_FILENAMES = {
|
|
31
|
+
"CON",
|
|
32
|
+
"PRN",
|
|
33
|
+
"AUX",
|
|
34
|
+
"NUL",
|
|
35
|
+
*(f"COM{number}" for number in range(1, 10)),
|
|
36
|
+
*(f"LPT{number}" for number in range(1, 10)),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ProzorroError(RuntimeError):
|
|
41
|
+
"""A user-facing error returned by Prozorro or the network."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class TenderReference:
|
|
46
|
+
tender_id: str | None = None
|
|
47
|
+
guid: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_tender_id(tender_id: str) -> str:
|
|
51
|
+
if not TENDER_ID_PATTERN.fullmatch(tender_id):
|
|
52
|
+
raise ProzorroError(
|
|
53
|
+
"Некоректний номер тендера. Очікується формат "
|
|
54
|
+
"UA-YYYY-MM-DD-NNNNNN-x."
|
|
55
|
+
)
|
|
56
|
+
return tender_id
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def compact_guid(guid: str) -> str:
|
|
60
|
+
if GUID_PATTERN.fullmatch(guid):
|
|
61
|
+
return guid.lower()
|
|
62
|
+
if NORMAL_GUID_PATTERN.fullmatch(guid):
|
|
63
|
+
return guid.replace("-", "").lower()
|
|
64
|
+
raise ProzorroError("Некоректний внутрішній id Prozorro.")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_tender_reference(value: str) -> TenderReference:
|
|
68
|
+
candidate = value.strip()
|
|
69
|
+
if TENDER_ID_PATTERN.fullmatch(candidate):
|
|
70
|
+
return TenderReference(tender_id=candidate)
|
|
71
|
+
if GUID_PATTERN.fullmatch(candidate) or NORMAL_GUID_PATTERN.fullmatch(candidate):
|
|
72
|
+
return TenderReference(guid=compact_guid(candidate))
|
|
73
|
+
|
|
74
|
+
parsed = urlsplit(candidate)
|
|
75
|
+
if parsed.scheme not in {"http", "https"} or parsed.hostname not in SUPPORTED_HOSTS:
|
|
76
|
+
raise ProzorroError(
|
|
77
|
+
"Очікується UA-ID, GUID, UUID або посилання на тендер Prozorro."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
parts = [unquote(part) for part in parsed.path.strip("/").split("/") if part]
|
|
81
|
+
if len(parts) == 2 and parts[0] == "tender":
|
|
82
|
+
return TenderReference(tender_id=validate_tender_id(parts[1]))
|
|
83
|
+
if len(parts) == 3 and parts[0] in {"uk", "en"} and parts[1] == "tender":
|
|
84
|
+
return TenderReference(tender_id=validate_tender_id(parts[2]))
|
|
85
|
+
if (
|
|
86
|
+
len(parts) == 4
|
|
87
|
+
and parts[0:2] == ["api", "tenders"]
|
|
88
|
+
and parts[3] == "summary"
|
|
89
|
+
):
|
|
90
|
+
return TenderReference(tender_id=validate_tender_id(parts[2]))
|
|
91
|
+
if (
|
|
92
|
+
len(parts) == 4
|
|
93
|
+
and parts[0:3] == ["api", "2.5", "tenders"]
|
|
94
|
+
):
|
|
95
|
+
return TenderReference(guid=compact_guid(parts[3]))
|
|
96
|
+
|
|
97
|
+
raise ProzorroError("Посилання не веде на підтримуваний тендер Prozorro.")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def tender_link(reference: str, *, timeout: float = 30.0) -> str:
|
|
101
|
+
tender_id = resolve_tender_id(reference, timeout=timeout)
|
|
102
|
+
return TENDER_PAGE_URL.format(tender_id=quote(tender_id, safe=""))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def public_api_link(reference: str, *, timeout: float = 30.0) -> str:
|
|
106
|
+
guid = resolve_guid(reference, timeout=timeout)
|
|
107
|
+
return PUBLIC_API_URL.format(guid=guid)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fetch_json(url: str, *, timeout: float = 30.0) -> dict[str, Any]:
|
|
111
|
+
request = Request(
|
|
112
|
+
url,
|
|
113
|
+
headers={
|
|
114
|
+
"Accept": "application/json",
|
|
115
|
+
"Accept-Language": "uk",
|
|
116
|
+
"User-Agent": "prozorro-cli/0.1 (+https://prozorro.gov.ua)",
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
with urlopen(request, timeout=timeout) as response:
|
|
122
|
+
payload = json.load(response)
|
|
123
|
+
except HTTPError as error:
|
|
124
|
+
if error.code == 404:
|
|
125
|
+
raise ProzorroError("Тендер не знайдено.") from error
|
|
126
|
+
raise ProzorroError(f"Prozorro повернув HTTP {error.code}.") from error
|
|
127
|
+
except URLError as error:
|
|
128
|
+
reason = getattr(error, "reason", error)
|
|
129
|
+
raise ProzorroError(f"Не вдалося підключитися до Prozorro: {reason}") from error
|
|
130
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as error:
|
|
131
|
+
raise ProzorroError("Prozorro повернув некоректний JSON.") from error
|
|
132
|
+
|
|
133
|
+
if not isinstance(payload, dict):
|
|
134
|
+
raise ProzorroError("Prozorro повернув JSON неочікуваного формату.")
|
|
135
|
+
return payload
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def resolve_guid(reference: str, *, timeout: float = 30.0) -> str:
|
|
139
|
+
parsed = parse_tender_reference(reference)
|
|
140
|
+
if parsed.guid is not None:
|
|
141
|
+
return parsed.guid
|
|
142
|
+
|
|
143
|
+
tender_id = parsed.tender_id
|
|
144
|
+
if tender_id is None:
|
|
145
|
+
raise ProzorroError("Не вдалося визначити номер тендера.")
|
|
146
|
+
url = SUMMARY_URL.format(tender_id=quote(tender_id, safe=""))
|
|
147
|
+
summary = fetch_json(url, timeout=timeout)
|
|
148
|
+
guid = summary.get("id")
|
|
149
|
+
|
|
150
|
+
if not isinstance(guid, str) or not GUID_PATTERN.fullmatch(guid):
|
|
151
|
+
raise ProzorroError("У відповіді Prozorro немає коректного внутрішнього id.")
|
|
152
|
+
return guid.lower()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def normal_guid(guid: str) -> str:
|
|
156
|
+
normalized = compact_guid(guid)
|
|
157
|
+
return (
|
|
158
|
+
f"{normalized[0:8]}-{normalized[8:12]}-{normalized[12:16]}-"
|
|
159
|
+
f"{normalized[16:20]}-{normalized[20:32]}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def fetch_tender(reference: str, *, timeout: float = 30.0) -> dict[str, Any]:
|
|
164
|
+
guid = resolve_guid(reference, timeout=timeout)
|
|
165
|
+
url = PUBLIC_API_URL.format(guid=guid)
|
|
166
|
+
return fetch_json(url, timeout=timeout)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def safe_document_filename(value: str, *, fallback: str) -> str:
|
|
170
|
+
filename = INVALID_FILENAME_CHARS.sub("_", value).strip().rstrip(". ")
|
|
171
|
+
if not filename:
|
|
172
|
+
filename = fallback
|
|
173
|
+
|
|
174
|
+
stem = filename.split(".", 1)[0].upper()
|
|
175
|
+
if stem in WINDOWS_RESERVED_FILENAMES:
|
|
176
|
+
filename = f"_{filename}"
|
|
177
|
+
return filename
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def available_path(directory: Path, filename: str) -> Path:
|
|
181
|
+
candidate = directory / filename
|
|
182
|
+
if not candidate.exists():
|
|
183
|
+
return candidate
|
|
184
|
+
|
|
185
|
+
suffix = candidate.suffix
|
|
186
|
+
stem = candidate.name[: -len(suffix)] if suffix else candidate.name
|
|
187
|
+
number = 2
|
|
188
|
+
while True:
|
|
189
|
+
candidate = directory / f"{stem} ({number}){suffix}"
|
|
190
|
+
if not candidate.exists():
|
|
191
|
+
return candidate
|
|
192
|
+
number += 1
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def download_document(
|
|
196
|
+
url: str,
|
|
197
|
+
destination: Path,
|
|
198
|
+
*,
|
|
199
|
+
timeout: float = 30.0,
|
|
200
|
+
) -> None:
|
|
201
|
+
parsed = urlsplit(url)
|
|
202
|
+
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
203
|
+
raise ProzorroError("Документ містить некоректне посилання.")
|
|
204
|
+
|
|
205
|
+
request = Request(
|
|
206
|
+
url,
|
|
207
|
+
headers={"User-Agent": "prozorro-cli/0.1 (+https://prozorro.gov.ua)"},
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
with urlopen(request, timeout=timeout) as response:
|
|
211
|
+
with destination.open("xb") as output:
|
|
212
|
+
shutil.copyfileobj(response, output)
|
|
213
|
+
except HTTPError as error:
|
|
214
|
+
destination.unlink(missing_ok=True)
|
|
215
|
+
raise ProzorroError(
|
|
216
|
+
f"Не вдалося завантажити документ: HTTP {error.code}."
|
|
217
|
+
) from error
|
|
218
|
+
except URLError as error:
|
|
219
|
+
destination.unlink(missing_ok=True)
|
|
220
|
+
reason = getattr(error, "reason", error)
|
|
221
|
+
raise ProzorroError(
|
|
222
|
+
f"Не вдалося завантажити документ: {reason}"
|
|
223
|
+
) from error
|
|
224
|
+
except OSError as error:
|
|
225
|
+
destination.unlink(missing_ok=True)
|
|
226
|
+
raise ProzorroError(
|
|
227
|
+
f"Не вдалося зберегти документ «{destination.name}»: {error}"
|
|
228
|
+
) from error
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def download_documents(
|
|
232
|
+
reference: str,
|
|
233
|
+
output: str | Path,
|
|
234
|
+
*,
|
|
235
|
+
timeout: float = 30.0,
|
|
236
|
+
) -> list[Path]:
|
|
237
|
+
payload = fetch_tender(reference, timeout=timeout)
|
|
238
|
+
data = payload.get("data")
|
|
239
|
+
documents = data.get("documents") if isinstance(data, dict) else None
|
|
240
|
+
if not isinstance(documents, list):
|
|
241
|
+
raise ProzorroError("У відповіді Prozorro немає масиву data.documents.")
|
|
242
|
+
|
|
243
|
+
output_directory = Path(output).expanduser()
|
|
244
|
+
try:
|
|
245
|
+
output_directory.mkdir(parents=True, exist_ok=True)
|
|
246
|
+
except OSError as error:
|
|
247
|
+
raise ProzorroError(
|
|
248
|
+
f"Не вдалося створити каталог «{output_directory}»: {error}"
|
|
249
|
+
) from error
|
|
250
|
+
if not output_directory.is_dir():
|
|
251
|
+
raise ProzorroError(f"Шлях «{output_directory}» не є каталогом.")
|
|
252
|
+
|
|
253
|
+
downloaded: list[Path] = []
|
|
254
|
+
for index, document in enumerate(documents, start=1):
|
|
255
|
+
if not isinstance(document, dict):
|
|
256
|
+
raise ProzorroError(
|
|
257
|
+
f"Документ #{index} у data.documents має некоректний формат."
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
url = document.get("url")
|
|
261
|
+
if not isinstance(url, str) or not url.strip():
|
|
262
|
+
raise ProzorroError(
|
|
263
|
+
f"Документ #{index} у data.documents не містить url."
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
document_id = document.get("id")
|
|
267
|
+
fallback = (
|
|
268
|
+
document_id
|
|
269
|
+
if isinstance(document_id, str) and document_id.strip()
|
|
270
|
+
else f"document-{index}"
|
|
271
|
+
)
|
|
272
|
+
title = document.get("title")
|
|
273
|
+
filename = safe_document_filename(
|
|
274
|
+
title if isinstance(title, str) else "",
|
|
275
|
+
fallback=fallback,
|
|
276
|
+
)
|
|
277
|
+
destination = available_path(output_directory, filename)
|
|
278
|
+
download_document(url, destination, timeout=timeout)
|
|
279
|
+
downloaded.append(destination)
|
|
280
|
+
|
|
281
|
+
return downloaded
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def resolve_tender_id(reference: str, *, timeout: float = 30.0) -> str:
|
|
285
|
+
parsed = parse_tender_reference(reference)
|
|
286
|
+
if parsed.tender_id is not None:
|
|
287
|
+
return parsed.tender_id
|
|
288
|
+
|
|
289
|
+
guid = parsed.guid
|
|
290
|
+
if guid is None:
|
|
291
|
+
raise ProzorroError("Не вдалося визначити внутрішній id Prozorro.")
|
|
292
|
+
payload = fetch_json(PUBLIC_API_URL.format(guid=guid), timeout=timeout)
|
|
293
|
+
data = payload.get("data")
|
|
294
|
+
tender_id = data.get("tenderID") if isinstance(data, dict) else None
|
|
295
|
+
if not isinstance(tender_id, str):
|
|
296
|
+
raise ProzorroError("У відповіді Prozorro немає номера тендера.")
|
|
297
|
+
return validate_tender_id(tender_id)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: prozorro-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Small command-line client for public Prozorro tender data
|
|
5
|
+
Author: prozorro-cli contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/radimbig2/prozorro-cli
|
|
8
|
+
Project-URL: Issues, https://github.com/radimbig2/prozorro-cli/issues
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# prozorro-cli
|
|
24
|
+
|
|
25
|
+
Невелика CLI для отримання публічних даних про тендери Prozorro за UA-ID,
|
|
26
|
+
внутрішнім GUID, стандартним UUID або посиланням.
|
|
27
|
+
|
|
28
|
+
## Встановлення
|
|
29
|
+
|
|
30
|
+
```powershell
|
|
31
|
+
pip install prozorro-cli
|
|
32
|
+
prozorro-cli --help
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Для ізольованого глобального встановлення CLI також можна використати
|
|
36
|
+
`pipx install prozorro-cli`.
|
|
37
|
+
|
|
38
|
+
## Команди
|
|
39
|
+
|
|
40
|
+
```powershell
|
|
41
|
+
# Посилання на повний JSON у Public API
|
|
42
|
+
prozorro-cli tender UA-2026-06-15-003439-a --link
|
|
43
|
+
|
|
44
|
+
# Посилання на HTML-сторінку тендера
|
|
45
|
+
prozorro-cli tender UA-2026-06-15-003439-a --link-html
|
|
46
|
+
|
|
47
|
+
# Надрукувати й відкрити JSON Public API у браузері
|
|
48
|
+
prozorro-cli tender UA-2026-06-15-003439-a --link --open
|
|
49
|
+
|
|
50
|
+
# Надрукувати й відкрити HTML-сторінку у браузері
|
|
51
|
+
prozorro-cli tender UA-2026-06-15-003439-a --link-html --open
|
|
52
|
+
|
|
53
|
+
# Скорочений alias для --link-html
|
|
54
|
+
prozorro-cli tender UA-2026-06-15-003439-a --linkhtml --open
|
|
55
|
+
|
|
56
|
+
# Внутрішній id Prozorro
|
|
57
|
+
prozorro-cli tender UA-2026-06-15-003439-a --guid
|
|
58
|
+
|
|
59
|
+
# Той самий id у стандартному форматі UUID
|
|
60
|
+
prozorro-cli tender UA-2026-06-15-003439-a --guid-normal
|
|
61
|
+
|
|
62
|
+
# Повний JSON із public-api.prozorro.gov.ua
|
|
63
|
+
prozorro-cli tender UA-2026-06-15-003439-a
|
|
64
|
+
|
|
65
|
+
# Повний JSON за GUID без дефісів
|
|
66
|
+
prozorro-cli tender 5d2590ef8a1b455f8d09ceeae474b21f
|
|
67
|
+
|
|
68
|
+
# Повний JSON за стандартним UUID
|
|
69
|
+
prozorro-cli tender 5d2590ef-8a1b-455f-8d09-ceeae474b21f
|
|
70
|
+
|
|
71
|
+
# Повний JSON за посиланням на сторінку тендера
|
|
72
|
+
prozorro-cli tender https://prozorro.gov.ua/tender/UA-2026-06-15-003439-a
|
|
73
|
+
|
|
74
|
+
# Повний JSON за посиланням Public API
|
|
75
|
+
prozorro-cli tender https://public-api.prozorro.gov.ua/api/2.5/tenders/5d2590ef8a1b455f8d09ceeae474b21f
|
|
76
|
+
|
|
77
|
+
# Завантажити всі файли з data.documents за посиланням Public API
|
|
78
|
+
prozorro-cli documents https://public-api.prozorro.gov.ua/api/2.5/tenders/5d2590ef8a1b455f8d09ceeae474b21f --output /temp
|
|
79
|
+
|
|
80
|
+
# Те саме за UA-ID
|
|
81
|
+
prozorro-cli documents UA-2026-06-15-003439-a --output /temp
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Для UA-ID і посилання на сторінку CLI спочатку отримує внутрішній `id` через
|
|
85
|
+
публічний endpoint `https://prozorro.gov.ua/api/tenders/<UA-ID>/summary`.
|
|
86
|
+
Для GUID, UUID і посилання Public API цей крок пропускається. Повний JSON
|
|
87
|
+
завантажується з `https://public-api.prozorro.gov.ua/api/2.5/tenders/<id>`.
|
|
88
|
+
|
|
89
|
+
Команда `documents` створює каталог із `--output`, якщо його ще немає, і
|
|
90
|
+
завантажує туди всі файли з `data.documents`. Імена беруться з `title`;
|
|
91
|
+
однакові імена не перезаписуються, а отримують суфікси `(2)`, `(3)` тощо.
|
|
92
|
+
|
|
93
|
+
## Розробка
|
|
94
|
+
|
|
95
|
+
```powershell
|
|
96
|
+
$env:PYTHONPATH = "$PWD\src"
|
|
97
|
+
python -m unittest discover -s tests -v
|
|
98
|
+
python -m prozorro_cli tender UA-2026-06-15-003439-a --guid
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Реліз
|
|
102
|
+
|
|
103
|
+
1. Оновіть `version` у `pyproject.toml` та `__version__` у
|
|
104
|
+
`src/prozorro_cli/__init__.py`.
|
|
105
|
+
2. Створіть і опублікуйте GitHub Release з тегом тієї ж версії, наприклад
|
|
106
|
+
`v0.1.0`.
|
|
107
|
+
3. GitHub Actions перевірить тести, збере wheel і sdist, додасть їх до Release
|
|
108
|
+
та опублікує пакет у PyPI.
|
|
109
|
+
|
|
110
|
+
Для публікації потрібен GitHub Actions secret `PYPI_API_TOKEN` з API-токеном
|
|
111
|
+
PyPI. Job публікації запускається в GitHub environment `pypi`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/prozorro_cli/__init__.py
|
|
5
|
+
src/prozorro_cli/__main__.py
|
|
6
|
+
src/prozorro_cli/cli.py
|
|
7
|
+
src/prozorro_cli/client.py
|
|
8
|
+
src/prozorro_cli.egg-info/PKG-INFO
|
|
9
|
+
src/prozorro_cli.egg-info/SOURCES.txt
|
|
10
|
+
src/prozorro_cli.egg-info/dependency_links.txt
|
|
11
|
+
src/prozorro_cli.egg-info/entry_points.txt
|
|
12
|
+
src/prozorro_cli.egg-info/top_level.txt
|
|
13
|
+
tests/test_cli.py
|
|
14
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
prozorro_cli
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
from contextlib import redirect_stdout
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
from prozorro_cli.cli import main
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
TENDER_ID = "UA-2026-06-15-003439-a"
|
|
13
|
+
GUID = "5d2590ef8a1b455f8d09ceeae474b21f"
|
|
14
|
+
NORMAL_GUID = "5d2590ef-8a1b-455f-8d09-ceeae474b21f"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CliTests(unittest.TestCase):
|
|
18
|
+
def run_cli(self, *arguments: str) -> tuple[int, str]:
|
|
19
|
+
stdout = io.StringIO()
|
|
20
|
+
with redirect_stdout(stdout):
|
|
21
|
+
exit_code = main(list(arguments))
|
|
22
|
+
return exit_code, stdout.getvalue()
|
|
23
|
+
|
|
24
|
+
@patch(
|
|
25
|
+
"prozorro_cli.cli.public_api_link",
|
|
26
|
+
return_value=(
|
|
27
|
+
"https://public-api.prozorro.gov.ua/api/2.5/tenders/"
|
|
28
|
+
f"{GUID}"
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
def test_link(self, public_api_link_mock) -> None:
|
|
32
|
+
exit_code, output = self.run_cli("tender", TENDER_ID, "--link")
|
|
33
|
+
|
|
34
|
+
self.assertEqual(exit_code, 0)
|
|
35
|
+
self.assertEqual(
|
|
36
|
+
output,
|
|
37
|
+
"https://public-api.prozorro.gov.ua/api/2.5/tenders/"
|
|
38
|
+
f"{GUID}\n",
|
|
39
|
+
)
|
|
40
|
+
public_api_link_mock.assert_called_once_with(TENDER_ID)
|
|
41
|
+
|
|
42
|
+
def test_link_html(self) -> None:
|
|
43
|
+
exit_code, output = self.run_cli("tender", TENDER_ID, "--link-html")
|
|
44
|
+
|
|
45
|
+
self.assertEqual(exit_code, 0)
|
|
46
|
+
self.assertEqual(
|
|
47
|
+
output,
|
|
48
|
+
f"https://prozorro.gov.ua/tender/{TENDER_ID}\n",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def test_linkhtml_alias(self) -> None:
|
|
52
|
+
exit_code, output = self.run_cli("tender", TENDER_ID, "--linkhtml")
|
|
53
|
+
|
|
54
|
+
self.assertEqual(exit_code, 0)
|
|
55
|
+
self.assertEqual(
|
|
56
|
+
output,
|
|
57
|
+
f"https://prozorro.gov.ua/tender/{TENDER_ID}\n",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@patch("prozorro_cli.cli.webbrowser.open", return_value=True)
|
|
61
|
+
@patch(
|
|
62
|
+
"prozorro_cli.cli.public_api_link",
|
|
63
|
+
return_value=(
|
|
64
|
+
"https://public-api.prozorro.gov.ua/api/2.5/tenders/"
|
|
65
|
+
f"{GUID}"
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
def test_link_open(
|
|
69
|
+
self,
|
|
70
|
+
public_api_link_mock,
|
|
71
|
+
browser_open_mock,
|
|
72
|
+
) -> None:
|
|
73
|
+
exit_code, output = self.run_cli(
|
|
74
|
+
"tender",
|
|
75
|
+
TENDER_ID,
|
|
76
|
+
"--link",
|
|
77
|
+
"--open",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
api_url = (
|
|
81
|
+
"https://public-api.prozorro.gov.ua/api/2.5/tenders/"
|
|
82
|
+
f"{GUID}"
|
|
83
|
+
)
|
|
84
|
+
self.assertEqual(exit_code, 0)
|
|
85
|
+
self.assertEqual(output, f"{api_url}\n")
|
|
86
|
+
public_api_link_mock.assert_called_once_with(TENDER_ID)
|
|
87
|
+
browser_open_mock.assert_called_once_with(api_url, new=2)
|
|
88
|
+
|
|
89
|
+
@patch("prozorro_cli.cli.webbrowser.open", return_value=True)
|
|
90
|
+
def test_link_html_open(self, browser_open_mock) -> None:
|
|
91
|
+
exit_code, output = self.run_cli(
|
|
92
|
+
"tender",
|
|
93
|
+
TENDER_ID,
|
|
94
|
+
"--linkhtml",
|
|
95
|
+
"--open",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
html_url = f"https://prozorro.gov.ua/tender/{TENDER_ID}"
|
|
99
|
+
self.assertEqual(exit_code, 0)
|
|
100
|
+
self.assertEqual(output, f"{html_url}\n")
|
|
101
|
+
browser_open_mock.assert_called_once_with(html_url, new=2)
|
|
102
|
+
|
|
103
|
+
def test_stream_configuration_is_safe_with_string_io(self) -> None:
|
|
104
|
+
exit_code, output = self.run_cli("tender", TENDER_ID, "--link-html")
|
|
105
|
+
|
|
106
|
+
self.assertEqual(exit_code, 0)
|
|
107
|
+
self.assertIn(TENDER_ID, output)
|
|
108
|
+
|
|
109
|
+
@patch("prozorro_cli.cli.resolve_guid", return_value=GUID)
|
|
110
|
+
def test_guid(self, resolve_guid_mock) -> None:
|
|
111
|
+
exit_code, output = self.run_cli("tender", TENDER_ID, "--guid")
|
|
112
|
+
|
|
113
|
+
self.assertEqual(exit_code, 0)
|
|
114
|
+
self.assertEqual(output, f"{GUID}\n")
|
|
115
|
+
resolve_guid_mock.assert_called_once_with(TENDER_ID)
|
|
116
|
+
|
|
117
|
+
@patch("prozorro_cli.cli.resolve_guid", return_value=GUID)
|
|
118
|
+
def test_guid_normal(self, resolve_guid_mock) -> None:
|
|
119
|
+
exit_code, output = self.run_cli("tender", TENDER_ID, "--guid-normal")
|
|
120
|
+
|
|
121
|
+
self.assertEqual(exit_code, 0)
|
|
122
|
+
self.assertEqual(output, "5d2590ef-8a1b-455f-8d09-ceeae474b21f\n")
|
|
123
|
+
resolve_guid_mock.assert_called_once_with(TENDER_ID)
|
|
124
|
+
|
|
125
|
+
@patch("prozorro_cli.cli.fetch_tender")
|
|
126
|
+
def test_default_prints_full_json(self, fetch_tender_mock) -> None:
|
|
127
|
+
fetch_tender_mock.return_value = {
|
|
128
|
+
"data": {
|
|
129
|
+
"id": GUID,
|
|
130
|
+
"tenderID": TENDER_ID,
|
|
131
|
+
"title": "Електрична енергія",
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
exit_code, output = self.run_cli("tender", TENDER_ID)
|
|
136
|
+
|
|
137
|
+
self.assertEqual(exit_code, 0)
|
|
138
|
+
self.assertEqual(json.loads(output)["data"]["id"], GUID)
|
|
139
|
+
self.assertIn("Електрична енергія", output)
|
|
140
|
+
|
|
141
|
+
@patch("prozorro_cli.cli.fetch_tender")
|
|
142
|
+
def test_default_passes_normal_uuid_to_client(self, fetch_tender_mock) -> None:
|
|
143
|
+
fetch_tender_mock.return_value = {
|
|
144
|
+
"data": {"id": GUID, "tenderID": TENDER_ID}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
exit_code, output = self.run_cli("tender", NORMAL_GUID)
|
|
148
|
+
|
|
149
|
+
self.assertEqual(exit_code, 0)
|
|
150
|
+
self.assertEqual(json.loads(output)["data"]["id"], GUID)
|
|
151
|
+
fetch_tender_mock.assert_called_once_with(NORMAL_GUID)
|
|
152
|
+
|
|
153
|
+
@patch("prozorro_cli.cli.download_documents")
|
|
154
|
+
def test_documents_downloads_to_output(self, download_documents_mock) -> None:
|
|
155
|
+
download_documents_mock.return_value = [
|
|
156
|
+
"/temp/specification.pdf",
|
|
157
|
+
"/temp/contract.docx",
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
exit_code, output = self.run_cli(
|
|
161
|
+
"documents",
|
|
162
|
+
TENDER_ID,
|
|
163
|
+
"--output",
|
|
164
|
+
"/temp",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
self.assertEqual(exit_code, 0)
|
|
168
|
+
self.assertEqual(
|
|
169
|
+
output,
|
|
170
|
+
"/temp/specification.pdf\n"
|
|
171
|
+
"/temp/contract.docx\n"
|
|
172
|
+
"Завантажено документів: 2\n",
|
|
173
|
+
)
|
|
174
|
+
download_documents_mock.assert_called_once_with(TENDER_ID, "/temp")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
unittest.main()
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import unittest
|
|
4
|
+
from io import BytesIO
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tempfile import TemporaryDirectory
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
from prozorro_cli.client import (
|
|
10
|
+
ProzorroError,
|
|
11
|
+
compact_guid,
|
|
12
|
+
download_document,
|
|
13
|
+
download_documents,
|
|
14
|
+
fetch_tender,
|
|
15
|
+
normal_guid,
|
|
16
|
+
parse_tender_reference,
|
|
17
|
+
public_api_link,
|
|
18
|
+
resolve_guid,
|
|
19
|
+
resolve_tender_id,
|
|
20
|
+
safe_document_filename,
|
|
21
|
+
tender_link,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
TENDER_ID = "UA-2026-06-15-003439-a"
|
|
26
|
+
GUID = "5d2590ef8a1b455f8d09ceeae474b21f"
|
|
27
|
+
NORMAL_GUID = "5d2590ef-8a1b-455f-8d09-ceeae474b21f"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ClientTests(unittest.TestCase):
|
|
31
|
+
def test_tender_link(self) -> None:
|
|
32
|
+
self.assertEqual(
|
|
33
|
+
tender_link(TENDER_ID),
|
|
34
|
+
f"https://prozorro.gov.ua/tender/{TENDER_ID}",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
@patch("prozorro_cli.client.fetch_json")
|
|
38
|
+
def test_public_api_link_from_tender_id(self, fetch_json_mock) -> None:
|
|
39
|
+
fetch_json_mock.return_value = {"id": GUID, "tenderID": TENDER_ID}
|
|
40
|
+
|
|
41
|
+
self.assertEqual(
|
|
42
|
+
public_api_link(TENDER_ID),
|
|
43
|
+
f"https://public-api.prozorro.gov.ua/api/2.5/tenders/{GUID}",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@patch("prozorro_cli.client.fetch_json")
|
|
47
|
+
def test_public_api_link_from_uuid_skips_network(self, fetch_json_mock) -> None:
|
|
48
|
+
self.assertEqual(
|
|
49
|
+
public_api_link(NORMAL_GUID),
|
|
50
|
+
f"https://public-api.prozorro.gov.ua/api/2.5/tenders/{GUID}",
|
|
51
|
+
)
|
|
52
|
+
fetch_json_mock.assert_not_called()
|
|
53
|
+
|
|
54
|
+
def test_invalid_reference_is_rejected(self) -> None:
|
|
55
|
+
with self.assertRaisesRegex(ProzorroError, "Очікується UA-ID"):
|
|
56
|
+
tender_link("not-a-tender")
|
|
57
|
+
|
|
58
|
+
def test_normal_guid(self) -> None:
|
|
59
|
+
self.assertEqual(normal_guid(GUID), NORMAL_GUID)
|
|
60
|
+
|
|
61
|
+
def test_compact_guid_accepts_normal_uuid(self) -> None:
|
|
62
|
+
self.assertEqual(compact_guid(NORMAL_GUID), GUID)
|
|
63
|
+
|
|
64
|
+
def test_parse_tender_page_links(self) -> None:
|
|
65
|
+
for url in (
|
|
66
|
+
f"https://prozorro.gov.ua/tender/{TENDER_ID}",
|
|
67
|
+
f"https://prozorro.gov.ua/uk/tender/{TENDER_ID}",
|
|
68
|
+
f"https://prozorro.gov.ua/en/tender/{TENDER_ID}?source=test",
|
|
69
|
+
):
|
|
70
|
+
with self.subTest(url=url):
|
|
71
|
+
self.assertEqual(parse_tender_reference(url).tender_id, TENDER_ID)
|
|
72
|
+
|
|
73
|
+
def test_parse_summary_link(self) -> None:
|
|
74
|
+
reference = parse_tender_reference(
|
|
75
|
+
f"https://prozorro.gov.ua/api/tenders/{TENDER_ID}/summary"
|
|
76
|
+
)
|
|
77
|
+
self.assertEqual(reference.tender_id, TENDER_ID)
|
|
78
|
+
|
|
79
|
+
def test_parse_public_api_links(self) -> None:
|
|
80
|
+
for guid in (GUID, NORMAL_GUID):
|
|
81
|
+
url = f"https://public-api.prozorro.gov.ua/api/2.5/tenders/{guid}"
|
|
82
|
+
with self.subTest(url=url):
|
|
83
|
+
self.assertEqual(parse_tender_reference(url).guid, GUID)
|
|
84
|
+
|
|
85
|
+
def test_unsupported_link_is_rejected(self) -> None:
|
|
86
|
+
with self.assertRaisesRegex(ProzorroError, "підтримуваний тендер"):
|
|
87
|
+
parse_tender_reference("https://prozorro.gov.ua/plans/example")
|
|
88
|
+
|
|
89
|
+
@patch("prozorro_cli.client.fetch_json")
|
|
90
|
+
def test_resolve_guid_uses_summary_endpoint(self, fetch_json_mock) -> None:
|
|
91
|
+
fetch_json_mock.return_value = {"id": GUID, "tenderID": TENDER_ID}
|
|
92
|
+
|
|
93
|
+
self.assertEqual(resolve_guid(TENDER_ID), GUID)
|
|
94
|
+
fetch_json_mock.assert_called_once_with(
|
|
95
|
+
f"https://prozorro.gov.ua/api/tenders/{TENDER_ID}/summary",
|
|
96
|
+
timeout=30.0,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@patch("prozorro_cli.client.fetch_json")
|
|
100
|
+
def test_resolve_guid_from_uuid_does_not_call_network(
|
|
101
|
+
self, fetch_json_mock
|
|
102
|
+
) -> None:
|
|
103
|
+
self.assertEqual(resolve_guid(NORMAL_GUID), GUID)
|
|
104
|
+
fetch_json_mock.assert_not_called()
|
|
105
|
+
|
|
106
|
+
@patch("prozorro_cli.client.fetch_json")
|
|
107
|
+
def test_resolve_guid_rejects_missing_id(self, fetch_json_mock) -> None:
|
|
108
|
+
fetch_json_mock.return_value = {"tenderID": TENDER_ID}
|
|
109
|
+
|
|
110
|
+
with self.assertRaisesRegex(ProzorroError, "внутрішнього id"):
|
|
111
|
+
resolve_guid(TENDER_ID)
|
|
112
|
+
|
|
113
|
+
@patch("prozorro_cli.client.fetch_json")
|
|
114
|
+
def test_fetch_tender_uses_resolved_guid(self, fetch_json_mock) -> None:
|
|
115
|
+
fetch_json_mock.side_effect = [
|
|
116
|
+
{"id": GUID, "tenderID": TENDER_ID},
|
|
117
|
+
{"data": {"id": GUID, "tenderID": TENDER_ID}},
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
payload = fetch_tender(TENDER_ID)
|
|
121
|
+
|
|
122
|
+
self.assertEqual(payload["data"]["id"], GUID)
|
|
123
|
+
self.assertEqual(
|
|
124
|
+
fetch_json_mock.call_args_list[1].args[0],
|
|
125
|
+
f"https://public-api.prozorro.gov.ua/api/2.5/tenders/{GUID}",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@patch("prozorro_cli.client.fetch_json")
|
|
129
|
+
def test_fetch_tender_by_guid_skips_summary(self, fetch_json_mock) -> None:
|
|
130
|
+
fetch_json_mock.return_value = {
|
|
131
|
+
"data": {"id": GUID, "tenderID": TENDER_ID}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
payload = fetch_tender(NORMAL_GUID)
|
|
135
|
+
|
|
136
|
+
self.assertEqual(payload["data"]["id"], GUID)
|
|
137
|
+
fetch_json_mock.assert_called_once_with(
|
|
138
|
+
f"https://public-api.prozorro.gov.ua/api/2.5/tenders/{GUID}",
|
|
139
|
+
timeout=30.0,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@patch("prozorro_cli.client.fetch_json")
|
|
143
|
+
def test_resolve_tender_id_from_guid(self, fetch_json_mock) -> None:
|
|
144
|
+
fetch_json_mock.return_value = {
|
|
145
|
+
"data": {"id": GUID, "tenderID": TENDER_ID}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
self.assertEqual(resolve_tender_id(GUID), TENDER_ID)
|
|
149
|
+
|
|
150
|
+
@patch("prozorro_cli.client.fetch_json")
|
|
151
|
+
def test_tender_link_from_guid(self, fetch_json_mock) -> None:
|
|
152
|
+
fetch_json_mock.return_value = {
|
|
153
|
+
"data": {"id": GUID, "tenderID": TENDER_ID}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
self.assertEqual(
|
|
157
|
+
tender_link(GUID),
|
|
158
|
+
f"https://prozorro.gov.ua/tender/{TENDER_ID}",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def test_safe_document_filename(self) -> None:
|
|
162
|
+
self.assertEqual(
|
|
163
|
+
safe_document_filename('contract: final?.pdf', fallback="document-1"),
|
|
164
|
+
"contract_ final_.pdf",
|
|
165
|
+
)
|
|
166
|
+
self.assertEqual(
|
|
167
|
+
safe_document_filename("CON", fallback="document-1"),
|
|
168
|
+
"_CON",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
@patch("prozorro_cli.client.urlopen")
|
|
172
|
+
def test_download_document_writes_response_bytes(self, urlopen_mock) -> None:
|
|
173
|
+
response = BytesIO(b"document contents")
|
|
174
|
+
urlopen_mock.return_value.__enter__.return_value = response
|
|
175
|
+
|
|
176
|
+
with TemporaryDirectory() as temporary_directory:
|
|
177
|
+
destination = Path(temporary_directory) / "document.pdf"
|
|
178
|
+
|
|
179
|
+
download_document(
|
|
180
|
+
"https://public-docs.prozorro.gov.ua/document",
|
|
181
|
+
destination,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
self.assertEqual(destination.read_bytes(), b"document contents")
|
|
185
|
+
|
|
186
|
+
@patch("prozorro_cli.client.download_document")
|
|
187
|
+
@patch("prozorro_cli.client.fetch_tender")
|
|
188
|
+
def test_download_documents_uses_data_documents(
|
|
189
|
+
self,
|
|
190
|
+
fetch_tender_mock,
|
|
191
|
+
download_document_mock,
|
|
192
|
+
) -> None:
|
|
193
|
+
download_document_mock.side_effect = (
|
|
194
|
+
lambda _url, destination, **_kwargs: destination.touch()
|
|
195
|
+
)
|
|
196
|
+
fetch_tender_mock.return_value = {
|
|
197
|
+
"data": {
|
|
198
|
+
"documents": [
|
|
199
|
+
{
|
|
200
|
+
"id": "first-id",
|
|
201
|
+
"title": "specification.pdf",
|
|
202
|
+
"url": "https://public-docs.prozorro.gov.ua/specification",
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"id": "second-id",
|
|
206
|
+
"title": "specification.pdf",
|
|
207
|
+
"url": "https://public-docs.prozorro.gov.ua/contract",
|
|
208
|
+
},
|
|
209
|
+
]
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
with TemporaryDirectory() as temporary_directory:
|
|
214
|
+
first_existing = Path(temporary_directory) / "specification.pdf"
|
|
215
|
+
first_existing.write_bytes(b"existing")
|
|
216
|
+
|
|
217
|
+
result = download_documents(TENDER_ID, temporary_directory)
|
|
218
|
+
|
|
219
|
+
self.assertEqual(
|
|
220
|
+
[path.name for path in result],
|
|
221
|
+
["specification (2).pdf", "specification (3).pdf"],
|
|
222
|
+
)
|
|
223
|
+
self.assertEqual(download_document_mock.call_count, 2)
|
|
224
|
+
|
|
225
|
+
@patch("prozorro_cli.client.fetch_tender")
|
|
226
|
+
def test_download_documents_requires_document_url(
|
|
227
|
+
self,
|
|
228
|
+
fetch_tender_mock,
|
|
229
|
+
) -> None:
|
|
230
|
+
fetch_tender_mock.return_value = {
|
|
231
|
+
"data": {"documents": [{"title": "missing-url.pdf"}]}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
with TemporaryDirectory() as temporary_directory:
|
|
235
|
+
with self.assertRaisesRegex(ProzorroError, "не містить url"):
|
|
236
|
+
download_documents(TENDER_ID, temporary_directory)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
unittest.main()
|