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,202 @@
1
+ """SQLite state: one transaction per successfully completed source window."""
2
+ from contextlib import contextmanager
3
+ from datetime import datetime, timezone
4
+ import json
5
+ from pathlib import Path
6
+ import sqlite3
7
+
8
+ from tenderkit.serialization import dumps, notice_from_record, notice_record
9
+
10
+ SCHEMA = '''
11
+ create table if not exists notices (
12
+ source text not null, number text not null, published text,
13
+ payload text not null, coverage text not null default 'PENDING',
14
+ primary key(source, number)
15
+ );
16
+ create index if not exists notices_published on notices(published, source, number);
17
+ create table if not exists refreshes (
18
+ id integer primary key, source text not null, start text not null, end text not null,
19
+ query text not null, status text not null, count integer not null,
20
+ error text, finished_at text not null
21
+ );
22
+ create table if not exists document_refs (
23
+ source text not null, number text not null, document_key text not null, payload text not null,
24
+ primary key(source,number,document_key)
25
+ );
26
+ create table if not exists procedure_links (
27
+ source text not null, number text not null, payload text not null,
28
+ primary key(source,number)
29
+ );
30
+ create table if not exists notice_documents (
31
+ source text not null, number text not null, document_key text not null, sha256 text not null,
32
+ payload text not null, fetched_at text not null,
33
+ primary key(source,number,document_key,sha256)
34
+ );
35
+ -- A document the platform refused with a permanent HTTP error (a withdrawn or replaced attachment). A whole-procedure
36
+ -- download skips it; asking for its key again retries it, and a later successful download clears it.
37
+ create table if not exists document_unavailable (
38
+ source text not null, number text not null, document_key text not null, error text not null, checked_at text not null,
39
+ primary key(source,number,document_key)
40
+ );
41
+ create table if not exists fetch_jobs (
42
+ id integer primary key, source text not null, number text not null, selection text,
43
+ status text not null, attempts integer not null default 0, error text, error_kind text,
44
+ next_attempt real not null default 0, heartbeat real, summary text not null default '{}'
45
+ );
46
+ create unique index if not exists active_document_job on fetch_jobs(source,number)
47
+ where status in ('QUEUED','RUNNING');
48
+ create table if not exists watch_cursors (
49
+ profile text not null, source text not null, resume_from text not null,
50
+ primary key(profile,source)
51
+ );
52
+ create table if not exists watch_seen (
53
+ profile text not null, source text not null, identity text not null,
54
+ revision text not null, deadline text, primary key(profile,source,identity)
55
+ );
56
+ create table if not exists watch_ted_aliases (
57
+ profile text not null, alias text not null, identity text not null, primary key(profile,alias)
58
+ );
59
+ create table if not exists watch_runs (
60
+ id integer primary key, profile text not null, finished_at text not null, sources text not null
61
+ );
62
+ create index if not exists watch_runs_profile on watch_runs(profile,id);
63
+ create table if not exists watch_events (
64
+ id integer primary key, run_id integer not null references watch_runs(id),
65
+ profile text not null, payload text not null, delivered integer not null default 0
66
+ );
67
+ create index if not exists watch_events_run on watch_events(run_id,id);
68
+ create index if not exists watch_events_pending on watch_events(profile) where delivered=0;
69
+ '''
70
+
71
+
72
+ class State:
73
+ def __init__(self, path, *, read_only=False):
74
+ self.path = Path(path).expanduser()
75
+ self.read_only = read_only
76
+ if read_only:
77
+ return
78
+ self.path.parent.mkdir(parents=True, exist_ok=True)
79
+ with self.connection() as conn:
80
+ conn.executescript(SCHEMA)
81
+
82
+ @contextmanager
83
+ def connection(self):
84
+ database = self.path.resolve().as_uri() + '?mode=ro' if self.read_only else self.path
85
+ conn = sqlite3.connect(database, timeout=30, uri=self.read_only)
86
+ conn.row_factory = sqlite3.Row
87
+ try:
88
+ with conn:
89
+ yield conn
90
+ finally:
91
+ conn.close()
92
+
93
+ def record_refresh(self, source, start, end, query, notices=None, error=None):
94
+ with self.connection() as conn:
95
+ for notice in notices or []:
96
+ conn.execute('''insert into notices(source,number,published,payload) values(?,?,?,?)
97
+ on conflict(source,number) do update set published=excluded.published,payload=excluded.payload''',
98
+ (notice.source, notice.number, str(notice.published) if notice.published else None,
99
+ dumps(notice_record(notice))))
100
+ conn.execute('''insert into refreshes(source,start,end,query,status,count,error,finished_at)
101
+ values(?,?,?,?,?,?,?,?)''', (source, str(start), str(end), dumps(query),
102
+ 'FAILED' if error else 'COMPLETED', len(notices or []), error,
103
+ datetime.now(timezone.utc).isoformat()))
104
+
105
+ def notices(self, start=None, end=None, sources=()):
106
+ conditions, params = [], []
107
+ if start:
108
+ conditions.append('published >= ?')
109
+ params.append(str(start))
110
+ if end:
111
+ conditions.append('published <= ?')
112
+ params.append(str(end))
113
+ if sources:
114
+ conditions.append('source in (' + ','.join('?' for _ in sources) + ')')
115
+ params.extend(sources)
116
+ where = ' where ' + ' and '.join(conditions) if conditions else ''
117
+ with self.connection() as conn:
118
+ for row in conn.execute('select payload from notices' + where + ' order by published desc,source,number', params):
119
+ yield notice_from_record(json.loads(row['payload']))
120
+
121
+ def get(self, source, number):
122
+ with self.connection() as conn:
123
+ row = conn.execute('select payload from notices where source=? and number=?', (source, number)).fetchone()
124
+ return notice_from_record(json.loads(row['payload'])) if row else None
125
+
126
+ def coverage(self):
127
+ with self.connection() as conn:
128
+ counts = [dict(r) for r in conn.execute('select source,coverage,count(*) as count from notices group by source,coverage')]
129
+ # Every source/query window is explicit; this is not a claim of complete catalogue coverage.
130
+ refreshes = [dict(r) for r in conn.execute('select * from refreshes order by id desc limit 50')]
131
+ for row in refreshes:
132
+ row['query'] = json.loads(row['query'])
133
+ jobs = [dict(r) for r in conn.execute('select id,source,number,status,attempts,error,error_kind,next_attempt,summary from fetch_jobs order by id desc limit 50')]
134
+ for job in jobs:
135
+ job['summary'] = json.loads(job['summary'])
136
+ return dict(counts=counts, refreshes=refreshes, jobs=jobs)
137
+
138
+ def set_coverage(self, source, number, status):
139
+ if status not in ('PENDING', 'FETCHED', 'NO_DOCUMENTS', 'UNSUPPORTED_PLATFORM', 'NO_REFERENCE', 'FETCH_FAILED'):
140
+ raise ValueError('Invalid coverage state')
141
+ with self.connection() as conn:
142
+ conn.execute('update notices set coverage=? where source=? and number=?', (status, source, number))
143
+
144
+ def save_listing(self, source, number, refs, link=None):
145
+ from dataclasses import asdict
146
+ with self.connection() as conn:
147
+ conn.execute('delete from document_refs where source=? and number=?', (source, number))
148
+ conn.executemany('insert into document_refs values(?,?,?,?)',
149
+ [(source, number, ref.key, dumps(asdict(ref))) for ref in refs])
150
+ if link is not None:
151
+ conn.execute('insert into procedure_links values(?,?,?) on conflict(source,number) do update set payload=excluded.payload',
152
+ (source, number, dumps(asdict(link))))
153
+
154
+ def procedure_link(self, source, number):
155
+ with self.connection() as conn:
156
+ row = conn.execute('select payload from procedure_links where source=? and number=?', (source, number)).fetchone()
157
+ return json.loads(row[0]) if row else None
158
+
159
+ def listing(self, source, number):
160
+ with self.connection() as conn:
161
+ return [json.loads(row[0]) for row in conn.execute(
162
+ 'select payload from document_refs where source=? and number=? order by rowid', (source, number))]
163
+
164
+ def record_document(self, source, number, record):
165
+ with self.connection() as conn:
166
+ conn.execute('''insert into notice_documents values(?,?,?,?,?,?)
167
+ on conflict(source,number,document_key,sha256) do update set payload=excluded.payload,fetched_at=excluded.fetched_at''',
168
+ (source, number, record['key'], record['sha256'], dumps(record), record['fetched_at']))
169
+
170
+ def mark_unavailable(self, source, number, key, error):
171
+ with self.connection() as conn:
172
+ conn.execute('''insert into document_unavailable values(?,?,?,?,?)
173
+ on conflict(source,number,document_key) do update set error=excluded.error,checked_at=excluded.checked_at''',
174
+ (source, number, key, error, datetime.now(timezone.utc).isoformat()))
175
+
176
+ def clear_unavailable(self, source, number, key):
177
+ with self.connection() as conn:
178
+ conn.execute('delete from document_unavailable where source=? and number=? and document_key=?', (source, number, key))
179
+
180
+ def unavailable(self, source, number):
181
+ """{document key: {error, checked_at}} of the documents the platform refused for good."""
182
+ with self.connection() as conn:
183
+ return {row['document_key']: dict(error=row['error'], checked_at=row['checked_at']) for row in conn.execute(
184
+ 'select document_key,error,checked_at from document_unavailable where source=? and number=?', (source, number))}
185
+
186
+ def documents(self, source, number):
187
+ with self.connection() as conn:
188
+ # Return latest versions by key; earlier hashes stay registered and readable.
189
+ found = {}
190
+ for row in conn.execute('select document_key,payload from notice_documents where source=? and number=? order by fetched_at,rowid', (source, number)):
191
+ found[row['document_key']] = json.loads(row['payload'])
192
+ return found
193
+
194
+ def document(self, digest):
195
+ import re
196
+ if not re.fullmatch(r'[a-f0-9]{64}', digest or ''):
197
+ raise ValueError('Document id must be a SHA-256 digest')
198
+ with self.connection() as conn:
199
+ row = conn.execute('select source,number,payload from notice_documents where sha256=? order by fetched_at desc limit 1', (digest,)).fetchone()
200
+ if row is None:
201
+ raise ValueError('Document not found in local state')
202
+ return dict(source=row['source'], number=row['number'], **json.loads(row['payload']))
tenderkit/ui.py ADDED
@@ -0,0 +1,243 @@
1
+ """Loopback HTTP view over the same saved notices and profiles as the CLI."""
2
+ from dataclasses import replace
3
+ from datetime import date, datetime, timezone
4
+ from html import escape
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ from importlib.resources import files
7
+ import re
8
+ import sqlite3
9
+ from string import Template
10
+ from urllib.parse import parse_qsl, urlencode, urlsplit
11
+ from zoneinfo import ZoneInfo
12
+
13
+ from tenderkit.profile import fold
14
+ from tenderkit.service import filtered
15
+ from tenderkit.store.state import State
16
+
17
+ PAGE_SIZE = 30
18
+ PROFILE_LABELS = {
19
+ 'oprogramowanie': 'Oprogramowanie',
20
+ 'leki': 'Leki',
21
+ 'wyroby-medyczne': 'Wyroby medyczne',
22
+ }
23
+ DEADLINE_LABELS = {
24
+ '': 'Dowolny',
25
+ 'upcoming': 'Przed terminem',
26
+ 'passed': 'Po terminie',
27
+ 'today': 'Dziś, godzina nieznana',
28
+ 'missing': 'Brak terminu',
29
+ }
30
+ SORT_LABELS = {
31
+ 'newest': 'Najnowsze',
32
+ 'oldest': 'Najstarsze',
33
+ 'deadline': 'Termin rosnąco',
34
+ 'title': 'Tytuł A–Z',
35
+ 'buyer': 'Zamawiający A–Z',
36
+ }
37
+
38
+
39
+ def page_link(params):
40
+ return '/?' + escape(urlencode({key: value for key, value in params.items() if value != ''}))
41
+
42
+
43
+ def options(labels, selected):
44
+ return ''.join(f'<option value="{key}"{" selected" if key == selected else ""}>{label}</option>'
45
+ for key, label in labels.items())
46
+
47
+
48
+ def local_deadline(notice, warsaw):
49
+ deadline = notice.deadline
50
+ if deadline is None:
51
+ return None
52
+ return deadline.astimezone(warsaw) if deadline.tzinfo else deadline.replace(tzinfo=warsaw)
53
+
54
+
55
+ def deadline_date_only(notice):
56
+ if notice.source != 'TED':
57
+ return False
58
+ dates = notice.raw.get('deadline-receipt-tender-date-lot') or []
59
+ first = min((value for value in dates if value), default='')
60
+ return bool(first and len(first) <= 16 and 'T' not in first)
61
+
62
+
63
+ def tab(label, href, active):
64
+ current = ' aria-current="page"' if active else ''
65
+ return f'<a href="{href}"{current}>{escape(label)}</a>'
66
+
67
+
68
+ def notice_row(notice, deadline, date_only):
69
+ title = escape(notice.title or 'Ogłoszenie bez tytułu')
70
+ buyer = escape(notice.buyer_name or 'Nie podano zamawiającego')
71
+ published = escape(str(notice.published or ''))
72
+ due = ''
73
+ if deadline:
74
+ date_text = deadline.strftime('%d.%m.%Y' if date_only else '%d.%m.%Y, %H:%M')
75
+ due = f'<span>Termin: {date_text}</span>'
76
+ link = ''
77
+ for url in (notice.url, notice.platform_url):
78
+ if not url:
79
+ continue
80
+ try:
81
+ parts = urlsplit(url)
82
+ except ValueError:
83
+ continue
84
+ if parts.scheme in ('http', 'https') and parts.hostname:
85
+ link = (f'<a class="open" href="{escape(url)}" target="_blank" rel="noopener noreferrer"'
86
+ f' aria-label="Otwórz ogłoszenie: {title}">↗</a>')
87
+ break
88
+ return f'''<li class="notice">
89
+ <div>
90
+ <div class="metadata"><span class="badge">{escape(notice.source)}</span><time>{published}</time>{due}</div>
91
+ <h2>{title}</h2>
92
+ <p>{buyer}</p>
93
+ </div>
94
+ {link}
95
+ </li>'''
96
+
97
+
98
+ def render(template, state, profiles, params):
99
+ active, query, source = (params[key] for key in ('profile', 'q', 'source'))
100
+ cpv, deadline_filter, order = (params[key] for key in ('cpv', 'deadline', 'sort'))
101
+ start = date.fromisoformat(params['from']) if params['from'] else None
102
+ end = date.fromisoformat(params['to']) if params['to'] else None
103
+ page = int(params['page'])
104
+ profile = profiles[active]
105
+ terms = fold(query).split()
106
+ warsaw = ZoneInfo('Europe/Warsaw')
107
+ now = datetime.now(timezone.utc)
108
+ found_sources, matches = set(), []
109
+ offset = (page - 1) * PAGE_SIZE
110
+ for notice in filtered(state, profile):
111
+ found_sources.add(notice.source)
112
+ if source and notice.source != source:
113
+ continue
114
+ if start and (not notice.published or notice.published < start):
115
+ continue
116
+ if end and (not notice.published or notice.published > end):
117
+ continue
118
+ if cpv and not any(code.startswith(cpv) for code in notice.cpv):
119
+ continue
120
+ if terms:
121
+ haystack = fold(f'{notice.title or ""} {notice.buyer_name or ""}')
122
+ if not all(term in haystack for term in terms):
123
+ continue
124
+ deadline = local_deadline(notice, warsaw)
125
+ date_only = deadline_date_only(notice)
126
+ if deadline and date_only:
127
+ # Date-only TED fields were stored at midnight with the source offset.
128
+ deadline = notice.deadline.replace(tzinfo=warsaw)
129
+ deadline_utc = deadline.astimezone(timezone.utc) if deadline else None
130
+ if deadline is None:
131
+ status = 'missing'
132
+ elif date_only and deadline.date() == now.astimezone(warsaw).date():
133
+ status = 'today'
134
+ else:
135
+ status = 'upcoming' if deadline_utc >= now else 'passed'
136
+ if deadline_filter and status != deadline_filter:
137
+ continue
138
+ matches.append((replace(notice, raw={}, lots=()), deadline, deadline_utc, date_only))
139
+
140
+ if order == 'oldest':
141
+ matches.sort(key=lambda item: (item[0].published is None, item[0].published))
142
+ elif order == 'deadline':
143
+ matches.sort(key=lambda item: (item[2] is None, item[2]))
144
+ elif order in ('title', 'buyer'):
145
+ field = 'title' if order == 'title' else 'buyer_name'
146
+ matches.sort(key=lambda item: (not getattr(item[0], field), fold(getattr(item[0], field) or '')))
147
+ total = len(matches)
148
+ visible = matches[offset:offset + PAGE_SIZE]
149
+
150
+ def link(**changes):
151
+ return page_link({**params, 'page': 1, **changes})
152
+
153
+ profile_tabs = ''.join(tab(PROFILE_LABELS.get(name, name), link(profile=name, source=''), name == active)
154
+ for name in profiles)
155
+ source_tabs = ''.join(tab(label, link(source=name), source == name)
156
+ for name, label in [('', 'Wszystkie')] +
157
+ [(name, name) for name in sorted(found_sources | ({source} if source else set()))])
158
+ count = f'{offset + 1}–{offset + len(visible)} z {total} ogłoszeń' if visible else f'{total} ogłoszeń'
159
+ pagination = []
160
+ if page > 1:
161
+ pagination.append(tab('← Poprzednia', link(page=page - 1), False))
162
+ if offset + PAGE_SIZE < total:
163
+ pagination.append(tab('Następna →', link(page=page + 1), False))
164
+ rows = ''.join(notice_row(notice, deadline, date_only) for notice, deadline, _, date_only in visible)
165
+ return template.substitute(
166
+ profile_tabs=profile_tabs, source_tabs=source_tabs, profile=escape(active),
167
+ source=escape(source), query=escape(query), count=count,
168
+ published_from=escape(params['from']), published_to=escape(params['to']), cpv=escape(cpv),
169
+ deadline_options=options(DEADLINE_LABELS, deadline_filter), sort_options=options(SORT_LABELS, order),
170
+ reset_link=page_link({'profile': active}),
171
+ criteria=''.join(f'<span>CPV {escape(code)}</span>' for code in profile.cpv)
172
+ + ''.join(f'<span>bez CPV {escape(code)}</span>' for code in profile.exclude_cpv),
173
+ rows=f'<ul class="notices">{rows}</ul>' if rows else
174
+ '<p class="empty">Brak wyników na tej stronie. Zmień filtry lub wybierz inny profil.</p>',
175
+ pagination=''.join(pagination),
176
+ )
177
+
178
+
179
+ def serve(path, profiles, initial, port=8765):
180
+ if not 0 <= port <= 65535:
181
+ raise ValueError('Port must be between 0 and 65535')
182
+ state = State(path, read_only=True)
183
+ try:
184
+ with state.connection() as connection:
185
+ connection.execute('select 1 from notices limit 1').fetchone()
186
+ except sqlite3.Error as exc:
187
+ raise ValueError(f'Nie można otworzyć bazy ogłoszeń: {state.path}') from exc
188
+
189
+ assets = files('tenderkit').joinpath('web')
190
+ template = Template(assets.joinpath('index.html').read_text(encoding='utf-8'))
191
+ css = assets.joinpath('style.css').read_bytes()
192
+
193
+ class Handler(BaseHTTPRequestHandler):
194
+ def do_GET(self):
195
+ port = self.server.server_port
196
+ if self.headers.get('Host') not in (f'127.0.0.1:{port}', f'localhost:{port}'):
197
+ return self.send_error(403)
198
+ path, _, params = self.path.partition('?')
199
+ if path == '/style.css':
200
+ return self.respond(css, 'text/css')
201
+ if path != '/':
202
+ return self.send_error(404)
203
+ try:
204
+ values = dict(parse_qsl(params, max_num_fields=12))
205
+ filters = dict(profile=initial, q='', source='', cpv='', deadline='', sort='newest', page='1')
206
+ filters.update({'from': '', 'to': ''})
207
+ filters = {key: values.get(key, default).strip() for key, default in filters.items()}
208
+ for key in ('from', 'to'):
209
+ if filters[key]:
210
+ filters[key] = date.fromisoformat(filters[key]).isoformat()
211
+ if (filters['profile'] not in profiles or len(filters['q']) > 200 or len(filters['source']) > 100
212
+ or not re.fullmatch(r'[0-9]{0,8}', filters['cpv']) or int(filters['page']) < 1
213
+ or filters['deadline'] not in DEADLINE_LABELS or filters['sort'] not in SORT_LABELS
214
+ or filters['to'] and filters['from'] > filters['to']):
215
+ raise ValueError
216
+ except ValueError:
217
+ return self.send_error(400, 'Invalid filter')
218
+ try:
219
+ body = render(template, state, profiles, filters)
220
+ except sqlite3.Error:
221
+ return self.send_error(503, 'Database unavailable')
222
+ self.respond(body.encode('utf-8'), 'text/html')
223
+
224
+ def respond(self, body, content_type):
225
+ self.send_response(200)
226
+ self.send_header('Content-Type', content_type + '; charset=utf-8')
227
+ self.send_header('Content-Length', str(len(body)))
228
+ self.send_header('Cache-Control', 'no-store')
229
+ self.send_header('Content-Security-Policy',
230
+ "default-src 'none'; style-src 'self'; form-action 'self'; frame-ancestors 'none'")
231
+ self.send_header('Referrer-Policy', 'no-referrer')
232
+ self.end_headers()
233
+ self.wfile.write(body)
234
+
235
+ def log_message(self, format, *args):
236
+ pass
237
+
238
+ with ThreadingHTTPServer(('127.0.0.1', port), Handler) as server:
239
+ print(f'tenderkit UI: http://127.0.0.1:{server.server_port}/', flush=True)
240
+ try:
241
+ server.serve_forever()
242
+ except KeyboardInterrupt:
243
+ pass