tenderkit 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.
Files changed (49) hide show
  1. tenderkit/__init__.py +9 -0
  2. tenderkit/__main__.py +5 -0
  3. tenderkit/cli.py +248 -0
  4. tenderkit/documents.py +260 -0
  5. tenderkit/errors.py +52 -0
  6. tenderkit/http.py +94 -0
  7. tenderkit/interfaces.py +42 -0
  8. tenderkit/limits.py +7 -0
  9. tenderkit/mcp.py +221 -0
  10. tenderkit/model.py +78 -0
  11. tenderkit/pdf_ocr.py +78 -0
  12. tenderkit/platforms/__init__.py +33 -0
  13. tenderkit/platforms/backends.py +163 -0
  14. tenderkit/platforms/base.py +79 -0
  15. tenderkit/platforms/bk.py +78 -0
  16. tenderkit/platforms/ezamowienia.py +85 -0
  17. tenderkit/platforms/html.py +67 -0
  18. tenderkit/platforms/marketplanet_html.py +128 -0
  19. tenderkit/platforms/public_documents.py +206 -0
  20. tenderkit/platforms/pz_html.py +127 -0
  21. tenderkit/platforms/session.py +114 -0
  22. tenderkit/profile.py +216 -0
  23. tenderkit/profiles/__init__.py +13 -0
  24. tenderkit/profiles/leki.toml +5 -0
  25. tenderkit/profiles/oprogramowanie.toml +4 -0
  26. tenderkit/profiles/wyroby-medyczne.toml +4 -0
  27. tenderkit/queue.py +126 -0
  28. tenderkit/read_document.py +309 -0
  29. tenderkit/serialization.py +63 -0
  30. tenderkit/service.py +164 -0
  31. tenderkit/sources/__init__.py +1 -0
  32. tenderkit/sources/bk.py +145 -0
  33. tenderkit/sources/bzp.py +142 -0
  34. tenderkit/sources/mapping.py +264 -0
  35. tenderkit/sources/ted.py +116 -0
  36. tenderkit/sources/ted_xml.py +97 -0
  37. tenderkit/store/__init__.py +1 -0
  38. tenderkit/store/documents.py +168 -0
  39. tenderkit/store/state.py +202 -0
  40. tenderkit/ui.py +243 -0
  41. tenderkit/watch.py +247 -0
  42. tenderkit/web/index.html +59 -0
  43. tenderkit/web/style.css +221 -0
  44. tenderkit-0.1.0.dist-info/METADATA +219 -0
  45. tenderkit-0.1.0.dist-info/RECORD +49 -0
  46. tenderkit-0.1.0.dist-info/WHEEL +5 -0
  47. tenderkit-0.1.0.dist-info/entry_points.txt +2 -0
  48. tenderkit-0.1.0.dist-info/licenses/LICENSE +202 -0
  49. tenderkit-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,264 @@
1
+ """Source-specific fields to the industry-independent Notice model."""
2
+ from collections import defaultdict
3
+ from datetime import date, datetime
4
+ from decimal import Decimal, InvalidOperation
5
+ import html
6
+ import re
7
+ from zoneinfo import ZoneInfo
8
+
9
+ from tenderkit.model import Award, Lot, Notice
10
+
11
+ REFERENCE = re.compile(r'4\.1\.2\.\)\s*Numer referencyjny:\s*(.+?)\s+\d\.\d\.\d+\.\)')
12
+ PLATFORM_URL = re.compile(r'Adres strony internetowej prowadzonego post.powania.{0,300}?(https?://\s*\S+|www\.\S+)')
13
+ CPV = re.compile(r'\b(\d{8})(?:-\d)?\b')
14
+ WARSAW = ZoneInfo('Europe/Warsaw')
15
+ # NUTS level 2 -> TERYT voivodeship. Warsaw and the surrounding region share 14.
16
+ NUTS_TERYT = dict(zip(
17
+ ('PL21', 'PL22', 'PL41', 'PL42', 'PL43', 'PL51', 'PL52', 'PL61', 'PL62', 'PL63', 'PL71', 'PL72', 'PL81', 'PL82', 'PL84', 'PL91', 'PL92'),
18
+ ('12', '24', '30', '32', '08', '02', '16', '04', '28', '22', '10', '26', '06', '18', '20', '14', '14')))
19
+
20
+
21
+ def region(value):
22
+ if not value:
23
+ return None
24
+ value = str(value)
25
+ if re.fullmatch(r'PL\d{2}', value):
26
+ return value[2:]
27
+ if re.fullmatch(r'\d{2}', value):
28
+ return value
29
+ return None
30
+
31
+
32
+ def ted_region(value):
33
+ value = first(value)
34
+ return NUTS_TERYT.get(str(value)[:4]) if value else None
35
+
36
+
37
+ def html_to_text(value):
38
+ return re.sub(r'\s+', ' ', re.sub(r'<[^>]+>', ' ', html.unescape(value or '')))
39
+
40
+
41
+ def amount(value):
42
+ if isinstance(value, list):
43
+ value = value[0] if value else None
44
+ try:
45
+ number = Decimal(re.sub(r'\s', '', str(value)).replace(',', '.'))
46
+ return number if number.is_finite() and number > 0 else None
47
+ except InvalidOperation:
48
+ return None
49
+
50
+
51
+ def day(value):
52
+ return date.fromisoformat(value[:10]) if value else None
53
+
54
+
55
+ def instant(value):
56
+ if not value:
57
+ return None
58
+ # TED returns date-only fields with a timezone suffix, e.g. 2026-10-21+02:00.
59
+ if len(value) <= 16 and 'T' not in value:
60
+ return datetime.fromisoformat(value[:10] + 'T00:00:00' + value[10:])
61
+ return datetime.fromisoformat(value.replace('Z', '+00:00'))
62
+
63
+
64
+ def bzp_publication_day(value):
65
+ published = instant(value)
66
+ if published is None:
67
+ return None
68
+ if published.tzinfo is not None:
69
+ published = published.astimezone(WARSAW)
70
+ return published.date()
71
+
72
+
73
+ def polish(value):
74
+ if isinstance(value, str):
75
+ return value
76
+ if not isinstance(value, dict) or not value:
77
+ return None
78
+ text = value.get('pol') or next(iter(value.values()))
79
+ return text[0] if isinstance(text, list) and text else text or None
80
+
81
+
82
+ def first(value):
83
+ return value[0] if isinstance(value, list) and value else value if not isinstance(value, list) else None
84
+
85
+
86
+ def bzp_notice(raw):
87
+ body = raw.get('htmlBody')
88
+ text = html_to_text(body)
89
+ reference, platform = REFERENCE.search(text), PLATFORM_URL.search(text)
90
+ value = re.search(r'Wartość zamówienia:\s*(\d[\d\s]*(?:,\d+)?)\s*([A-Z]{3})\b', text)
91
+ description, lots = bzp_scope(body)
92
+ return Notice(
93
+ source='BZP', number=raw['noticeNumber'], procedure_id=raw.get('tenderId'),
94
+ published=bzp_publication_day(raw.get('publicationDate')), deadline=instant(raw.get('submittingOffersDate')),
95
+ title=raw.get('orderObject'), buyer_name=raw.get('organizationName'),
96
+ buyer_id=raw.get('organizationNationalId'), buyer_city=raw.get('organizationCity'),
97
+ buyer_region=region(raw.get('organizationProvince')),
98
+ cpv=tuple(dict.fromkeys(CPV.findall(raw.get('cpvCode') or ''))),
99
+ value=amount(value[1]) if value else None, currency=value[2] if value and amount(value[1]) else None,
100
+ reference=reference[1].strip() if reference else None,
101
+ platform_url=re.sub(r'\s+', '', platform[1]) if platform else None,
102
+ url=f"https://ezamowienia.gov.pl/mo-client-board/bzp/notice-details/id/{raw['objectId']}",
103
+ notice_type=raw.get('noticeType'), raw=raw,
104
+ description=description, lots=lots,
105
+ awards=bzp_awards(raw),
106
+ lot_count=int(re.search(r'Liczba części:\s*(\d+)', text)[1]) if re.search(r'Liczba części:\s*(\d+)', text)
107
+ else 1 if re.search(r'Możliwe jest składanie ofert częściowych:\s*Nie', text) else None,
108
+ )
109
+
110
+
111
+ def bzp_amendment(raw):
112
+ text = html_to_text(raw.get('htmlBody'))
113
+ target = re.search(r'Numer zmienianego ogłoszenia w BZP:\s*(\d{4}/BZP \d+)(?:/(\d+))?', text)
114
+ if not target:
115
+ return None
116
+ version = re.search(r'Identyfikator ostatniej wersji zmienianego ogłoszenia:\s*(\d+)', text)
117
+ target_number = f'{target[1]}/{target[2] or (version[1] if version else "01")}'
118
+ deadline = None
119
+ for label, value in bzp_blocks(raw.get('htmlBody')):
120
+ if label.startswith('3.4.1.') and re.match(r'8\.1\.\)?\s*Termin składania ofert\b', value):
121
+ updated = re.search(r'Po zmianie:\s*(\d{4}-\d{2}-\d{2} \d{2}:\d{2}(?::\d{2})?)', value)
122
+ if updated:
123
+ deadline = datetime.fromisoformat(updated[1]).replace(tzinfo=WARSAW)
124
+ return Notice('BZP', raw['noticeNumber'], procedure_id=raw.get('tenderId'),
125
+ published=bzp_publication_day(raw.get('publicationDate')), deadline=deadline, title=raw.get('orderObject'),
126
+ url=f"https://ezamowienia.gov.pl/mo-client-board/bzp/notice-details/id/{raw['objectId']}",
127
+ notice_type=raw.get('noticeType'), raw={'amends': target_number})
128
+
129
+
130
+ def bzp_blocks(body, labels=None):
131
+ """(label, value) pairs of one BZP HTML body, the same h3 layout the reader uses."""
132
+ if not body:
133
+ return []
134
+ main = re.search(r'<main\b[^>]*>(.*?)</main>', body, re.S | re.I)
135
+ if main:
136
+ body = main[1]
137
+ found = []
138
+ for heading, value in re.findall(r'<h3\b[^>]*>(.*?)</h3>(.*?)(?=<h[23]\b|</main>|</body>|$)', body, re.S | re.I):
139
+ if labels is not None and not labels.search(heading):
140
+ continue
141
+ inline = re.findall(r'<span\b[^>]*>(.*?)</span>', heading, re.S | re.I)
142
+ label = re.sub(r'<span\b[^>]*>.*?</span>', '', heading, flags=re.S | re.I)
143
+ found.append((html_to_text(label).strip(), html_to_text(' '.join(inline) + ' ' + value).strip()))
144
+ return found
145
+
146
+
147
+ _SCOPE_LABELS = re.compile(r'Krótki opis przedmiotu zamówienia|Część\s+\d|Nazwa części', re.I)
148
+
149
+
150
+ def bzp_scope(body):
151
+ descriptions, titles = [], {}
152
+ lot_number = None
153
+ for label, value in bzp_blocks(body, _SCOPE_LABELS):
154
+ lot = re.fullmatch(r'Część\s+(\d+)(?:\s*[-–—:]\s*(.+))?', label, re.I)
155
+ if lot:
156
+ lot_number = lot[1]
157
+ if lot[2]:
158
+ titles[lot_number] = lot[2]
159
+ elif 'krótki opis przedmiotu zamówienia' in label.casefold() and value:
160
+ descriptions.append(value)
161
+ elif lot_number is not None and 'nazwa części' in label.casefold() and value:
162
+ titles[lot_number] = value
163
+ return '\n\n'.join(dict.fromkeys(descriptions)) or None, tuple(
164
+ Lot(number, title=title) for number, title in titles.items())
165
+
166
+
167
+ def bzp_fields(body):
168
+ fields = defaultdict(list)
169
+ for label, value in bzp_blocks(body):
170
+ code = re.match(r'^(\d+(?:\.\d+)*)\.?\)', label)
171
+ if code:
172
+ fields[code[1]].append((label, value))
173
+ return fields
174
+
175
+
176
+ def _one(fields, code):
177
+ entries = fields.get(code, [])
178
+ return entries[0][1] if len(entries) == 1 else None
179
+
180
+
181
+ def _bid_amount(fields, code):
182
+ value = _one(fields, code)
183
+ matched = re.fullmatch(r'\s*([\d\s\u00a0]+,\d{2})\s+(PLN)\s*', value or '')
184
+ if not matched:
185
+ return None, None
186
+ return Decimal(re.sub(r'\s+', '', matched[1]).replace(',', '.')), matched[2]
187
+
188
+
189
+ def bzp_awards(raw):
190
+ """Formal per-lot results from a TenderResultNotice; unparsed sections yield no award, never a guess."""
191
+ if raw.get('noticeType') != 'TenderResultNotice':
192
+ return ()
193
+ body = raw.get('htmlBody') or ''
194
+ sections = [match for match in re.finditer(r'<h2\b[^>]*>(.*?)</h2>', body, re.I | re.S)
195
+ if re.search(r'\bSEKCJA\s+V\b', match[1])]
196
+ published = bzp_publication_day(raw.get('publicationDate'))
197
+ awards = []
198
+ for index, section in enumerate(sections):
199
+ heading = html_to_text(section[1]).strip()
200
+ content = body[section.end():sections[index + 1].start() if index + 1 < len(sections) else len(body)]
201
+ fields = bzp_fields(content)
202
+ status = _one(fields, '5.1')
203
+ if not status:
204
+ continue
205
+ lot = re.search(r'dla części\s+(\d+)(?![\w])', heading, re.I)
206
+ lot = lot[1] if lot else '__all__'
207
+ normalized = status.casefold()
208
+ if 'unieważn' in normalized and 'zawarciem umowy' not in normalized:
209
+ awards.append(Award(lot=lot, status='CANCELLED', offers=_offer_count(fields), date=published))
210
+ elif 'zawarciem umowy' in normalized:
211
+ winner = _one(fields, '7.3.1')
212
+ if _one(fields, '7.1') != 'Nie':
213
+ continue # consortium or unknown structure: do not invent a single contractor
214
+ price, currency = _bid_amount(fields, '6.4')
215
+ awards.append(Award(lot=lot, status='AWARDED', contractor=winner,
216
+ contractor_id=_one(fields, '7.3.2'), price=price, currency=currency,
217
+ offers=_offer_count(fields), date=published))
218
+ return tuple(awards)
219
+
220
+
221
+ def _offer_count(fields):
222
+ count = _one(fields, '6.1')
223
+ return int(count) if count and count.isdigit() else None
224
+
225
+
226
+ def _ted_texts(value):
227
+ if isinstance(value, dict):
228
+ value = value.get('pol') or next(iter(value.values()), None)
229
+ texts = value if isinstance(value, list) else [value]
230
+ return tuple(text.strip() for text in texts if isinstance(text, str) and text.strip())
231
+
232
+
233
+ def ted_scope(raw):
234
+ descriptions = _ted_texts(raw.get('description-proc')) + _ted_texts(raw.get('description-lot'))
235
+ return '\n\n'.join(dict.fromkeys(descriptions)) or None, tuple(dict.fromkeys(_ted_texts(raw.get('title-lot'))))
236
+
237
+
238
+ def ted_notice(raw):
239
+ ids = raw.get('identifier-lot') or []
240
+ values, currencies = raw.get('estimated-value-lot') or [], raw.get('estimated-value-cur-lot') or []
241
+ value, currency = amount(raw.get('estimated-value-proc')), first(raw.get('estimated-value-cur-proc'))
242
+ if value is None and values and all(amount(v) is not None for v in values) and len(set(currencies)) == 1:
243
+ value, currency = sum(amount(v) for v in values), currencies[0]
244
+ deadlines = sorted(d for d in raw.get('deadline-receipt-tender-date-lot') or [] if d)
245
+ urls = [u for u in raw.get('document-url-lot') or [] if u]
246
+ description, lot_titles = ted_scope(raw)
247
+ return Notice(
248
+ source='TED', number=raw['publication-number'], procedure_id=first(raw.get('procedure-identifier')),
249
+ published=day(raw.get('publication-date')), deadline=instant(deadlines[0]) if deadlines else None,
250
+ title=polish(raw.get('title-proc')) or polish(raw.get('notice-title')),
251
+ buyer_name=polish(raw.get('buyer-name')), buyer_city=first(raw.get('organisation-city-buyer')),
252
+ buyer_id=first(raw.get('organisation-identifier-buyer')),
253
+ buyer_region=ted_region(raw.get('organisation-country-sub-buyer')),
254
+ cpv=tuple(dict.fromkeys(str(c).split('-')[0] for c in raw.get('classification-cpv') or [])),
255
+ value=value, currency=currency if value else None,
256
+ # Search API arrays are flattened, without a join between each amount
257
+ # and its lot id. Keep the IDs; never infer per-lot amounts by position.
258
+ lots=tuple(Lot(str(number)) for number in ids),
259
+ description=description, lot_titles=lot_titles,
260
+ reference=first(raw.get('internal-identifier-proc')), platform_url=urls[0] if urls else None,
261
+ url=f"https://ted.europa.eu/pl/notice/-/detail/{raw['publication-number']}",
262
+ notice_type=first(raw.get('notice-type')) or 'cn-standard', raw=raw,
263
+ lot_count=len(ids) or None,
264
+ )
@@ -0,0 +1,116 @@
1
+ """TED Search API v3 with bounded pagination and completeness checks."""
2
+ from dataclasses import replace
3
+ import re
4
+ import time
5
+
6
+ from tenderkit.http import Session
7
+ from tenderkit.sources.mapping import ted_notice
8
+
9
+ TED_SEARCH = 'https://api.ted.europa.eu/v3/notices/search'
10
+ TED_XML = 'https://ted.europa.eu/en/notice/{number}/xml'
11
+ # Search API fields only; the award XML below carries the per-lot join.
12
+ TED_FIELDS = ['publication-number', 'publication-date', 'procedure-identifier', 'title-proc', 'notice-title',
13
+ 'buyer-name', 'organisation-city-buyer', 'internal-identifier-proc', 'document-url-lot',
14
+ 'deadline-receipt-tender-date-lot', 'classification-cpv', 'identifier-lot', 'estimated-value-proc',
15
+ 'estimated-value-cur-proc', 'estimated-value-lot', 'estimated-value-cur-lot',
16
+ 'organisation-country-sub-buyer', 'organisation-identifier-buyer', 'notice-type',
17
+ 'notice-identifier', 'notice-version', 'change-notice-version-identifier',
18
+ 'description-proc', 'description-lot', 'title-lot']
19
+ NOTICE_KINDS = ('cn-standard', 'cn-social', 'pin-cfc-standard')
20
+ RESULT_KINDS = ('can-standard',)
21
+ SEARCH_KINDS = NOTICE_KINDS + RESULT_KINDS
22
+ MAX_AWARD_XML = 1000
23
+
24
+
25
+ class TedIncomplete(RuntimeError):
26
+ """The source did not return exactly its declared set of publications."""
27
+
28
+
29
+ def cpv_condition(codes):
30
+ if any(not re.fullmatch(r'\d{1,8}\*?', code) for code in codes):
31
+ raise ValueError('Invalid CPV')
32
+ terms = [f'classification-cpv={code}' for code in codes]
33
+ return terms[0] if len(terms) == 1 else '(' + ' OR '.join(terms) + ')' if terms else ''
34
+
35
+
36
+ def fetch_notices(session, start, end, codes=(), kinds=('cn-standard',), *, mapper=ted_notice,
37
+ max_pages=100, delay=1.0):
38
+ if start > end or not 1 <= max_pages <= 100:
39
+ raise ValueError('Invalid TED window or pagination limit')
40
+ if not kinds:
41
+ return []
42
+ if any(not re.fullmatch(r'[a-z][a-z0-9-]{0,50}', k) for k in kinds):
43
+ raise ValueError('Invalid TED notice type')
44
+ terms = ['buyer-country=POL', cpv_condition(codes)]
45
+ kinds_query = [f'notice-type={k}' for k in kinds]
46
+ terms.append(kinds_query[0] if len(kinds_query) == 1 else '(' + ' OR '.join(kinds_query) + ')')
47
+ terms += [f'publication-date>={start:%Y%m%d}', f'publication-date<={end:%Y%m%d}']
48
+ query = ' AND '.join(t for t in terms if t)
49
+ rows, seen, expected = [], set(), None
50
+ for page in range(1, max_pages + 1):
51
+ for attempt in range(6):
52
+ response = session.post(TED_SEARCH, json=dict(query=query, fields=TED_FIELDS, limit=250, page=page), timeout=90)
53
+ if response.status_code != 429:
54
+ break
55
+ time.sleep(10 * (attempt + 1))
56
+ response.raise_for_status()
57
+ body = response.json()
58
+ batch, total = body['notices'], body.get('totalNoticeCount')
59
+ if not isinstance(batch, list):
60
+ raise ValueError('Expected TED notices array')
61
+ if total is not None:
62
+ if not isinstance(total, int) or total < 0 or expected is not None and total != expected:
63
+ raise TedIncomplete('TED total changed during pagination')
64
+ expected = total
65
+ if expected > 15000:
66
+ raise TedIncomplete('TED pagination supports 15000 notices; use a narrower window or profile')
67
+ for raw in batch:
68
+ number = raw['publication-number']
69
+ if number in seen:
70
+ raise TedIncomplete('TED repeated a publication during pagination')
71
+ seen.add(number)
72
+ rows.append(mapper(raw))
73
+ if expected is not None and len(rows) == expected:
74
+ return rows
75
+ if len(batch) < 250:
76
+ if expected is not None and len(rows) != expected:
77
+ raise TedIncomplete(f'{len(rows)} notices fetched, TED counts {expected}')
78
+ return rows
79
+ time.sleep(delay)
80
+ raise TedIncomplete('TED pagination safety limit reached')
81
+
82
+
83
+ class Ted:
84
+ name = 'TED'
85
+
86
+ def __init__(self, session=None, xml_delay=1.0):
87
+ self.session = session or Session()
88
+ self.xml_delay = xml_delay
89
+
90
+ def fetch(self, start, end, query):
91
+ known = tuple(k for k in query.notice_types if k in SEARCH_KINDS)
92
+ kinds = known or NOTICE_KINDS
93
+ if query.include_results:
94
+ kinds = kinds + tuple(k for k in RESULT_KINDS if k not in kinds)
95
+ # SourceQuery carries prefixes; append * to short codes, keep full codes exact.
96
+ codes = tuple(p + '*' if len(p) < 8 else p for p in query.cpv_prefixes)
97
+ notices = fetch_notices(self.session, start, end, codes, kinds)
98
+ if not any(kind in kinds for kind in RESULT_KINDS):
99
+ return notices
100
+ results = [notice for notice in notices if notice.notice_type in RESULT_KINDS]
101
+ if len(results) > MAX_AWARD_XML:
102
+ raise TedIncomplete(f'{len(results)} TED award notices exceed the {MAX_AWARD_XML} XML budget; narrow the window')
103
+ return [self.awards(notice) if notice.notice_type in RESULT_KINDS else notice for notice in notices]
104
+
105
+ def awards(self, notice):
106
+ """Fill per-lot awards from the notice XML; a failed fetch keeps the search-API record intact."""
107
+ try:
108
+ if self.xml_delay:
109
+ time.sleep(self.xml_delay)
110
+ response = self.session.get(TED_XML.format(number=notice.number),
111
+ headers={'Accept': 'application/xml'}, timeout=90)
112
+ response.raise_for_status()
113
+ from tenderkit.sources.ted_xml import parse_awards
114
+ return replace(notice, awards=parse_awards(response.content))
115
+ except (OSError, ValueError, RuntimeError):
116
+ return notice
@@ -0,0 +1,97 @@
1
+ """Per-lot awards from the public TED notice XML (eForms UBL).
2
+
3
+ The Search API arrays are flattened and carry no join between a lot and its winner, so multi-lot
4
+ awards come from the authoritative XML behind `https://ted.europa.eu/en/notice/{number}/xml`.
5
+ Only the NoticeResult section is read; missing fields stay empty instead of being inferred.
6
+ """
7
+ from datetime import date
8
+ from decimal import Decimal, InvalidOperation
9
+ import xml.etree.ElementTree as ET
10
+
11
+ from tenderkit.model import Award
12
+
13
+ NS = {
14
+ 'cac': 'urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2',
15
+ 'cbc': 'urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2',
16
+ 'efac': 'http://data.europa.eu/p27/eforms-ubl-extension-aggregate-components/1',
17
+ 'efbc': 'http://data.europa.eu/p27/eforms-ubl-extension-basic-components/1',
18
+ }
19
+ AWARDED_STATUS = 'selec-w'
20
+
21
+
22
+ def text(element, path):
23
+ found = element.find(path, NS) if element is not None else None
24
+ value = (found.text or '').strip() if found is not None and found.text else None
25
+ return value or None
26
+
27
+
28
+ def money(element):
29
+ node = element.find('cac:LegalMonetaryTotal/cbc:PayableAmount', NS) if element is not None else None
30
+ if node is None or not (node.text or '').strip():
31
+ return None, None
32
+ try:
33
+ value = Decimal(node.text.strip())
34
+ except InvalidOperation:
35
+ return None, None
36
+ return (value, node.get('currencyID')) if value.is_finite() and value >= 0 else (None, None)
37
+
38
+
39
+ def parse_awards(document):
40
+ """Award tuples for one ContractAwardNotice XML document; raises ValueError when it is not one."""
41
+ try:
42
+ root = ET.fromstring(document)
43
+ except ET.ParseError as exc:
44
+ raise ValueError('Unreadable TED notice XML') from exc
45
+ if root.tag.rsplit('}', 1)[-1] not in ('ContractAwardNotice', 'ContractAwardNoticeType'):
46
+ raise ValueError('Not a TED contract award notice')
47
+ organizations = {}
48
+ for company in root.findall('.//efac:Organizations/efac:Organization/efac:Company', NS):
49
+ key = text(company, 'cac:PartyIdentification/cbc:ID')
50
+ if key:
51
+ organizations[key] = (text(company, 'cac:PartyName/cbc:Name'),
52
+ text(company, 'cac:PartyLegalEntity/cbc:CompanyID'))
53
+ tenders = {}
54
+ for tender in root.findall('.//efac:NoticeResult/efac:LotTender', NS):
55
+ key = text(tender, 'cbc:ID')
56
+ if key:
57
+ price, currency = money(tender)
58
+ tenders[key] = dict(tenderer=text(tender, 'efac:TenderingParty/cbc:ID'), price=price, currency=currency)
59
+ contracts = {}
60
+ for contract in root.findall('.//efac:NoticeResult/efac:SettledContract', NS):
61
+ key, issued = text(contract, 'cbc:ID'), text(contract, 'cbc:IssueDate')
62
+ if key and issued:
63
+ try:
64
+ contracts[key] = date.fromisoformat(issued[:10])
65
+ except ValueError:
66
+ pass
67
+ parties = {}
68
+ for party in root.findall('.//efac:NoticeResult/efac:TenderingParty', NS):
69
+ key, organization = text(party, 'cbc:ID'), text(party, 'efac:Tenderer/cbc:ID')
70
+ if key and organization:
71
+ parties[key] = organization
72
+ awards = []
73
+ for result in root.findall('.//efac:NoticeResult/efac:LotResult', NS):
74
+ lot = text(result, 'efac:TenderLot/cbc:ID')
75
+ if not lot:
76
+ continue
77
+ tender = tenders.get(text(result, 'efac:LotTender/cbc:ID')) or {}
78
+ contractor, contractor_id = organizations.get(parties.get(tender.get('tenderer')), (None, None))
79
+ awarded = text(result, 'cbc:TenderResultCode') == AWARDED_STATUS
80
+ awards.append(Award(
81
+ lot=lot, status='AWARDED' if awarded else 'CANCELLED',
82
+ contractor=contractor if awarded else None,
83
+ contractor_id=contractor_id if awarded else None,
84
+ price=tender.get('price') if awarded else None,
85
+ currency=tender.get('currency') if awarded else None,
86
+ offers=subscription_count(result),
87
+ date=contracts.get(text(result, 'efac:SettledContract/cbc:ID')) if awarded else None))
88
+ return tuple(awards)
89
+
90
+
91
+ def subscription_count(result):
92
+ for statistics in result.findall('efac:ReceivedSubmissionsStatistics', NS):
93
+ if text(statistics, 'efbc:StatisticsCode') == 'tenders':
94
+ value = text(statistics, 'efbc:StatisticsNumeric')
95
+ if value and value.isdigit():
96
+ return int(value)
97
+ return None
@@ -0,0 +1 @@
1
+ """Local persistence, independent of pharma's database."""
@@ -0,0 +1,168 @@
1
+ """Verified SHA-256 publication shared with pharma, plus the standalone sharded store."""
2
+ import hashlib
3
+ import os
4
+ from pathlib import Path
5
+ import re
6
+ import json
7
+ import tempfile
8
+ import shutil
9
+
10
+ from tenderkit.limits import MAX_BYTES
11
+
12
+ STORAGE_NAME = re.compile(r'([a-f0-9]{64})\.[a-z0-9]+')
13
+
14
+ class StoreError(ValueError):
15
+ pass
16
+
17
+
18
+ class PathTooLong(StoreError):
19
+ """A store path that Windows would refuse (MAX_PATH); code PATH_TOO_LONG is what a download job reports."""
20
+ code = 'PATH_TOO_LONG'
21
+
22
+
23
+ # Windows refuses paths of 260 characters or more unless long paths are enabled for the machine and Python; a store in a
24
+ # deep directory then failed with a bare FileNotFoundError (issue #23). Other systems allow far longer paths.
25
+ MAX_PATH = 259 if os.name == 'nt' else 4095
26
+
27
+
28
+ def check_length(path, limit=None):
29
+ """The path, or PathTooLong naming its length and the remedy."""
30
+ limit = MAX_PATH if limit is None else limit
31
+ if len(str(path)) > limit:
32
+ raise PathTooLong(f'PATH_TOO_LONG: {len(str(path))} characters (limit {limit}): {path}. '
33
+ 'Choose a shorter --documents-dir or TENDERKIT_DOCUMENT_DIR.')
34
+ return path
35
+
36
+ def sha256_file(path):
37
+ digest = hashlib.sha256()
38
+ with Path(path).open('rb') as stream:
39
+ for block in iter(lambda: stream.read(1 << 20), b''):
40
+ digest.update(block)
41
+ return digest.hexdigest()
42
+
43
+ def state(store, storage_name, digest, size=None):
44
+ """'OK', 'MISSING' or 'MISMATCH' (other content or size) for one registered document."""
45
+ path = Path(store)/storage_name
46
+ if not path.is_file():
47
+ return 'MISSING'
48
+ if (size is not None and path.stat().st_size != size) or sha256_file(path) != digest:
49
+ return 'MISMATCH'
50
+ return 'OK'
51
+
52
+ def publish(store, source, digest, storage_name):
53
+ """Copy a verified source into the store; True when written, False when the same content is already there.
54
+
55
+ The copy goes to a temporary file in the store, is checked and only then renamed, so a reader never sees a partial
56
+ file. An existing file with other content is an error and is never overwritten.
57
+ """
58
+ found = STORAGE_NAME.fullmatch(storage_name or '')
59
+ if not found or found[1] != digest:
60
+ raise StoreError(f'Storage name does not match its SHA-256: {storage_name}')
61
+ if sha256_file(source) != digest:
62
+ raise StoreError(f'Source does not match its SHA-256: {Path(source).name}')
63
+ store = Path(store)
64
+ target = store/storage_name
65
+ current = state(store, storage_name, digest)
66
+ if current == 'OK':
67
+ return False
68
+ if current == 'MISMATCH':
69
+ raise StoreError(f'Store holds other content under {storage_name}')
70
+ store.mkdir(parents=True, exist_ok=True)
71
+ temporary = store/f'.{storage_name}.{os.getpid()}.tmp'
72
+ try:
73
+ with Path(source).open('rb') as reader, temporary.open('xb') as writer:
74
+ for block in iter(lambda: reader.read(1 << 20), b''):
75
+ writer.write(block)
76
+ writer.flush()
77
+ os.fsync(writer.fileno())
78
+ if sha256_file(temporary) != digest:
79
+ raise StoreError(f'Copy does not match its SHA-256: {storage_name}')
80
+ # Content-addressed: a concurrent writer of the same name wrote the same bytes.
81
+ os.replace(temporary, target)
82
+ finally:
83
+ temporary.unlink(missing_ok=True)
84
+ return True
85
+
86
+
87
+ class DocumentStore:
88
+ def __init__(self, root):
89
+ self.root = Path(root).expanduser().resolve()
90
+
91
+ def path(self, storage_name):
92
+ if not STORAGE_NAME.fullmatch(storage_name or ''):
93
+ raise StoreError('Invalid storage name')
94
+ path = self.root / 'objects' / storage_name[:2] / storage_name
95
+ if not path.resolve().is_relative_to(self.root):
96
+ raise StoreError('Document escapes store')
97
+ return check_length(path)
98
+
99
+ def put(self, source):
100
+ source = Path(source)
101
+ if source.stat().st_size > MAX_BYTES:
102
+ raise StoreError('Document exceeds file size limit')
103
+ digest = sha256_file(source)
104
+ ext = re.sub(r'[^a-z0-9]', '', source.suffix.lower())[:10] or 'bin'
105
+ name = f'{digest}.{ext}'
106
+ target = self.path(name)
107
+ publish(target.parent, source, digest, name)
108
+ return dict(sha256=digest, storage_name=name, byte_size=target.stat().st_size)
109
+
110
+ def verified(self, record):
111
+ path = self.path(record['storage_name'])
112
+ if state(path.parent, path.name, record['sha256'], record['byte_size']) != 'OK':
113
+ raise StoreError('Stored document is missing or corrupt')
114
+ return path
115
+
116
+ def manifest(self, source, number, value):
117
+ key = hashlib.sha256(json.dumps([source, number], ensure_ascii=False).encode('utf-8')).hexdigest()
118
+ directory = self.root / 'procedures' / key / 'raw'
119
+ if not directory.resolve().is_relative_to(self.root):
120
+ raise StoreError('Manifest escapes store')
121
+ # manifest.json and its temporary file must fit as well as the linked documents.
122
+ check_length(directory / ('x' * 24 + '.tmp'))
123
+ directory.mkdir(parents=True, exist_ok=True)
124
+ for item in value['documents']:
125
+ if not item.get('localPath'):
126
+ continue
127
+ source = Path(item['localPath'])
128
+ digest = item['sha256']
129
+ if sha256_file(source) != digest:
130
+ raise StoreError('Manifest source does not match its SHA-256')
131
+ # Existing pharma readers resolve raw/Path(localPath).name. Keep
132
+ # that layout as hard links to the canonical sharded objects.
133
+ # The readable part of the name shrinks to fit the path limit; the digest prefix keeps names unique.
134
+ suffix = source.suffix
135
+ room = MAX_PATH - len(str(directory)) - len(os.sep) - 17 - len(suffix)
136
+ safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', item['name']).strip(' .')
137
+ if Path(safe).suffix.lower() == suffix.lower():
138
+ safe = safe[:len(safe) - len(suffix)]
139
+ safe = safe[:max(0, min(80, room))].strip(' .') or 'document'
140
+ target = check_length(directory / (digest[:16] + '_' + safe + suffix))
141
+ if target.exists():
142
+ if sha256_file(target) != digest:
143
+ raise StoreError('Manifest file holds other content')
144
+ else:
145
+ try:
146
+ os.link(source, target)
147
+ except OSError:
148
+ with tempfile.NamedTemporaryFile(dir=directory, suffix='.tmp', delete=False) as stream:
149
+ temporary_copy = Path(stream.name)
150
+ try:
151
+ shutil.copyfile(source, temporary_copy)
152
+ if sha256_file(temporary_copy) != digest:
153
+ raise StoreError('Manifest copy does not match SHA-256')
154
+ os.replace(temporary_copy, target)
155
+ finally:
156
+ temporary_copy.unlink(missing_ok=True)
157
+ item['localPath'] = str(target)
158
+ path = directory / 'manifest.json'
159
+ with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=directory, suffix='.tmp', delete=False) as stream:
160
+ temporary = Path(stream.name)
161
+ json.dump(value, stream, ensure_ascii=False, indent=1)
162
+ stream.flush()
163
+ os.fsync(stream.fileno())
164
+ try:
165
+ os.replace(temporary, path)
166
+ finally:
167
+ temporary.unlink(missing_ok=True)
168
+ return path