bitpoint 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.
- bitpoint/__init__.py +8 -0
- bitpoint/__main__.py +108 -0
- bitpoint/app.py +158 -0
- bitpoint/deps.py +118 -0
- bitpoint/loader.py +89 -0
- bitpoint/request.py +60 -0
- bitpoint/response.py +43 -0
- bitpoint/router.py +91 -0
- bitpoint/worker.py +89 -0
- bitpoint/workers.py +125 -0
- bitpoint-0.1.0.dist-info/METADATA +299 -0
- bitpoint-0.1.0.dist-info/RECORD +14 -0
- bitpoint-0.1.0.dist-info/WHEEL +4 -0
- bitpoint-0.1.0.dist-info/licenses/LICENSE +21 -0
bitpoint/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Bitpoint: create HTTP endpoints quickly using files.
|
|
2
|
+
|
|
3
|
+
This module is intentionally import-light: it is imported inside the
|
|
4
|
+
per-endpoint environments (which only need `bitpoint.request` and
|
|
5
|
+
`bitpoint.response`), so nothing here may import Flask.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
bitpoint/__main__.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Command-line entry point: python -m bitpoint [options]."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import atexit
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import __version__, router
|
|
11
|
+
from .app import create_app
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger("bitpoint")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv=None):
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="python -m bitpoint",
|
|
19
|
+
description="Create HTTP endpoints quickly using files.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--port", type=int, default=8000, help="listening port")
|
|
22
|
+
parser.add_argument("--host", default="127.0.0.1", help="listening address")
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--dir", default="./routes", help="root directory for endpoints"
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--production",
|
|
28
|
+
action="store_true",
|
|
29
|
+
help="hide tracebacks and disable filesystem-based hot reload",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--no-install",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="do not install dependencies automatically",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--version", action="version", version=f"bitpoint {__version__}"
|
|
38
|
+
)
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
|
|
41
|
+
routes_dir = os.path.abspath(args.dir)
|
|
42
|
+
if not os.path.isdir(routes_dir):
|
|
43
|
+
parser.error(f"routes directory not found: {routes_dir}")
|
|
44
|
+
|
|
45
|
+
logging.basicConfig(
|
|
46
|
+
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
app = create_app(
|
|
50
|
+
routes_dir, production=args.production, install=not args.no_install
|
|
51
|
+
)
|
|
52
|
+
state = app.extensions["bitpoint"]
|
|
53
|
+
|
|
54
|
+
endpoints = list(router.iter_endpoints(routes_dir))
|
|
55
|
+
if endpoints:
|
|
56
|
+
for method, url, file_path in endpoints:
|
|
57
|
+
rel = os.path.join(
|
|
58
|
+
os.path.basename(routes_dir), os.path.relpath(file_path, routes_dir)
|
|
59
|
+
)
|
|
60
|
+
log.info("%-7s %s (%s)", method, url, rel)
|
|
61
|
+
else:
|
|
62
|
+
log.warning("No endpoints found under %s", routes_dir)
|
|
63
|
+
if not args.no_install:
|
|
64
|
+
state["deps"].sync_all(routes_dir)
|
|
65
|
+
|
|
66
|
+
if hasattr(signal, "SIGHUP"):
|
|
67
|
+
|
|
68
|
+
def _reload(signum, frame):
|
|
69
|
+
log.info("SIGHUP received, reloading endpoints")
|
|
70
|
+
state["loader"].clear()
|
|
71
|
+
state["deps"].clear()
|
|
72
|
+
state["pool"].kill_all()
|
|
73
|
+
|
|
74
|
+
signal.signal(signal.SIGHUP, _reload)
|
|
75
|
+
|
|
76
|
+
if args.production:
|
|
77
|
+
pid_file = os.path.abspath("bitpoint.pid")
|
|
78
|
+
with open(pid_file, "w", encoding="utf-8") as fh:
|
|
79
|
+
fh.write(str(os.getpid()))
|
|
80
|
+
|
|
81
|
+
def _remove_pid_file():
|
|
82
|
+
try:
|
|
83
|
+
os.remove(pid_file)
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
atexit.register(_remove_pid_file)
|
|
88
|
+
|
|
89
|
+
atexit.register(state["pool"].kill_all)
|
|
90
|
+
|
|
91
|
+
mode = "production" if args.production else "development"
|
|
92
|
+
log.info(
|
|
93
|
+
"Bitpoint %s serving %s on http://%s:%d (%s)",
|
|
94
|
+
__version__,
|
|
95
|
+
routes_dir,
|
|
96
|
+
args.host,
|
|
97
|
+
args.port,
|
|
98
|
+
mode,
|
|
99
|
+
)
|
|
100
|
+
try:
|
|
101
|
+
app.run(host=args.host, port=args.port, threaded=True, use_reloader=False)
|
|
102
|
+
except KeyboardInterrupt:
|
|
103
|
+
pass
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
sys.exit(main())
|
bitpoint/app.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""The Flask application that powers Bitpoint.
|
|
2
|
+
|
|
3
|
+
Flask is an implementation detail: a single catch-all view resolves the
|
|
4
|
+
URL against the routes directory on every request, then executes the
|
|
5
|
+
endpoint either in-process (no PEP 723 block) or through its persistent
|
|
6
|
+
worker (isolated environment).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import traceback
|
|
13
|
+
|
|
14
|
+
from flask import Flask, Response
|
|
15
|
+
from flask import request as flask_request
|
|
16
|
+
|
|
17
|
+
from . import router
|
|
18
|
+
from .deps import DependencyError, DepsManager
|
|
19
|
+
from .loader import Loader
|
|
20
|
+
from .request import CaseInsensitiveDict, HTTPError, Request
|
|
21
|
+
from .response import normalize
|
|
22
|
+
from .workers import WorkerCrash, WorkerPool
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("bitpoint")
|
|
25
|
+
|
|
26
|
+
ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_app(routes_dir="./routes", production=False, install=True):
|
|
30
|
+
routes_dir = os.path.abspath(routes_dir)
|
|
31
|
+
app = Flask("bitpoint")
|
|
32
|
+
loader = Loader(routes_dir, production=production)
|
|
33
|
+
deps = DepsManager(production=production, install=install)
|
|
34
|
+
pool = WorkerPool(routes_dir, production=production)
|
|
35
|
+
app.extensions["bitpoint"] = {
|
|
36
|
+
"loader": loader,
|
|
37
|
+
"deps": deps,
|
|
38
|
+
"pool": pool,
|
|
39
|
+
"routes_dir": routes_dir,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
def dispatch(path=""):
|
|
43
|
+
resolved = router.resolve(routes_dir, path)
|
|
44
|
+
if resolved is None:
|
|
45
|
+
pool.reap_deleted()
|
|
46
|
+
return _plain("404 Not Found\n", 404)
|
|
47
|
+
endpoint_dir, params = resolved
|
|
48
|
+
file_path = router.method_file(endpoint_dir, flask_request.method)
|
|
49
|
+
if file_path is None:
|
|
50
|
+
pool.reap_deleted()
|
|
51
|
+
allowed = router.allowed_methods(endpoint_dir)
|
|
52
|
+
if not allowed:
|
|
53
|
+
return _plain("404 Not Found\n", 404)
|
|
54
|
+
return _plain(
|
|
55
|
+
"405 Method Not Allowed\n", 405, extra={"Allow": ", ".join(allowed)}
|
|
56
|
+
)
|
|
57
|
+
try:
|
|
58
|
+
return _execute(file_path, params)
|
|
59
|
+
except HTTPError as exc:
|
|
60
|
+
return _plain(exc.message + "\n", exc.status)
|
|
61
|
+
except (DependencyError, WorkerCrash) as exc:
|
|
62
|
+
log.error("%s", exc)
|
|
63
|
+
body = "Internal Server Error\n" if production else str(exc) + "\n"
|
|
64
|
+
return _plain(body, 500)
|
|
65
|
+
except Exception:
|
|
66
|
+
log.exception("Unhandled error in %s", file_path)
|
|
67
|
+
body = "Internal Server Error\n" if production else traceback.format_exc()
|
|
68
|
+
return _plain(body, 500)
|
|
69
|
+
|
|
70
|
+
def _execute(file_path, params):
|
|
71
|
+
python = deps.python_for(file_path)
|
|
72
|
+
if python is None:
|
|
73
|
+
request = Request(
|
|
74
|
+
method=flask_request.method,
|
|
75
|
+
path=flask_request.path,
|
|
76
|
+
params=params,
|
|
77
|
+
args=flask_request.args.to_dict(),
|
|
78
|
+
headers=CaseInsensitiveDict(dict(flask_request.headers)),
|
|
79
|
+
body=flask_request.get_data(),
|
|
80
|
+
)
|
|
81
|
+
module = loader.load(file_path)
|
|
82
|
+
handler = getattr(module, "handle", None)
|
|
83
|
+
if handler is None:
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
f"{os.path.relpath(file_path)} does not define a handle(request) function"
|
|
86
|
+
)
|
|
87
|
+
body, status, headers, content_type = normalize(handler(request))
|
|
88
|
+
else:
|
|
89
|
+
payload = {
|
|
90
|
+
"method": flask_request.method,
|
|
91
|
+
"path": flask_request.path,
|
|
92
|
+
"params": params,
|
|
93
|
+
"args": flask_request.args.to_dict(),
|
|
94
|
+
"headers": dict(flask_request.headers),
|
|
95
|
+
"body": base64.b64encode(flask_request.get_data()).decode("ascii"),
|
|
96
|
+
}
|
|
97
|
+
result = pool.request(file_path, python, payload)
|
|
98
|
+
error = result.get("error")
|
|
99
|
+
if error is not None:
|
|
100
|
+
if error.get("status"):
|
|
101
|
+
raise HTTPError(error["status"], error.get("message", ""))
|
|
102
|
+
detail = error.get("traceback", "unknown endpoint error")
|
|
103
|
+
if "ModuleNotFoundError" in detail and not install:
|
|
104
|
+
detail += (
|
|
105
|
+
"\nDependencies for this endpoint are not installed and "
|
|
106
|
+
"--no-install is active. Install them (uv sync --script "
|
|
107
|
+
f"{os.path.relpath(file_path)}) or start without --no-install."
|
|
108
|
+
)
|
|
109
|
+
return _endpoint_error(detail, production)
|
|
110
|
+
body = base64.b64decode(result["body"])
|
|
111
|
+
status = result["status"]
|
|
112
|
+
headers = result["headers"]
|
|
113
|
+
content_type = result["content_type"]
|
|
114
|
+
return _build_response(body, status, headers, content_type)
|
|
115
|
+
|
|
116
|
+
app.add_url_rule(
|
|
117
|
+
"/",
|
|
118
|
+
endpoint="bitpoint_root",
|
|
119
|
+
view_func=dispatch,
|
|
120
|
+
defaults={"path": ""},
|
|
121
|
+
methods=ALL_METHODS,
|
|
122
|
+
provide_automatic_options=False,
|
|
123
|
+
)
|
|
124
|
+
app.add_url_rule(
|
|
125
|
+
"/<path:path>",
|
|
126
|
+
endpoint="bitpoint_dispatch",
|
|
127
|
+
view_func=dispatch,
|
|
128
|
+
methods=ALL_METHODS,
|
|
129
|
+
provide_automatic_options=False,
|
|
130
|
+
)
|
|
131
|
+
return app
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _endpoint_error(detail, production):
|
|
135
|
+
log.error("Endpoint error:\n%s", detail)
|
|
136
|
+
body = "Internal Server Error\n" if production else detail
|
|
137
|
+
return _plain(body, 500)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _build_response(body, status, headers, content_type):
|
|
141
|
+
response = Response(body, status=status)
|
|
142
|
+
response.headers.remove("Content-Type")
|
|
143
|
+
has_content_type = False
|
|
144
|
+
for key, value in headers:
|
|
145
|
+
if key.lower() == "content-type":
|
|
146
|
+
has_content_type = True
|
|
147
|
+
response.headers[key] = value
|
|
148
|
+
if content_type and not has_content_type:
|
|
149
|
+
response.headers["Content-Type"] = content_type
|
|
150
|
+
return response
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _plain(body, status, extra=None):
|
|
154
|
+
response = Response(body, status=status, mimetype="text/plain")
|
|
155
|
+
if extra:
|
|
156
|
+
for key, value in extra.items():
|
|
157
|
+
response.headers[key] = value
|
|
158
|
+
return response
|
bitpoint/deps.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""PEP 723 dependency handling, delegated to uv.
|
|
2
|
+
|
|
3
|
+
For each endpoint with a script block, `uv sync --script` materializes a
|
|
4
|
+
cached environment and `uv python find --script` locates its interpreter.
|
|
5
|
+
That interpreter is what the worker pool uses to spawn the endpoint's
|
|
6
|
+
persistent worker process.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import threading
|
|
16
|
+
|
|
17
|
+
from . import router
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("bitpoint")
|
|
20
|
+
|
|
21
|
+
# Canonical PEP 723 regex, from the spec.
|
|
22
|
+
PEP723_RE = re.compile(
|
|
23
|
+
r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DependencyError(RuntimeError):
|
|
28
|
+
"""uv is missing, or dependency resolution/installation failed."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def script_block(source):
|
|
32
|
+
"""Return the PEP 723 `script` block of a source file, or None."""
|
|
33
|
+
for match in PEP723_RE.finditer(source):
|
|
34
|
+
if match.group("type") == "script":
|
|
35
|
+
return match.group(0)
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DepsManager:
|
|
40
|
+
"""Decides how each endpoint runs and keeps its environment in sync.
|
|
41
|
+
|
|
42
|
+
python_for() returns None for endpoints without a PEP 723 block (they
|
|
43
|
+
run in-process, in the base environment) or the path to the isolated
|
|
44
|
+
environment's interpreter otherwise.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, production=False, install=True):
|
|
48
|
+
self.production = production
|
|
49
|
+
self.install = install
|
|
50
|
+
self._cache = {} # file_path -> (mtime_ns, block_digest, python_path)
|
|
51
|
+
self._lock = threading.Lock()
|
|
52
|
+
|
|
53
|
+
def python_for(self, file_path):
|
|
54
|
+
file_path = os.path.abspath(file_path)
|
|
55
|
+
with self._lock:
|
|
56
|
+
cached = self._cache.get(file_path)
|
|
57
|
+
if cached is not None and self.production:
|
|
58
|
+
return cached[2]
|
|
59
|
+
mtime = os.stat(file_path).st_mtime_ns
|
|
60
|
+
if cached is not None and cached[0] == mtime:
|
|
61
|
+
return cached[2]
|
|
62
|
+
with open(file_path, encoding="utf-8") as fh:
|
|
63
|
+
block = script_block(fh.read())
|
|
64
|
+
if block is None:
|
|
65
|
+
self._cache[file_path] = (mtime, None, None)
|
|
66
|
+
return None
|
|
67
|
+
digest = hashlib.sha256(block.encode("utf-8")).hexdigest()
|
|
68
|
+
if cached is not None and cached[1] == digest and cached[2] is not None:
|
|
69
|
+
# The file changed but the dependency block did not: no resync.
|
|
70
|
+
self._cache[file_path] = (mtime, digest, cached[2])
|
|
71
|
+
return cached[2]
|
|
72
|
+
python = self._prepare(file_path)
|
|
73
|
+
self._cache[file_path] = (mtime, digest, python)
|
|
74
|
+
return python
|
|
75
|
+
|
|
76
|
+
def sync_all(self, routes_dir):
|
|
77
|
+
"""Install every endpoint's dependencies (used at startup)."""
|
|
78
|
+
for _, url, file_path in router.iter_endpoints(routes_dir):
|
|
79
|
+
with open(file_path, encoding="utf-8") as fh:
|
|
80
|
+
if script_block(fh.read()) is None:
|
|
81
|
+
continue
|
|
82
|
+
log.info(
|
|
83
|
+
"Syncing dependencies for %s",
|
|
84
|
+
os.path.relpath(file_path, os.path.dirname(routes_dir) or routes_dir),
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
self.python_for(file_path)
|
|
88
|
+
except DependencyError as exc:
|
|
89
|
+
log.error("%s", exc)
|
|
90
|
+
|
|
91
|
+
def clear(self):
|
|
92
|
+
with self._lock:
|
|
93
|
+
self._cache.clear()
|
|
94
|
+
|
|
95
|
+
def _prepare(self, file_path):
|
|
96
|
+
if shutil.which("uv") is None:
|
|
97
|
+
raise DependencyError(
|
|
98
|
+
f"{os.path.relpath(file_path)} declares PEP 723 dependencies, but uv "
|
|
99
|
+
"is not on PATH. Install it: https://docs.astral.sh/uv/"
|
|
100
|
+
)
|
|
101
|
+
if self.install:
|
|
102
|
+
result = _run(["uv", "sync", "--script", file_path])
|
|
103
|
+
if result.returncode != 0:
|
|
104
|
+
raise DependencyError(
|
|
105
|
+
f"uv sync failed for {os.path.relpath(file_path)}:\n"
|
|
106
|
+
+ result.stderr.strip()
|
|
107
|
+
)
|
|
108
|
+
result = _run(["uv", "python", "find", "--script", file_path])
|
|
109
|
+
if result.returncode != 0:
|
|
110
|
+
raise DependencyError(
|
|
111
|
+
f"could not resolve an interpreter for {os.path.relpath(file_path)}:\n"
|
|
112
|
+
+ result.stderr.strip()
|
|
113
|
+
)
|
|
114
|
+
return result.stdout.strip().splitlines()[-1]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _run(cmd):
|
|
118
|
+
return subprocess.run(cmd, capture_output=True, text=True)
|
bitpoint/loader.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""In-process loading of endpoints that have no PEP 723 block.
|
|
2
|
+
|
|
3
|
+
Reloading never uses importlib.reload: a changed file is re-executed as
|
|
4
|
+
a brand-new module object, so no stale state can survive. Shared code
|
|
5
|
+
under routes/lib is tracked too; when any lib file changes, everything
|
|
6
|
+
is evicted and re-imported fresh.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import types
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Loader:
|
|
16
|
+
def __init__(self, routes_dir, production=False):
|
|
17
|
+
self.routes_dir = os.path.abspath(routes_dir)
|
|
18
|
+
self.production = production
|
|
19
|
+
self._cache = {} # file_path -> (mtime_ns, module)
|
|
20
|
+
self._lib_mtimes = None
|
|
21
|
+
self._lock = threading.Lock()
|
|
22
|
+
# lib/ modules are imported normally; without this, a re-import
|
|
23
|
+
# after eviction could hit a stale __pycache__ entry.
|
|
24
|
+
sys.dont_write_bytecode = True
|
|
25
|
+
if self.routes_dir not in sys.path:
|
|
26
|
+
sys.path.insert(0, self.routes_dir)
|
|
27
|
+
self._evict_lib() # drop any 'lib' left over from a previous routes dir
|
|
28
|
+
|
|
29
|
+
def load(self, file_path):
|
|
30
|
+
file_path = os.path.abspath(file_path)
|
|
31
|
+
with self._lock:
|
|
32
|
+
if not self.production:
|
|
33
|
+
self._refresh_lib()
|
|
34
|
+
cached = self._cache.get(file_path)
|
|
35
|
+
if cached is not None:
|
|
36
|
+
if self.production or os.stat(file_path).st_mtime_ns == cached[0]:
|
|
37
|
+
return cached[1]
|
|
38
|
+
mtime = os.stat(file_path).st_mtime_ns
|
|
39
|
+
module = self._execute(file_path)
|
|
40
|
+
self._cache[file_path] = (mtime, module)
|
|
41
|
+
return module
|
|
42
|
+
|
|
43
|
+
def clear(self):
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._cache.clear()
|
|
46
|
+
self._lib_mtimes = None
|
|
47
|
+
self._evict_lib()
|
|
48
|
+
for name in [n for n in sys.modules if n.startswith("bitpoint_routes.")]:
|
|
49
|
+
del sys.modules[name]
|
|
50
|
+
|
|
51
|
+
def _execute(self, file_path):
|
|
52
|
+
rel = os.path.relpath(file_path, self.routes_dir)
|
|
53
|
+
name = "bitpoint_routes." + rel[:-3].replace(os.sep, ".").replace(
|
|
54
|
+
"[", "_"
|
|
55
|
+
).replace("]", "_")
|
|
56
|
+
# Compile straight from source, never through the .pyc cache: its
|
|
57
|
+
# validator only looks at whole-second mtime and file size, which
|
|
58
|
+
# can serve stale code right after a save. Reload must be reliable.
|
|
59
|
+
with open(file_path, encoding="utf-8") as fh:
|
|
60
|
+
source = fh.read()
|
|
61
|
+
module = types.ModuleType(name)
|
|
62
|
+
module.__file__ = file_path
|
|
63
|
+
sys.modules[name] = module
|
|
64
|
+
code = compile(source, file_path, "exec")
|
|
65
|
+
exec(code, module.__dict__)
|
|
66
|
+
return module
|
|
67
|
+
|
|
68
|
+
def _refresh_lib(self):
|
|
69
|
+
lib_dir = os.path.join(self.routes_dir, "lib")
|
|
70
|
+
mtimes = {}
|
|
71
|
+
for dirpath, _, filenames in os.walk(lib_dir):
|
|
72
|
+
for filename in filenames:
|
|
73
|
+
if filename.endswith(".py"):
|
|
74
|
+
path = os.path.join(dirpath, filename)
|
|
75
|
+
try:
|
|
76
|
+
mtimes[path] = os.stat(path).st_mtime_ns
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
if self._lib_mtimes is None:
|
|
80
|
+
self._lib_mtimes = mtimes
|
|
81
|
+
elif mtimes != self._lib_mtimes:
|
|
82
|
+
self._lib_mtimes = mtimes
|
|
83
|
+
self._cache.clear()
|
|
84
|
+
self._evict_lib()
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def _evict_lib():
|
|
88
|
+
for name in [n for n in sys.modules if n == "lib" or n.startswith("lib.")]:
|
|
89
|
+
del sys.modules[name]
|
bitpoint/request.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The request object passed to every handle() function.
|
|
2
|
+
|
|
3
|
+
This module must stay free of Flask imports: it is also imported inside
|
|
4
|
+
the per-endpoint worker environments, where only the endpoint's own
|
|
5
|
+
dependencies are installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HTTPError(Exception):
|
|
12
|
+
"""An error with an associated HTTP status code."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, status, message):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.status = status
|
|
17
|
+
self.message = message
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CaseInsensitiveDict(dict):
|
|
21
|
+
"""A dict with case-insensitive string keys (stored lowercase)."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, data=None):
|
|
24
|
+
super().__init__()
|
|
25
|
+
if data:
|
|
26
|
+
items = data.items() if hasattr(data, "items") else data
|
|
27
|
+
for key, value in items:
|
|
28
|
+
self[key] = value
|
|
29
|
+
|
|
30
|
+
def __setitem__(self, key, value):
|
|
31
|
+
super().__setitem__(key.lower(), value)
|
|
32
|
+
|
|
33
|
+
def __getitem__(self, key):
|
|
34
|
+
return super().__getitem__(key.lower())
|
|
35
|
+
|
|
36
|
+
def __contains__(self, key):
|
|
37
|
+
return super().__contains__(key.lower())
|
|
38
|
+
|
|
39
|
+
def get(self, key, default=None):
|
|
40
|
+
return super().get(key.lower(), default)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Request:
|
|
44
|
+
"""What an endpoint receives: method, path, params, args, headers, body."""
|
|
45
|
+
|
|
46
|
+
__slots__ = ("method", "path", "params", "args", "headers", "body")
|
|
47
|
+
|
|
48
|
+
def __init__(self, method, path, params, args, headers, body):
|
|
49
|
+
self.method = method
|
|
50
|
+
self.path = path
|
|
51
|
+
self.params = params
|
|
52
|
+
self.args = args
|
|
53
|
+
self.headers = headers
|
|
54
|
+
self.body = body
|
|
55
|
+
|
|
56
|
+
def json(self):
|
|
57
|
+
try:
|
|
58
|
+
return json.loads(self.body)
|
|
59
|
+
except ValueError:
|
|
60
|
+
raise HTTPError(400, "Invalid JSON body") from None
|
bitpoint/response.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Translate handle() return values into HTTP responses.
|
|
2
|
+
|
|
3
|
+
Must stay free of Flask imports: it also runs inside the per-endpoint
|
|
4
|
+
worker environments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize(rv):
|
|
11
|
+
"""Return (body: bytes, status: int, headers: list[tuple], content_type).
|
|
12
|
+
|
|
13
|
+
content_type is None when there is no body (204) so the server can
|
|
14
|
+
omit the header entirely; explicit Content-Type headers from the
|
|
15
|
+
endpoint always win.
|
|
16
|
+
"""
|
|
17
|
+
headers = []
|
|
18
|
+
status = None
|
|
19
|
+
body = rv
|
|
20
|
+
if isinstance(rv, tuple):
|
|
21
|
+
if len(rv) == 2:
|
|
22
|
+
body, status = rv
|
|
23
|
+
elif len(rv) == 3:
|
|
24
|
+
body, status, raw_headers = rv
|
|
25
|
+
items = (
|
|
26
|
+
raw_headers.items() if hasattr(raw_headers, "items") else raw_headers
|
|
27
|
+
)
|
|
28
|
+
headers = [(str(k), str(v)) for k, v in items]
|
|
29
|
+
else:
|
|
30
|
+
raise TypeError(
|
|
31
|
+
f"handle() returned a tuple of length {len(rv)}, "
|
|
32
|
+
"expected (body, status) or (body, status, headers)"
|
|
33
|
+
)
|
|
34
|
+
if body is None:
|
|
35
|
+
return b"", status if status is not None else 204, headers, None
|
|
36
|
+
if isinstance(body, str):
|
|
37
|
+
return body.encode("utf-8"), status or 200, headers, "text/plain; charset=utf-8"
|
|
38
|
+
if isinstance(body, (dict, list)):
|
|
39
|
+
data = json.dumps(body).encode("utf-8")
|
|
40
|
+
return data, status or 200, headers, "application/json"
|
|
41
|
+
if isinstance(body, (bytes, bytearray)):
|
|
42
|
+
return bytes(body), status or 200, headers, "application/octet-stream"
|
|
43
|
+
raise TypeError(f"handle() returned an unsupported type: {type(body).__name__}")
|
bitpoint/router.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Filesystem-based routing: the directory tree under `routes` is the router.
|
|
2
|
+
|
|
3
|
+
Resolution happens against the live filesystem on every request, so new
|
|
4
|
+
files and directories are picked up without any registration step.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
METHOD_FILES = ("get", "post", "put", "patch", "delete", "head", "options")
|
|
10
|
+
RESERVED_DIRS = frozenset({"lib"})
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve(routes_dir, url_path):
|
|
14
|
+
"""Map a URL path to (endpoint_dir, params) or None if no directory matches."""
|
|
15
|
+
current = routes_dir
|
|
16
|
+
params = {}
|
|
17
|
+
for depth, segment in enumerate(s for s in url_path.split("/") if s):
|
|
18
|
+
if segment in (".", "..") or "\\" in segment or "\x00" in segment:
|
|
19
|
+
return None
|
|
20
|
+
if depth == 0 and segment in RESERVED_DIRS:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
entries = os.listdir(current)
|
|
24
|
+
except OSError:
|
|
25
|
+
return None
|
|
26
|
+
if (
|
|
27
|
+
segment in entries
|
|
28
|
+
and not _is_dynamic(segment)
|
|
29
|
+
and os.path.isdir(os.path.join(current, segment))
|
|
30
|
+
):
|
|
31
|
+
current = os.path.join(current, segment)
|
|
32
|
+
continue
|
|
33
|
+
dynamic = sorted(
|
|
34
|
+
e
|
|
35
|
+
for e in entries
|
|
36
|
+
if _is_dynamic(e) and os.path.isdir(os.path.join(current, e))
|
|
37
|
+
)
|
|
38
|
+
if not dynamic:
|
|
39
|
+
return None
|
|
40
|
+
params[dynamic[0][1:-1]] = segment
|
|
41
|
+
current = os.path.join(current, dynamic[0])
|
|
42
|
+
if not os.path.isdir(current):
|
|
43
|
+
return None
|
|
44
|
+
return current, params
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def method_file(endpoint_dir, method):
|
|
48
|
+
"""Return the file that serves `method` in this directory, or None.
|
|
49
|
+
|
|
50
|
+
HEAD falls back to get.py when there is no head.py.
|
|
51
|
+
"""
|
|
52
|
+
method = method.lower()
|
|
53
|
+
candidates = [method] if method != "head" else ["head", "get"]
|
|
54
|
+
for name in candidates:
|
|
55
|
+
if name in METHOD_FILES:
|
|
56
|
+
path = os.path.join(endpoint_dir, name + ".py")
|
|
57
|
+
if os.path.isfile(path):
|
|
58
|
+
return path
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def allowed_methods(endpoint_dir):
|
|
63
|
+
"""Methods served by this directory, for the Allow header."""
|
|
64
|
+
methods = {
|
|
65
|
+
name.upper()
|
|
66
|
+
for name in METHOD_FILES
|
|
67
|
+
if os.path.isfile(os.path.join(endpoint_dir, name + ".py"))
|
|
68
|
+
}
|
|
69
|
+
if "GET" in methods:
|
|
70
|
+
methods.add("HEAD")
|
|
71
|
+
return sorted(methods)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def iter_endpoints(routes_dir):
|
|
75
|
+
"""Yield (METHOD, url, file_path) for every endpoint under routes_dir."""
|
|
76
|
+
routes_dir = os.path.abspath(routes_dir)
|
|
77
|
+
for dirpath, dirnames, filenames in os.walk(routes_dir):
|
|
78
|
+
if os.path.abspath(dirpath) == routes_dir:
|
|
79
|
+
dirnames[:] = [d for d in dirnames if d not in RESERVED_DIRS]
|
|
80
|
+
dirnames.sort()
|
|
81
|
+
rel = os.path.relpath(dirpath, routes_dir)
|
|
82
|
+
segments = [] if rel == "." else rel.split(os.sep)
|
|
83
|
+
for filename in sorted(filenames):
|
|
84
|
+
stem, ext = os.path.splitext(filename)
|
|
85
|
+
if ext == ".py" and stem in METHOD_FILES:
|
|
86
|
+
url = "/" + "/".join(segments)
|
|
87
|
+
yield stem.upper(), url, os.path.join(dirpath, filename)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _is_dynamic(name):
|
|
91
|
+
return len(name) > 2 and name.startswith("[") and name.endswith("]")
|
bitpoint/worker.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Persistent endpoint worker.
|
|
2
|
+
|
|
3
|
+
Runs inside the endpoint's isolated environment (resolved by uv) and
|
|
4
|
+
serves requests over a newline-delimited JSON protocol: one request line
|
|
5
|
+
in on stdin, one response line out on stdout. The endpoint module is
|
|
6
|
+
loaded once per worker; the server kills and respawns the worker when
|
|
7
|
+
the file changes, so a reload is always a fresh process.
|
|
8
|
+
|
|
9
|
+
Only the standard library plus bitpoint.request/bitpoint.response are
|
|
10
|
+
used here: the endpoint's environment does not contain Flask.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import traceback
|
|
18
|
+
import types
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
file_path, routes_dir, bitpoint_parent = sys.argv[1:4]
|
|
23
|
+
# Endpoint print() calls must not corrupt the protocol: keep the real
|
|
24
|
+
# stdout for ourselves and point sys.stdout at stderr.
|
|
25
|
+
protocol_out = os.fdopen(os.dup(sys.stdout.fileno()), "w", buffering=1)
|
|
26
|
+
sys.stdout = sys.stderr
|
|
27
|
+
# Never read or write .pyc caches: their whole-second validator can
|
|
28
|
+
# hand a freshly-respawned worker the previous version of the code.
|
|
29
|
+
sys.dont_write_bytecode = True
|
|
30
|
+
sys.path.insert(0, bitpoint_parent)
|
|
31
|
+
sys.path.insert(0, routes_dir)
|
|
32
|
+
|
|
33
|
+
from bitpoint.request import CaseInsensitiveDict, HTTPError, Request
|
|
34
|
+
from bitpoint.response import normalize
|
|
35
|
+
|
|
36
|
+
module = None
|
|
37
|
+
load_error = None
|
|
38
|
+
try:
|
|
39
|
+
module = _load(file_path)
|
|
40
|
+
except BaseException:
|
|
41
|
+
load_error = traceback.format_exc()
|
|
42
|
+
|
|
43
|
+
for line in sys.stdin:
|
|
44
|
+
if not line.strip():
|
|
45
|
+
continue
|
|
46
|
+
payload = json.loads(line)
|
|
47
|
+
try:
|
|
48
|
+
if load_error is not None:
|
|
49
|
+
raise RuntimeError("endpoint failed to load:\n" + load_error)
|
|
50
|
+
handler = getattr(module, "handle", None)
|
|
51
|
+
if handler is None:
|
|
52
|
+
raise RuntimeError(
|
|
53
|
+
f"{file_path} does not define a handle(request) function"
|
|
54
|
+
)
|
|
55
|
+
request = Request(
|
|
56
|
+
method=payload["method"],
|
|
57
|
+
path=payload["path"],
|
|
58
|
+
params=payload["params"],
|
|
59
|
+
args=payload["args"],
|
|
60
|
+
headers=CaseInsensitiveDict(payload["headers"]),
|
|
61
|
+
body=base64.b64decode(payload["body"]),
|
|
62
|
+
)
|
|
63
|
+
body, status, headers, content_type = normalize(handler(request))
|
|
64
|
+
result = {
|
|
65
|
+
"status": status,
|
|
66
|
+
"headers": headers,
|
|
67
|
+
"body": base64.b64encode(body).decode("ascii"),
|
|
68
|
+
"content_type": content_type,
|
|
69
|
+
}
|
|
70
|
+
except HTTPError as exc:
|
|
71
|
+
result = {"error": {"status": exc.status, "message": exc.message}}
|
|
72
|
+
except Exception:
|
|
73
|
+
result = {"error": {"traceback": traceback.format_exc()}}
|
|
74
|
+
protocol_out.write(json.dumps(result) + "\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _load(file_path):
|
|
78
|
+
with open(file_path, encoding="utf-8") as fh:
|
|
79
|
+
source = fh.read()
|
|
80
|
+
module = types.ModuleType("bitpoint_endpoint")
|
|
81
|
+
module.__file__ = file_path
|
|
82
|
+
sys.modules["bitpoint_endpoint"] = module
|
|
83
|
+
code = compile(source, file_path, "exec")
|
|
84
|
+
exec(code, module.__dict__)
|
|
85
|
+
return module
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
main()
|
bitpoint/workers.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Worker pool: one persistent process per PEP 723 endpoint.
|
|
2
|
+
|
|
3
|
+
Lifecycle: spawned lazily on first request, killed and respawned when
|
|
4
|
+
the endpoint file changes (reload is always a fresh process, never a
|
|
5
|
+
module reload), killed when the file disappears, and killed on SIGHUP
|
|
6
|
+
in production so a `git pull` deploy picks up new code and dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import threading
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("bitpoint")
|
|
16
|
+
|
|
17
|
+
_WORKER_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "worker.py")
|
|
18
|
+
_BITPOINT_PARENT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WorkerCrash(RuntimeError):
|
|
22
|
+
"""The worker process died before producing a response."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Worker:
|
|
26
|
+
def __init__(self, python, file_path, routes_dir):
|
|
27
|
+
self.python = python
|
|
28
|
+
self.file_path = file_path
|
|
29
|
+
self.mtime_ns = os.stat(file_path).st_mtime_ns
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self._proc = subprocess.Popen(
|
|
32
|
+
[python, _WORKER_SCRIPT, file_path, routes_dir, _BITPOINT_PARENT],
|
|
33
|
+
stdin=subprocess.PIPE,
|
|
34
|
+
stdout=subprocess.PIPE,
|
|
35
|
+
text=True,
|
|
36
|
+
bufsize=1,
|
|
37
|
+
)
|
|
38
|
+
log.debug("Spawned worker %d for %s", self._proc.pid, file_path)
|
|
39
|
+
|
|
40
|
+
def request(self, payload):
|
|
41
|
+
"""Send one request and read one response. Serialized per worker."""
|
|
42
|
+
with self._lock:
|
|
43
|
+
if self._proc.poll() is not None:
|
|
44
|
+
raise WorkerCrash(
|
|
45
|
+
f"worker for {self.file_path} exited with code {self._proc.returncode}"
|
|
46
|
+
)
|
|
47
|
+
try:
|
|
48
|
+
self._proc.stdin.write(json.dumps(payload) + "\n")
|
|
49
|
+
self._proc.stdin.flush()
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
raise WorkerCrash(
|
|
52
|
+
f"worker for {self.file_path} is gone: {exc}"
|
|
53
|
+
) from exc
|
|
54
|
+
line = self._proc.stdout.readline()
|
|
55
|
+
if not line:
|
|
56
|
+
raise WorkerCrash(
|
|
57
|
+
f"worker for {self.file_path} died while handling a request"
|
|
58
|
+
)
|
|
59
|
+
return json.loads(line)
|
|
60
|
+
|
|
61
|
+
def alive(self):
|
|
62
|
+
return self._proc.poll() is None
|
|
63
|
+
|
|
64
|
+
def kill(self):
|
|
65
|
+
if self._proc.poll() is None:
|
|
66
|
+
self._proc.terminate()
|
|
67
|
+
try:
|
|
68
|
+
self._proc.wait(timeout=3)
|
|
69
|
+
except subprocess.TimeoutExpired:
|
|
70
|
+
self._proc.kill()
|
|
71
|
+
self._proc.wait()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class WorkerPool:
|
|
75
|
+
def __init__(self, routes_dir, production=False):
|
|
76
|
+
self.routes_dir = routes_dir
|
|
77
|
+
self.production = production
|
|
78
|
+
self._workers = {} # file_path -> Worker
|
|
79
|
+
self._lock = threading.Lock()
|
|
80
|
+
|
|
81
|
+
def request(self, file_path, python, payload):
|
|
82
|
+
worker = self._get(file_path, python)
|
|
83
|
+
try:
|
|
84
|
+
return worker.request(payload)
|
|
85
|
+
except WorkerCrash as exc:
|
|
86
|
+
# One respawn-and-retry; a second crash surfaces as a 500.
|
|
87
|
+
log.warning("%s, respawning", exc)
|
|
88
|
+
self._evict(worker)
|
|
89
|
+
worker = self._get(file_path, python)
|
|
90
|
+
return worker.request(payload)
|
|
91
|
+
|
|
92
|
+
def _get(self, file_path, python):
|
|
93
|
+
with self._lock:
|
|
94
|
+
worker = self._workers.get(file_path)
|
|
95
|
+
if worker is not None:
|
|
96
|
+
stale = worker.python != python or not worker.alive()
|
|
97
|
+
if not stale and not self.production:
|
|
98
|
+
stale = os.stat(file_path).st_mtime_ns != worker.mtime_ns
|
|
99
|
+
if stale:
|
|
100
|
+
worker.kill()
|
|
101
|
+
del self._workers[file_path]
|
|
102
|
+
worker = None
|
|
103
|
+
if worker is None:
|
|
104
|
+
worker = Worker(python, file_path, self.routes_dir)
|
|
105
|
+
self._workers[file_path] = worker
|
|
106
|
+
return worker
|
|
107
|
+
|
|
108
|
+
def _evict(self, worker):
|
|
109
|
+
with self._lock:
|
|
110
|
+
if self._workers.get(worker.file_path) is worker:
|
|
111
|
+
del self._workers[worker.file_path]
|
|
112
|
+
worker.kill()
|
|
113
|
+
|
|
114
|
+
def reap_deleted(self):
|
|
115
|
+
"""Kill workers whose endpoint file no longer exists."""
|
|
116
|
+
with self._lock:
|
|
117
|
+
for path in [p for p in self._workers if not os.path.isfile(p)]:
|
|
118
|
+
log.info("Endpoint %s deleted, stopping its worker", path)
|
|
119
|
+
self._workers.pop(path).kill()
|
|
120
|
+
|
|
121
|
+
def kill_all(self):
|
|
122
|
+
with self._lock:
|
|
123
|
+
for worker in self._workers.values():
|
|
124
|
+
worker.kill()
|
|
125
|
+
self._workers.clear()
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bitpoint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Create HTTP endpoints quickly using files, without going through a framework.
|
|
5
|
+
Project-URL: Homepage, https://github.com/tanrax/bitpoint
|
|
6
|
+
Project-URL: Repository, https://github.com/tanrax/bitpoint
|
|
7
|
+
Project-URL: Issues, https://github.com/tanrax/bitpoint/issues
|
|
8
|
+
Author-email: Andros Fenollosa <andros@fenollosa.email>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: endpoints,file-based-routing,http,microframework,pep723,webhooks
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: flask>=3.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Bitpoint
|
|
29
|
+
|
|
30
|
+
Create HTTP endpoints quickly using files, without going through a framework like Flask or FastAPI.
|
|
31
|
+
|
|
32
|
+
- **File-based routing**: the directory structure defines the routes, the file name defines the HTTP method.
|
|
33
|
+
- **Hot reload**: save the file and the server updates automatically. Deploy with a simple `git pull`.
|
|
34
|
+
- **Per-endpoint dependencies**: each endpoint declares its dependencies with [PEP 723](https://peps.python.org/pep-0723/) and they are installed automatically in an isolated environment.
|
|
35
|
+
|
|
36
|
+
Bitpoint is for the moments when a full project feels like too much: exposing a script as a webhook, mocking an API so the frontend can move forward, publishing a small internal service for your team, or keeping a handful of endpoints alive on a personal server. In all these cases the API is not the product, it is just plumbing, and spending an afternoon on scaffolding, virtual environments and deploy pipelines is out of proportion. With Bitpoint you write one file and you get one endpoint.
|
|
37
|
+
|
|
38
|
+
Under the hood, Bitpoint stays out of your way. There is nothing to register and nothing to configure: no app object, no decorators, no config files, just a `handle` function per file. Dependency isolation is delegated to [uv](https://docs.astral.sh/uv/), with a cached environment per endpoint, so two endpoints can use incompatible versions of the same library without ever conflicting. Responses follow the type you return: a string becomes plain text, a dict becomes JSON, a tuple adds a status code and headers, and the sensible thing happens by default.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install bitpoint
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or, if you use [uv](https://docs.astral.sh/uv/):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv tool install bitpoint
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Python 3.9 or later. If you want per-endpoint dependencies, `uv` must be available on the server.
|
|
53
|
+
|
|
54
|
+
## Quickstart
|
|
55
|
+
|
|
56
|
+
Create a directory called `routes` and, inside it, a subdirectory called `hello` (this will be the route). Inside `hello`, create a file called `get.py` with the following content:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
# routes/hello/get.py
|
|
60
|
+
|
|
61
|
+
def handle(request):
|
|
62
|
+
return "world"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Start the server with:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python -m bitpoint
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
You can now try your endpoint:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
curl localhost:8000/hello
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
It will return:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
world
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Routing
|
|
84
|
+
|
|
85
|
+
The URL path maps to the directory path inside `routes`, and the HTTP method maps to the file name:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
routes/
|
|
89
|
+
├── hello/
|
|
90
|
+
│ └── get.py → GET /hello
|
|
91
|
+
├── users/
|
|
92
|
+
│ ├── get.py → GET /users
|
|
93
|
+
│ ├── post.py → POST /users
|
|
94
|
+
│ └── [id]/
|
|
95
|
+
│ ├── get.py → GET /users/42
|
|
96
|
+
│ └── delete.py → DELETE /users/42
|
|
97
|
+
└── lib/ → shared code, does not generate routes
|
|
98
|
+
└── db.py
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Valid file names are the HTTP methods in lowercase: `get.py`, `post.py`, `put.py`, `patch.py`, `delete.py`, `head.py` and `options.py`. Any other file is ignored and does not generate routes, so you can keep helper modules next to your endpoints.
|
|
102
|
+
|
|
103
|
+
### Dynamic routes
|
|
104
|
+
|
|
105
|
+
A directory whose name is wrapped in brackets captures a URL segment:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
# routes/users/[id]/get.py
|
|
109
|
+
|
|
110
|
+
def handle(request):
|
|
111
|
+
return {"user_id": request.params["id"]}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
curl localhost:8000/users/42
|
|
116
|
+
# {"user_id": "42"}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Captured values always arrive as `str`. Type conversion is the endpoint's responsibility.
|
|
120
|
+
|
|
121
|
+
## The `handle` contract
|
|
122
|
+
|
|
123
|
+
Each endpoint exposes a `handle(request)` function. It is the only symbol Bitpoint looks for in the file, the rest of the module is yours.
|
|
124
|
+
|
|
125
|
+
### The `request` object
|
|
126
|
+
|
|
127
|
+
| Attribute | Type | Description |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| `request.method` | `str` | HTTP method in uppercase, for example `"GET"` |
|
|
130
|
+
| `request.path` | `str` | Request path, for example `/users/42` |
|
|
131
|
+
| `request.params` | `dict[str, str]` | Dynamic path segments |
|
|
132
|
+
| `request.args` | `dict[str, str]` | Query string parameters |
|
|
133
|
+
| `request.headers` | `dict[str, str]` | Headers, case-insensitive keys |
|
|
134
|
+
| `request.body` | `bytes` | Raw request body |
|
|
135
|
+
| `request.json()` | `Any` | Body parsed as JSON, raises a 400 error if invalid |
|
|
136
|
+
|
|
137
|
+
Example with a query string:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
# routes/greet/get.py
|
|
141
|
+
|
|
142
|
+
def handle(request):
|
|
143
|
+
name = request.args.get("name", "world")
|
|
144
|
+
return f"Hello, {name}!"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
curl "localhost:8000/greet?name=Bob"
|
|
149
|
+
# Hello, Bob!
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Return values
|
|
153
|
+
|
|
154
|
+
The return type determines the response:
|
|
155
|
+
|
|
156
|
+
| Return | Response |
|
|
157
|
+
|---|---|
|
|
158
|
+
| `str` | `200`, `text/plain; charset=utf-8` |
|
|
159
|
+
| `dict` or `list` | `200`, `application/json` |
|
|
160
|
+
| `bytes` | `200`, `application/octet-stream` |
|
|
161
|
+
| `(body, status)` | As above, with the given status code |
|
|
162
|
+
| `(body, status, headers)` | Additionally with custom headers |
|
|
163
|
+
| `None` | `204 No Content` |
|
|
164
|
+
|
|
165
|
+
```python
|
|
166
|
+
# routes/users/post.py
|
|
167
|
+
|
|
168
|
+
def handle(request):
|
|
169
|
+
data = request.json()
|
|
170
|
+
return {"created": data["name"]}, 201
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Errors
|
|
174
|
+
|
|
175
|
+
- If `handle` raises an exception, Bitpoint responds with `500`.
|
|
176
|
+
- In development mode (the default) the body includes the traceback. In production (`--production`) the body is a generic message and the traceback goes to the log.
|
|
177
|
+
- Path with no matching directory: `404`. Directory exists but there is no file for that method: `405` with the `Allow` header listing the available methods.
|
|
178
|
+
|
|
179
|
+
## Dependencies
|
|
180
|
+
|
|
181
|
+
Declare each endpoint's dependencies with a [PEP 723](https://peps.python.org/pep-0723/) block at the top of the file, the same format understood by `uv` and `pipx`:
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
# routes/status/get.py
|
|
185
|
+
|
|
186
|
+
# /// script
|
|
187
|
+
# dependencies = ["requests"]
|
|
188
|
+
# ///
|
|
189
|
+
|
|
190
|
+
def handle(request):
|
|
191
|
+
import requests
|
|
192
|
+
return requests.get("https://example.com").json()
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Bitpoint delegates resolution and installation to [uv](https://docs.astral.sh/uv/): each endpoint runs with its dependencies in an isolated, cached environment. Two endpoints can use incompatible versions of the same library without conflict.
|
|
196
|
+
|
|
197
|
+
- An endpoint without a PEP 723 block runs in the base environment, with no extra cost.
|
|
198
|
+
- Installation happens on first startup and whenever the dependency block changes, not on every request.
|
|
199
|
+
|
|
200
|
+
> **Security note**: installing dependencies automatically after a `git pull` means running third-party code at deploy time. If you prefer to control that step, start with `--no-install` and Bitpoint will fail with a clear error on endpoints whose dependencies are not already installed.
|
|
201
|
+
|
|
202
|
+
## Shared code
|
|
203
|
+
|
|
204
|
+
The `routes/lib/` directory is reserved: it does not generate routes and is importable from any endpoint:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
# routes/lib/db.py
|
|
208
|
+
|
|
209
|
+
def get_connection():
|
|
210
|
+
...
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
# routes/users/get.py
|
|
215
|
+
|
|
216
|
+
from lib.db import get_connection
|
|
217
|
+
|
|
218
|
+
def handle(request):
|
|
219
|
+
conn = get_connection()
|
|
220
|
+
...
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Configuration
|
|
224
|
+
|
|
225
|
+
Everything is controlled from the command line, there is no configuration file:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
python -m bitpoint [options]
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
| Option | Default | Description |
|
|
232
|
+
|---|---|---|
|
|
233
|
+
| `--port` | `8000` | Listening port |
|
|
234
|
+
| `--host` | `127.0.0.1` | Listening address |
|
|
235
|
+
| `--dir` | `./routes` | Root directory for endpoints |
|
|
236
|
+
| `--production` | disabled | Hides tracebacks and disables filesystem-based hot reload |
|
|
237
|
+
| `--no-install` | disabled | Does not install dependencies automatically |
|
|
238
|
+
|
|
239
|
+
## Hot reload
|
|
240
|
+
|
|
241
|
+
In development mode, Bitpoint watches the `routes` directory and reloads each module when its file changes. There is no shared state across reloads: if your endpoint keeps in-memory state (caches, connections), it is lost on reload. For persistent state use external resources (a database, Redis, files).
|
|
242
|
+
|
|
243
|
+
In production, filesystem-based reload is disabled. The recommended deploy flow is `git pull` followed by a reload signal:
|
|
244
|
+
|
|
245
|
+
```bash
|
|
246
|
+
git pull && kill -HUP $(cat bitpoint.pid)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## A real example
|
|
250
|
+
|
|
251
|
+
A GitHub webhook that sends a Telegram message on every push:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
# routes/webhooks/github/post.py
|
|
255
|
+
|
|
256
|
+
# /// script
|
|
257
|
+
# dependencies = ["requests"]
|
|
258
|
+
# ///
|
|
259
|
+
import os
|
|
260
|
+
import requests
|
|
261
|
+
|
|
262
|
+
def handle(request):
|
|
263
|
+
if request.headers.get("x-github-event") != "push":
|
|
264
|
+
return None
|
|
265
|
+
payload = request.json()
|
|
266
|
+
pusher = payload["pusher"]["name"]
|
|
267
|
+
repo = payload["repository"]["name"]
|
|
268
|
+
commits = len(payload["commits"])
|
|
269
|
+
requests.post(
|
|
270
|
+
f"https://api.telegram.org/bot{os.environ['TELEGRAM_TOKEN']}/sendMessage",
|
|
271
|
+
json={
|
|
272
|
+
"chat_id": os.environ["TELEGRAM_CHAT_ID"],
|
|
273
|
+
"text": f"{pusher} pushed {commits} commit(s) to {repo}",
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
return None
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
One file, deployed with a `git pull`. No project, no virtualenv, no route registration, no restart.
|
|
280
|
+
|
|
281
|
+
## Why not Flask or FastAPI?
|
|
282
|
+
|
|
283
|
+
Use Flask or FastAPI when the API is your product: they are excellent frameworks and Bitpoint does not try to compete with them.
|
|
284
|
+
|
|
285
|
+
Bitpoint is for when the API is just plumbing. A webhook here, an internal endpoint there, a mock for the frontend. In those cases a framework gives you power you will not use at the price of ceremony you have to pay every time: a project to scaffold, an app to configure, routes to register, a virtualenv to maintain and a server to restart. Bitpoint removes all of that by turning the decisions into conventions: the filesystem is the router, the file is the endpoint, and its header declares its dependencies.
|
|
286
|
+
|
|
287
|
+
If an endpoint outgrows Bitpoint, nothing locks you in: `handle` is a plain function, and moving it to a Flask or FastAPI route is a copy-paste.
|
|
288
|
+
|
|
289
|
+
## Small core
|
|
290
|
+
|
|
291
|
+
Some areas are explicitly not covered in order to keep the core small:
|
|
292
|
+
|
|
293
|
+
- Middleware and global hooks
|
|
294
|
+
- Authentication
|
|
295
|
+
- WebSockets and streaming
|
|
296
|
+
- `async` endpoints
|
|
297
|
+
- Static files
|
|
298
|
+
|
|
299
|
+
If you need any of this today, Bitpoint is not your tool, you will have to use external tools.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
bitpoint/__init__.py,sha256=gWKOyye32Cw9unXGjrVbhNrm7MTml68d6qkTVRsWSJg,275
|
|
2
|
+
bitpoint/__main__.py,sha256=_WycoF3Ig_LsshTTPJtCak6wK8QceU77hqi5noro6Cw,3080
|
|
3
|
+
bitpoint/app.py,sha256=IdX2H4vFi0FlQYhSGc_EIUVwt3KFTP9JO5AETfDRyNw,5888
|
|
4
|
+
bitpoint/deps.py,sha256=HNxawER0FBG-GAmovRqgg6426-lUHX328KkY4xqokzk,4278
|
|
5
|
+
bitpoint/loader.py,sha256=bSbGD-0_uvxJG8NZJ80ZS-hPfeR8L-Xna3xRkYwdZ8s,3406
|
|
6
|
+
bitpoint/request.py,sha256=ad_01EPB-y_nEbuLkiS3I-WsQXXYagZtVU4E8IXPHjo,1699
|
|
7
|
+
bitpoint/response.py,sha256=pEo9jQfVRQ3V-z7nvzH6UYaPcTi71BjadlO0OkNBzdw,1590
|
|
8
|
+
bitpoint/router.py,sha256=t21T71BPERG2SRmhtfRKKDnycPcJLxPG_b4Hk3vXLm4,3114
|
|
9
|
+
bitpoint/worker.py,sha256=R6yAlqPkXz9BF0tfTCy8_q6fdPzvXAPnq6bFrkSy5zU,3131
|
|
10
|
+
bitpoint/workers.py,sha256=DpjA-mQ83mQdf4mhSXySdHL4EVGLxF2PNWRALitS2uI,4469
|
|
11
|
+
bitpoint-0.1.0.dist-info/METADATA,sha256=RLT9bd6obkyLmWG-wiMPIYFUNxew91urRgOWLNUSaOs,10795
|
|
12
|
+
bitpoint-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
13
|
+
bitpoint-0.1.0.dist-info/licenses/LICENSE,sha256=-ZEiPx9pAy1-h5EUoMe_jvG225xP7dxkqXo9zZqzuFM,1073
|
|
14
|
+
bitpoint-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andros Fenollosa
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|