Skip to content

Commit 89a6d12

Browse files
authored
Merge pull request #589 from remittor-pr/bottle
[python] Add bottle framework
2 parents d2086c1 + 836e8e8 commit 89a6d12

7 files changed

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

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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"display_name": "bottle",
3+
"language": "Python",
4+
"type": "tuned",
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-tls",
15+
"upload",
16+
"api-4",
17+
"api-16",
18+
"async-db",
19+
"static"
20+
],
21+
"maintainers": []
22+
}

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)