prozorro-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prozorro_cli/__init__.py +4 -0
- prozorro_cli/__main__.py +6 -0
- prozorro_cli/cli.py +141 -0
- prozorro_cli/client.py +297 -0
- prozorro_cli-0.1.0.dist-info/METADATA +111 -0
- prozorro_cli-0.1.0.dist-info/RECORD +10 -0
- prozorro_cli-0.1.0.dist-info/WHEEL +5 -0
- prozorro_cli-0.1.0.dist-info/entry_points.txt +2 -0
- prozorro_cli-0.1.0.dist-info/licenses/LICENSE +22 -0
- prozorro_cli-0.1.0.dist-info/top_level.txt +1 -0
prozorro_cli/__init__.py
ADDED
prozorro_cli/__main__.py
ADDED
prozorro_cli/cli.py
ADDED
|
@@ -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
|
prozorro_cli/client.py
ADDED
|
@@ -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,10 @@
|
|
|
1
|
+
prozorro_cli/__init__.py,sha256=f5nEXYxLx9K2LTCu7O7npVNkD1Zfqjxxp5cAD6Ms5ng,83
|
|
2
|
+
prozorro_cli/__main__.py,sha256=OqX5QqcJXR9KkCRi-aDOVAycnswOmMg2RXBioMVlXsg,93
|
|
3
|
+
prozorro_cli/cli.py,sha256=-oDmywxy0OME6ErnuaXly1ba9zqP5g-kXuyLGhxS2aQ,4534
|
|
4
|
+
prozorro_cli/client.py,sha256=9ryPqD9E9IClDCiTCdKn4JlsbSGUO6UFhTDa9SuEGzw,10651
|
|
5
|
+
prozorro_cli-0.1.0.dist-info/licenses/LICENSE,sha256=hGKdMD9xcC-tN4dm1tdMvQdjvxz6YMJo-H1sCeJukRo,1083
|
|
6
|
+
prozorro_cli-0.1.0.dist-info/METADATA,sha256=KsBwBtA_s2VowYVH5BgABXGzU-57ULB41TsTYcwvhpo,4678
|
|
7
|
+
prozorro_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
prozorro_cli-0.1.0.dist-info/entry_points.txt,sha256=cXh2mNyxCc2b8lqzDallHiqaMGDdLEPxBatQo1faWk0,55
|
|
9
|
+
prozorro_cli-0.1.0.dist-info/top_level.txt,sha256=OJy-Q0hwUQTUCgd5LW3Fipk1zTIkW4mj_VuvD35pMWQ,13
|
|
10
|
+
prozorro_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
prozorro_cli
|