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
tenderkit/service.py ADDED
@@ -0,0 +1,164 @@
1
+ """One application path for CLI and MCP: fetch to SQLite, then filter the same notices."""
2
+ from dataclasses import asdict, replace
3
+ from datetime import date, datetime, timedelta
4
+ from importlib.metadata import entry_points
5
+ import json
6
+ from zoneinfo import ZoneInfo
7
+
8
+ from tenderkit.interfaces import Source
9
+ from tenderkit.profile import fold
10
+ from tenderkit.serialization import dumps, notice_record
11
+ from tenderkit.sources.bzp import Bzp
12
+ from tenderkit.sources.bk import Bk
13
+ from tenderkit.sources.ted import Ted
14
+
15
+ MAX_WINDOW_DAYS = 120
16
+ MAX_NOTICES = 20000
17
+ MAX_RESULTS = 500
18
+
19
+
20
+ def today():
21
+ return datetime.now(ZoneInfo('Europe/Warsaw')).date()
22
+
23
+
24
+ def window(start=None, end=None):
25
+ end = date.fromisoformat(end) if isinstance(end, str) else end or today()
26
+ start = date.fromisoformat(start) if isinstance(start, str) else start or end - timedelta(days=29)
27
+ if not 0 <= (end - start).days < MAX_WINDOW_DAYS:
28
+ raise ValueError(f'Publication window must contain 1 to {MAX_WINDOW_DAYS} days')
29
+ return start, end
30
+
31
+
32
+ def sources(*, include_bk=False):
33
+ registered = {'BZP': Bzp(), 'TED': Ted()}
34
+ if include_bk:
35
+ registered['BK'] = Bk()
36
+ for entry in entry_points(group='tenderkit.sources'):
37
+ source = entry.load()()
38
+ if not isinstance(source, Source) or source.name in registered:
39
+ raise ValueError(f'Invalid or duplicate source entry point: {entry.name}')
40
+ registered[source.name] = source
41
+ return registered
42
+
43
+
44
+ def refresh(state, profile, start, end, adapters=None, include_results=False, include_amendments=False):
45
+ start, end = window(start, end)
46
+ adapters = sources() if adapters is None else adapters
47
+ summaries = []
48
+ query = profile.source_query()
49
+ if include_results or include_amendments:
50
+ query = replace(query, include_results=include_results, include_amendments=include_amendments)
51
+ for source, adapter in adapters.items():
52
+ try:
53
+ notices = []
54
+ for notice in adapter.fetch(start, end, query):
55
+ if len(notices) >= MAX_NOTICES:
56
+ raise ValueError('Notice safety limit reached')
57
+ if notice.source != source or notice.published is None or not start <= notice.published <= end:
58
+ raise ValueError('Source returned a notice outside its declared window')
59
+ notices.append(notice)
60
+ state.record_refresh(source, start, end, asdict(query), notices)
61
+ summaries.append(dict(source=source, status='COMPLETED', notices=len(notices)))
62
+ except Exception as exc:
63
+ state.record_refresh(source, start, end, asdict(query), error=type(exc).__name__)
64
+ summaries.append(dict(source=source, status='FAILED', error=type(exc).__name__))
65
+ return summaries
66
+
67
+
68
+ def filtered(state, profile, *, start=None, end=None, source_names=(), on=None):
69
+ if start is not None or end is not None:
70
+ start, end = window(start, end)
71
+ on = on or today()
72
+ for notice in state.notices(start, end, source_names):
73
+ if profile.matches(notice, today=on):
74
+ yield notice
75
+
76
+
77
+ def search(state, profile, *, start=None, end=None, limit=100, offset=0, source_names=()):
78
+ if type(limit) is not int or not 1 <= limit <= MAX_RESULTS or type(offset) is not int or not 0 <= offset <= MAX_NOTICES:
79
+ raise ValueError('Invalid result limit or offset')
80
+ found, more = [], False
81
+ for index, notice in enumerate(filtered(state, profile, start=start, end=end, source_names=source_names)):
82
+ if index < offset:
83
+ continue
84
+ if len(found) == limit:
85
+ more = True
86
+ break
87
+ found.append(matched_notice_record(notice, profile))
88
+ return dict(notices=found, offset=offset, next_offset=offset + len(found) if more else None)
89
+
90
+
91
+ def matched_notice_record(notice, profile, *, raw=False):
92
+ record = notice_record(notice, raw=raw)
93
+ if profile.keywords_any:
94
+ record['keyword_matches'] = profile.keyword_matches(notice)
95
+ return record
96
+
97
+
98
+ def _award_record(award, notice):
99
+ record = json_roundtrip(asdict(award))
100
+ return dict(record, source=notice.source, number=notice.number,
101
+ published=str(notice.published) if notice.published else None,
102
+ title=notice.title, buyer_name=notice.buyer_name)
103
+
104
+
105
+ def json_roundtrip(value):
106
+ return json.loads(dumps(value))
107
+
108
+
109
+ def latest_awards(notices):
110
+ """Latest formal result per source, procedure and lot; a later notice replaces an earlier one."""
111
+ latest = {}
112
+ for notice in sorted(notices, key=lambda n: (n.published or date.min, n.number)):
113
+ if not notice.awards:
114
+ continue
115
+ key = notice.procedure_id or notice.number
116
+ for award in notice.awards:
117
+ latest[(notice.source, key, award.lot)] = (award, notice)
118
+ return [latest[key] for key in sorted(latest, key=lambda k: (k[0], k[1], k[2]))]
119
+
120
+
121
+ def procedure_results(state, source, number):
122
+ """One procedure's contract and result notices, linked by procedure_id, with the latest award per lot."""
123
+ notice = state.get(source, number)
124
+ if notice is None:
125
+ raise ValueError('Notice not found in local state')
126
+ related = [notice]
127
+ if notice.procedure_id:
128
+ related = [n for n in state.notices(sources=(source,)) if n.procedure_id == notice.procedure_id]
129
+ if not any(n.number == notice.number for n in related):
130
+ related.append(notice)
131
+ related.sort(key=lambda n: (n.published or date.min, n.number))
132
+ return dict(
133
+ notice=notice_record(notice, raw=False),
134
+ procedure_id=notice.procedure_id,
135
+ contracts=[notice_record(n, raw=False) for n in related if not n.awards],
136
+ results=[notice_record(n, raw=False) for n in related if n.awards],
137
+ awards=[_award_record(award, owner) for award, owner in latest_awards(related)])
138
+
139
+
140
+ def search_results(state, *, buyer=None, cpv=(), start=None, end=None, limit=100, offset=0, source_names=()):
141
+ """Earlier awards by buyer text and CPV, latest per procedure and lot, paginated like notice search."""
142
+ if type(limit) is not int or not 1 <= limit <= MAX_RESULTS or type(offset) is not int or not 0 <= offset <= MAX_NOTICES:
143
+ raise ValueError('Invalid result limit or offset')
144
+ if start is not None or end is not None:
145
+ start, end = window(start, end)
146
+ patterns = tuple(str(code).strip() for code in cpv)
147
+ if any(not pattern or not (pattern.rstrip('*').isdigit()) for pattern in patterns):
148
+ raise ValueError('Result CPV must be digits, optionally ending in *')
149
+ folded_buyer = fold(buyer) if buyer else None
150
+ candidates = []
151
+ for notice in state.notices(start, end, source_names):
152
+ if not notice.awards:
153
+ continue
154
+ if folded_buyer and folded_buyer not in fold(notice.buyer_name):
155
+ continue
156
+ if patterns and not any(
157
+ str(code).startswith(pattern[:-1]) if pattern.endswith('*') else str(code) == pattern
158
+ for pattern in patterns for code in notice.cpv):
159
+ continue
160
+ candidates.append(notice)
161
+ awards = [_award_record(award, owner) for award, owner in latest_awards(candidates)]
162
+ page = awards[offset:offset + limit]
163
+ return dict(awards=page, offset=offset, total=len(awards),
164
+ next_offset=offset + len(page) if offset + len(page) < len(awards) else None)
@@ -0,0 +1 @@
1
+ """Public notice sources. Network clients can be supplied for offline testing."""
@@ -0,0 +1,145 @@
1
+ """Bounded reads of public BK2021 notices from the portal's backend."""
2
+ from datetime import date, datetime, timezone
3
+ import re
4
+ from zoneinfo import ZoneInfo
5
+
6
+ from tenderkit.model import Lot, Notice
7
+ from tenderkit.platforms.session import BK_HOST, PlatformSession
8
+ from tenderkit.sources.mapping import CPV, amount, html_to_text, instant
9
+
10
+ BASE = f'https://{BK_HOST}'
11
+ WARSAW = ZoneInfo('Europe/Warsaw')
12
+ NOTICE_TYPE = 'BKAnnouncement'
13
+ STATUSES = ('PUBLISHED', 'CLOSED', 'RESOLVED', 'CANCELLED')
14
+ MAX_TOTAL = 1000
15
+ # Publication-date ordering can repeat records across page boundaries.
16
+ # Request the bounded batch in one response when the portal permits it.
17
+ PAGE_SIZE = MAX_TOTAL
18
+ CPV_BATCH = 100
19
+
20
+
21
+ class BkIncomplete(RuntimeError):
22
+ """The returned page or detail did not match the public search."""
23
+
24
+
25
+ def _data(session, path, params=None):
26
+ response = session.get(BASE + path, params=params, timeout=30)
27
+ response.raise_for_status()
28
+ result = response.json()['data']
29
+ if not isinstance(result, dict):
30
+ raise BkIncomplete('Expected a BK data object')
31
+ return result
32
+
33
+
34
+ def detail(session, identifier):
35
+ row = _data(session, f'/api/announcements/{identifier}')['advertisement']
36
+ if row['advertisement']['id'] != identifier or type(row['id']) is not int or row['id'] < 1:
37
+ raise BkIncomplete('BK detail does not match the search result')
38
+ return row
39
+
40
+
41
+ def _codes(session, prefixes):
42
+ codes = set()
43
+ for prefix in dict.fromkeys(prefixes):
44
+ data = _data(session, '/api/dictionaries/cpv', {'search_key': prefix, 'request_id': 1})
45
+ rows = data['cpv_items']
46
+ if data['meta']['last_page'] != 1 or data['meta']['total'] != len(rows):
47
+ raise BkIncomplete('Incomplete BK CPV dictionary')
48
+ for row in rows:
49
+ code = row['code']
50
+ if not re.fullmatch(r'\d{8}-\d', code):
51
+ raise BkIncomplete('Invalid BK CPV code')
52
+ if code.startswith(prefix):
53
+ codes.add(code)
54
+ return sorted(codes)
55
+
56
+
57
+ def _list(session, start, end, codes):
58
+ params = [('limit', PAGE_SIZE), ('sort', 'publicationDate'),
59
+ ('publicationDateRange[from]', str(start)), ('publicationDateRange[to]', str(end))]
60
+ params += [(f'status[{index}]', status) for index, status in enumerate(STATUSES)]
61
+ params += [('cpvItem[]', code) for code in codes]
62
+ found, expected, page = {}, None, 1
63
+ while True:
64
+ data = _data(session, '/api/announcements/search', [*params, ('page', page)])
65
+ total, rows = data['meta']['total'], data['advertisements']
66
+ if (type(total) is not int or not 0 <= total <= MAX_TOTAL
67
+ or expected is not None and total != expected
68
+ or not isinstance(rows, list) or len(rows) != min(PAGE_SIZE, total - len(found))):
69
+ raise BkIncomplete('BK count changed or exceeded the fetch limit; narrow the window')
70
+ expected = total
71
+ for row in rows:
72
+ identifier = row['id']
73
+ published = date.fromisoformat(row['publication_date'])
74
+ if type(identifier) is not int or identifier < 1 or identifier in found or not start <= published <= end:
75
+ raise BkIncomplete('Duplicate or invalid BK search result')
76
+ found[identifier] = row
77
+ if len(found) == total:
78
+ return found
79
+ page += 1
80
+
81
+
82
+ def bk_notice(summary, row, acquired):
83
+ identifier = summary['id']
84
+ published = date.fromisoformat(row['publication_date'])
85
+ if published != date.fromisoformat(summary['publication_date']):
86
+ raise BkIncomplete('BK publication date changed during the fetch')
87
+ descriptions, lots = [], []
88
+ orders = row['orders']
89
+ for order in orders:
90
+ codes = []
91
+ for item in order['order_items']:
92
+ descriptions.append(html_to_text(item.get('description')).strip())
93
+ codes.extend(match for cpv in item['cpv_items'] for match in CPV.findall(cpv['code']))
94
+ estimate = amount(order.get('estimated_value'))
95
+ lots.append(Lot(str(order['id']), title=order.get('title'), cpv=tuple(dict.fromkeys(codes)),
96
+ value=estimate, currency='PLN' if estimate is not None else None))
97
+ # A single order's estimate can be used directly. Variants and partial orders
98
+ # cannot be added safely without an explicit whole-notice total.
99
+ value = lots[0].value if len(orders) == 1 and not orders[0].get('is_variant') else None
100
+ deadline = instant(row.get('submission_deadline'))
101
+ if deadline is not None and deadline.tzinfo is None:
102
+ deadline = deadline.replace(tzinfo=WARSAW)
103
+ address = row.get('advertiser_address_details') or {}
104
+ url = f'{BASE}/ogloszenia/{identifier}'
105
+ return Notice(
106
+ 'BK', str(identifier), procedure_id=str(identifier), title=row['title'], published=published,
107
+ deadline=deadline, buyer_name=summary.get('advertiser_name') or address.get('name'),
108
+ buyer_city=(address.get('address') or {}).get('locality'),
109
+ cpv=tuple(dict.fromkeys(code for lot in lots for code in lot.cpv)), lots=tuple(lots),
110
+ description='\n\n'.join(dict.fromkeys(text for text in descriptions if text)) or None,
111
+ value=value, currency='PLN' if value is not None else None, url=url, platform_url=url,
112
+ reference=row['advertisement'].get('number'), notice_type=NOTICE_TYPE, lot_count=len(lots),
113
+ raw={'version': row['id']}, source_attribution={
114
+ 'publisher': 'Ministerstwo Funduszy i Polityki Regionalnej',
115
+ 'acquired_at': acquired, 'processing': 'Selected and normalized from the public BK2021 notice',
116
+ 'url': url,
117
+ })
118
+
119
+
120
+ class Bk:
121
+ name = 'BK'
122
+
123
+ def __init__(self, session=None):
124
+ self.session = session or PlatformSession()
125
+
126
+ def fetch(self, start, end, query):
127
+ if query.notice_types and NOTICE_TYPE not in query.notice_types:
128
+ return
129
+ if any(not re.fullmatch(r'\d{1,8}', prefix) for prefix in query.cpv_prefixes):
130
+ raise ValueError('Invalid BK CPV prefix')
131
+ codes = _codes(self.session, query.cpv_prefixes)
132
+ batches = [codes[i:i + CPV_BATCH] for i in range(0, len(codes), CPV_BATCH)] if query.cpv_prefixes else [()]
133
+ found = {}
134
+ for batch in batches:
135
+ for identifier, row in _list(self.session, start, end, batch).items():
136
+ if identifier in found and any(
137
+ found[identifier].get(key) != row.get(key)
138
+ for key in ('publication_date', 'submission_deadline', 'title', 'advertiser_name')
139
+ ):
140
+ raise BkIncomplete('BK search result changed between CPV batches')
141
+ found[identifier] = row
142
+ if len(found) > MAX_TOTAL:
143
+ raise BkIncomplete('BK detail request limit exceeded; narrow the window')
144
+ for identifier, row in found.items():
145
+ yield bk_notice(row, detail(self.session, identifier), datetime.now(timezone.utc).isoformat())
@@ -0,0 +1,142 @@
1
+ """Bounded BZP pagination shared by ingestion and read-only discovery."""
2
+ from datetime import datetime, timedelta, timezone
3
+ import time
4
+ from zoneinfo import ZoneInfo
5
+
6
+ WARSAW = ZoneInfo('Europe/Warsaw')
7
+ FRESH_NOTICE_TYPES = ('ContractNotice', 'SmallContractNotice', 'CompetitionNotice')
8
+ RESULT_NOTICE_TYPES = ('TenderResultNotice',)
9
+ AMENDMENT_TYPE = 'NoticeUpdateNotice'
10
+ ENDPOINT = 'https://ezamowienia.gov.pl/mo-board/api/v1/notice'
11
+
12
+
13
+ def timestamp(value):
14
+ result = datetime.fromisoformat(value.replace('Z', '+00:00'))
15
+ if result.tzinfo is None:
16
+ raise ValueError('Publication timestamp has no timezone')
17
+ return result.astimezone(timezone.utc)
18
+
19
+
20
+ def bounds(date_from, date_to):
21
+ if date_from > date_to:
22
+ raise ValueError('Invalid date interval')
23
+ start = datetime.combine(date_from, datetime.min.time(), WARSAW)
24
+ end = datetime.combine(date_to + timedelta(days=1), datetime.min.time(), WARSAW)
25
+ return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
26
+
27
+
28
+ class FetchFailure(RuntimeError):
29
+ def __init__(self, message, audit, notices):
30
+ super().__init__(message)
31
+ self.audit, self.notices = audit, notices
32
+
33
+
34
+ def fetch_window(session, date_from, date_to, kind, cpv_code='336', page_size=500,
35
+ delay=0.3, observed_at=None, save_page=None, max_pages=100,
36
+ retries=3):
37
+ """Fetch broad API date bounds, then enforce exact Warsaw bounds locally.
38
+
39
+ save_page is called before validation, preserving malformed/error evidence.
40
+ Failed windows carry partial notices but must never be marked complete.
41
+ """
42
+ if not 1 <= page_size <= 500 or max_pages < 1 or retries < 1:
43
+ raise ValueError('Invalid pagination configuration')
44
+ start, end = bounds(date_from, date_to)
45
+ observed_at = observed_at or datetime.now(timezone.utc)
46
+ if observed_at.tzinfo is None:
47
+ raise ValueError('Observation timestamp has no timezone')
48
+ cutoff = min(end, observed_at)
49
+ params = dict(NoticeType=kind, PageSize=page_size,
50
+ PublicationDateFrom=start.strftime('%Y-%m-%dT00:00:00'),
51
+ PublicationDateTo=end.strftime('%Y-%m-%dT23:59:59'))
52
+ if cpv_code:
53
+ params['CpvCode'] = cpv_code
54
+ audit = dict(notice_type=kind, from_date=str(date_from), to_date=str(date_to),
55
+ start_inclusive=start.isoformat(), end_exclusive=end.isoformat(),
56
+ observed_at=observed_at.isoformat(), pages=0, returned_unique=0,
57
+ included=0, duplicates=0, outside_window=0, complete=False,
58
+ provisional=date_to >= observed_at.astimezone(WARSAW).date())
59
+ seen, accepted = {}, {}
60
+ try:
61
+ for page_number in range(1, max_pages + 1):
62
+ for attempt in range(retries):
63
+ if delay:
64
+ time.sleep(delay * (2 ** attempt))
65
+ try:
66
+ response = session.get(ENDPOINT, params=params,
67
+ headers={'Accept': 'application/json'}, timeout=40)
68
+ response.raise_for_status()
69
+ page = response.json()
70
+ break
71
+ except Exception:
72
+ if attempt == retries - 1:
73
+ raise
74
+ if save_page:
75
+ save_page(kind, page_number, dict(params), page)
76
+ audit['pages'] += 1
77
+ if not isinstance(page, list):
78
+ raise ValueError('Expected notice array, not an empty/error object')
79
+ before = len(seen)
80
+ for notice in page:
81
+ if not isinstance(notice, dict) or not notice.get('objectId'):
82
+ raise ValueError('Notice is missing objectId')
83
+ nid = notice['objectId']
84
+ if nid in seen:
85
+ if seen[nid] != notice:
86
+ raise ValueError('Conflicting content for the same objectId')
87
+ audit['duplicates'] += 1
88
+ continue
89
+ seen[nid] = notice
90
+ audit['returned_unique'] = len(seen)
91
+ if notice.get('noticeType') != kind:
92
+ raise ValueError('Unexpected notice type')
93
+ published = timestamp(notice['publicationDate'])
94
+ if not start <= published < cutoff:
95
+ audit['outside_window'] += 1
96
+ continue
97
+ accepted[nid] = notice
98
+ audit['included'] = len(accepted)
99
+ if len(page) < page_size:
100
+ audit['complete'] = not audit['provisional']
101
+ audit['fetch_succeeded'] = True
102
+ return list(accepted.values()), audit
103
+ cursor = page[-1]['objectId']
104
+ if len(seen) == before or cursor == params.get('SearchAfter'):
105
+ raise ValueError('Pagination stalled')
106
+ params['SearchAfter'] = cursor
107
+ raise ValueError('Pagination safety limit reached')
108
+ except Exception as exc:
109
+ audit.update(error_type=type(exc).__name__, fetch_succeeded=False,
110
+ http_status=getattr(getattr(exc, 'response', None), 'status_code', None))
111
+ # Avoid printing connection strings or response bodies from exceptions.
112
+ raise FetchFailure(type(exc).__name__, audit, list(accepted.values())) from exc
113
+
114
+
115
+ class Bzp:
116
+ name = 'BZP'
117
+
118
+ def __init__(self, session=None, delay=2.1):
119
+ from tenderkit.http import Session
120
+ self.session, self.delay = session or Session(), delay
121
+
122
+ def fetch(self, start, end, query):
123
+ from tenderkit.sources.mapping import bzp_amendment, bzp_notice
124
+ known = FRESH_NOTICE_TYPES + RESULT_NOTICE_TYPES
125
+ kinds = tuple(k for k in query.notice_types if k in known) if query.notice_types else FRESH_NOTICE_TYPES
126
+ if query.include_results:
127
+ kinds = kinds + tuple(k for k in RESULT_NOTICE_TYPES if k not in kinds)
128
+ found = {}
129
+ for kind in kinds:
130
+ for prefix in query.cpv_prefixes or ('',):
131
+ rows, _ = fetch_window(self.session, start, end, kind, cpv_code=prefix, delay=self.delay)
132
+ for raw in rows:
133
+ notice = bzp_notice(raw)
134
+ found[notice.number] = notice
135
+ if query.include_amendments and any(kind in FRESH_NOTICE_TYPES for kind in kinds):
136
+ # Amendment records do not carry CPV codes. Keep the reference and changed deadline.
137
+ rows, _ = fetch_window(self.session, start, end, AMENDMENT_TYPE, cpv_code='', delay=self.delay)
138
+ for raw in rows:
139
+ notice = bzp_amendment(raw)
140
+ if notice is not None:
141
+ found[notice.number] = notice
142
+ return list(found.values())