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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.