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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ """Command-line client for public Prozorro tender data."""
2
+
3
+ __version__ = "0.1.0"
4
+
@@ -0,0 +1,6 @@
1
+ from prozorro_cli.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ prozorro-cli = prozorro_cli.cli:main
@@ -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()