Skip to content

Commit 67ffd52

Browse files
committed
[python] Add bottle framework
1 parent 960fd7b commit 67ffd52

7 files changed

Lines changed: 347 additions & 0 deletions

File tree

frameworks/bottle/Dockerfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
FROM python:3.13-slim
2+
WORKDIR /app
3+
COPY requirements.txt .
4+
RUN pip install --no-cache-dir -r requirements.txt
5+
COPY . .
6+
EXPOSE 8080 8081
7+
CMD [ "python3", "launcher.py", "gunicorn", "--config", "gunicorn_conf.py", "app:app" ]

frameworks/bottle/app.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import os
2+
import sys
3+
import multiprocessing
4+
import json
5+
import gzip
6+
import mimetypes
7+
8+
import psycopg_pool
9+
import psycopg.rows
10+
11+
from bottle import Bottle, route, request, response, hook, static_file
12+
13+
14+
app = Bottle()
15+
16+
17+
# -- Dataset and constants --------------------------------------------------------
18+
19+
CPU_COUNT = int(multiprocessing.cpu_count())
20+
WRK_COUNT = min(len(os.sched_getaffinity(0)), 128)
21+
WRK_COUNT = max(WRK_COUNT, 4)
22+
23+
DATASET_LARGE_PATH = "/data/dataset-large.json"
24+
DATASET_PATH = os.environ.get("DATASET_PATH", "/data/dataset.json")
25+
DATASET_ITEMS = None
26+
try:
27+
with open(DATASET_PATH) as file:
28+
DATASET_ITEMS = json.load(file)
29+
except Exception:
30+
pass
31+
32+
33+
# -- Postgres DB ------------------------------------------------------------
34+
35+
DATABASE_URL = os.environ.get("DATABASE_URL", '')
36+
DATABASE_POOL = None
37+
DATABASE_QUERY = (
38+
"SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count"
39+
" FROM items"
40+
" WHERE price BETWEEN %s AND %s LIMIT %s"
41+
)
42+
if DATABASE_URL and DATABASE_URL.startswith("postgres://"):
43+
DATABASE_URL = "postgresql://" + DATABASE_URL[len("postgres://"):]
44+
45+
PG_POOL_MIN_SIZE = 1
46+
PG_POOL_MAX_SIZE = 2
47+
48+
def db_close():
49+
global DATABASE_POOL
50+
if DATABASE_POOL:
51+
try:
52+
DATABASE_POOL.close()
53+
except Exception:
54+
pass
55+
DATABASE_POOL = None
56+
57+
def db_setup():
58+
global DATABASE_POOL, DATABASE_URL, PG_POOL_MIN_SIZE, PG_POOL_MAX_SIZE, WRK_COUNT
59+
db_close()
60+
if not DATABASE_URL:
61+
return
62+
DATABASE_MAX_CONN = os.environ.get("DATABASE_MAX_CONN", None)
63+
if DATABASE_MAX_CONN:
64+
avr_pool_size = int(DATABASE_MAX_CONN) * 0.92 / WRK_COUNT
65+
#PG_POOL_MIN_SIZE = int(avr_pool_size + 0.35)
66+
PG_POOL_MAX_SIZE = int(avr_pool_size + 0.95)
67+
try:
68+
DATABASE_POOL = psycopg_pool.ConnectionPool(
69+
conninfo = DATABASE_URL,
70+
min_size = max(PG_POOL_MIN_SIZE, 1),
71+
max_size = max(PG_POOL_MAX_SIZE, 2),
72+
kwargs = { 'row_factory': psycopg.rows.dict_row },
73+
)
74+
#DATABASE_POOL.wait()
75+
except Exception:
76+
DATABASE_POOL = None
77+
78+
db_setup()
79+
80+
81+
# -- framework features ----------------------------------------------------------
82+
83+
@hook('after_request')
84+
def compress_response():
85+
if response.status_code < 200 or response.status_code in (204, 304, 206):
86+
return
87+
88+
accept_encoding = request.headers.get('Accept-Encoding', '')
89+
if 'gzip' not in accept_encoding:
90+
return
91+
92+
if response.headers.get('Content-Encoding'):
93+
return
94+
95+
if response.content_length == 0:
96+
return
97+
98+
body = b''
99+
try:
100+
for chunk in response.body:
101+
if isinstance(chunk, str):
102+
chunk = chunk.encode('utf-8')
103+
body += chunk
104+
response.body.close()
105+
except Exception:
106+
return
107+
108+
compressed_body = gzip.compress(body, compresslevel = 1)
109+
response.body = compressed_body
110+
response.set_header('Content-Encoding', 'gzip')
111+
response.set_header('Content-Length', str(len(compressed_body)))
112+
return
113+
114+
115+
# -- Routes ------------------------------------------------------------------
116+
117+
@app.route('/pipeline')
118+
def pipeline():
119+
return b'ok'
120+
121+
122+
@app.route('/baseline11', methods=['GET', 'POST'])
123+
def baseline11():
124+
total = 0
125+
try:
126+
total = int(request.query.a)
127+
total = int(request.query.b)
128+
except ValueError:
129+
pass
130+
if request.method == 'POST':
131+
try:
132+
total += int(request.body.read(100).strip())
133+
except ValueError:
134+
pass
135+
return str(total)
136+
137+
138+
@app.route('/json/<count:int>')
139+
@app.route('/json-comp/<count:int>')
140+
def json_endpoint(count: int):
141+
global DATASET_ITEMS
142+
if not DATASET_ITEMS:
143+
return "No dataset", '500 Internal Server Error'
144+
m_val = float(request.query.m)
145+
items = [ ]
146+
for idx, dsitem in enumerate(DATASET_ITEMS):
147+
if idx >= count:
148+
break
149+
item = dict(dsitem)
150+
item["total"] = dsitem["price"] * dsitem["quantity"] * m_val
151+
items.append(item)
152+
return { 'items': items, 'count': len(items) }
153+
154+
155+
@app.route('/async-db')
156+
def async_db_endpoint():
157+
global DATABASE_POOL
158+
if not DATABASE_POOL:
159+
return { "items": [ ], "count": 0 }
160+
try:
161+
min_val = float(request.query.min)
162+
max_val = float(request.query.max)
163+
limit = int(request.query.limit)
164+
with DATABASE_POOL.connection() as db_conn:
165+
rows = db_conn.execute(DATABASE_QUERY, (min_val, max_val, limit)).fetchall()
166+
items = [
167+
{
168+
'id' : row['id'],
169+
'name' : row['name'],
170+
'category': row['category'],
171+
'price' : row['price'],
172+
'quantity': row['quantity'],
173+
'active' : row['active'],
174+
'tags' : json.loads(row['tags']) if isinstance(row['tags'], str) else row['tags'],
175+
'rating': {
176+
'score': row['rating_score'],
177+
'count': row['rating_count'],
178+
}
179+
}
180+
for row in rows
181+
]
182+
return { "items": items, "count": len(items) }
183+
except Exception:
184+
return { "items": [ ], "count": 0 }
185+
186+
187+
@app.route('/upload', methods=['POST'])
188+
def upload_endpoint():
189+
size = 0
190+
while True:
191+
chunk = request.body.read(256*1024)
192+
if not chunk:
193+
break
194+
size += len(chunk)
195+
return str(size)
196+
197+
198+
mimetypes.add_type('.woff2', 'font/woff2')
199+
mimetypes.add_type('.webp', 'image/webp')
200+
201+
@app.route('/static/<filepath:path>')
202+
def send_static_file(filepath):
203+
return static_file(filepath, root = '/data/static')
204+

frameworks/bottle/gunicorn_conf.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import os
2+
import sys
3+
import multiprocessing
4+
import gunicorn
5+
6+
7+
_CPU_COUNT = int(multiprocessing.cpu_count())
8+
_WRK_COUNT = min(len(os.sched_getaffinity(0)), 128)
9+
_WRK_COUNT = max(_WRK_COUNT, 4)
10+
11+
12+
bind = "0.0.0.0:8080"
13+
workers = _WRK_COUNT
14+
keepalive = 120
15+
loglevel = 'critical'
16+
accesslog = None
17+
errorlog = "-"
18+
disable_redirect_access_to_syslog = True
19+
pidfile = "gunicorn.pid"
20+
worker_class = "sync"
21+
22+
gunicorn.SERVER_SOFTWARE = "Bottle"
23+
os.environ["SERVER_SOFTWARE"] = "Bottle"
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import os
2+
import sys
3+
import multiprocessing
4+
import gunicorn
5+
6+
7+
_CPU_COUNT = int(multiprocessing.cpu_count())
8+
_WRK_COUNT = min(len(os.sched_getaffinity(0)), 128)
9+
_WRK_COUNT = max(_WRK_COUNT, 4)
10+
11+
12+
bind = "0.0.0.0:8081"
13+
workers = _WRK_COUNT
14+
keepalive = 120
15+
loglevel = 'critical'
16+
accesslog = None
17+
errorlog = "-"
18+
disable_redirect_access_to_syslog = True
19+
pidfile = "gunicorn_ssl.pid"
20+
worker_class = "sync"
21+
22+
gunicorn.SERVER_SOFTWARE = "Bottle"
23+
os.environ["SERVER_SOFTWARE"] = "Bottle"
24+
25+
default_proc_name = 'gunicorn_ssl'
26+
27+
certfile = os.environ.get("TLS_CERT", "/certs/server.crt")
28+
keyfile = os.environ.get("TLS_KEY" , "/certs/server.key")

frameworks/bottle/launcher.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import os
2+
import sys
3+
import multiprocessing
4+
import subprocess
5+
import signal
6+
import time
7+
8+
9+
CPU_COUNT = int(multiprocessing.cpu_count())
10+
WRK_COUNT = min(len(os.sched_getaffinity(0)), 128)
11+
WRK_COUNT = max(WRK_COUNT, 4)
12+
13+
14+
if len(sys.argv) < 2:
15+
print("Usage: launcher.py <program> [args...]", file=sys.stderr)
16+
sys.exit(1)
17+
18+
args = sys.argv[1:] # [ "gunicorn", "--config", "gunicorn_conf.py", "app:app" ]
19+
20+
def run_prog(args: list, ssl: bool = False):
21+
config_idx = 0
22+
try:
23+
config_idx = args.index("--config") + 1
24+
base_config = args[config_idx]
25+
except Exception:
26+
config_idx = 0
27+
base_config = ''
28+
cmd = list(args)
29+
if ssl and (config_idx == 0 or not base_config):
30+
return None
31+
if ssl:
32+
cmd[config_idx] = 'gunicorn_conf_ssl.py'
33+
return subprocess.Popen(cmd)
34+
35+
36+
http_proc = run_prog(args)
37+
38+
https_proc = run_prog(args, ssl = True)
39+
40+
def shutdown(sig, frame):
41+
http_proc.terminate()
42+
https_proc.terminate() if https_proc else None
43+
time.sleep(1)
44+
if http_proc.poll() is None:
45+
http_proc.kill()
46+
if https_proc and https_proc.poll() is None:
47+
https_proc.kill()
48+
sys.exit(0)
49+
50+
signal.signal(signal.SIGTERM, shutdown)
51+
signal.signal(signal.SIGINT, shutdown)
52+
53+
try:
54+
http_proc.wait()
55+
https_proc.terminate() if https_proc else None
56+
except Exception:
57+
pass
58+

frameworks/bottle/meta.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"display_name": "bottle",
3+
"language": "Python",
4+
"type": "production",
5+
"engine": "gunicorn",
6+
"description": "Fast, simple and lightweight WSGI micro web-framework for Python",
7+
"repo": "https://github.com/bottlepy/bottle",
8+
"enabled": true,
9+
"tests": [
10+
"baseline",
11+
"pipelined",
12+
"limited-conn",
13+
"json",
14+
"json-comp",
15+
"json-tls",
16+
"upload",
17+
"api-4",
18+
"api-16",
19+
"async-db",
20+
"static"
21+
],
22+
"maintainers": []
23+
}

frameworks/bottle/requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
bottle==0.13.4
2+
gunicorn==25.3.0
3+
psycopg[binary]==3.3.3
4+
psycopg_pool==3.3.0

0 commit comments

Comments
 (0)