diff options
author | Tomasz Kramkowski <tomasz@kramkow.ski> | 2023-02-14 18:36:54 +0000 |
---|---|---|
committer | Tomasz Kramkowski <tomasz@kramkow.ski> | 2023-02-14 18:36:54 +0000 |
commit | ac0b1401574711a26d494a80c03807e422cc0853 (patch) | |
tree | 19d1440d05c6448e8d5065c6aa4270747e418020 | |
parent | 0d143bb226e702006d6929c346b8d25a05e10b2e (diff) | |
download | paste-ac0b1401574711a26d494a80c03807e422cc0853.tar.gz paste-ac0b1401574711a26d494a80c03807e422cc0853.tar.xz paste-ac0b1401574711a26d494a80c03807e422cc0853.zip |
First draft version
-rw-r--r-- | .gitignore | 3 | ||||
-rw-r--r-- | ARCHITECTURE | 26 | ||||
-rw-r--r-- | LICENSE | 20 | ||||
-rw-r--r-- | README | 3 | ||||
-rwxr-xr-x | format | 3 | ||||
-rw-r--r-- | paste/__init__.py | 181 | ||||
-rw-r--r-- | paste/__main__.py | 140 | ||||
-rw-r--r-- | paste/db.py | 69 | ||||
-rw-r--r-- | paste/store.py | 52 | ||||
-rw-r--r-- | poetry.lock | 221 | ||||
-rw-r--r-- | pyproject.toml | 19 | ||||
-rwxr-xr-x | vermin | 2 |
12 files changed, 739 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af023e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/paste.sqlite3 +/dist/ +__pycache__/ diff --git a/ARCHITECTURE b/ARCHITECTURE new file mode 100644 index 0000000..617aea1 --- /dev/null +++ b/ARCHITECTURE @@ -0,0 +1,26 @@ +API Keys: + +paste uses 96 bit API keys which are stored as SHA-256 hashes. + +The keys are transmitted as base64 (RFC 4648) encoded Bearer tokens with +no padding. + +The length is long enough to be secure against any and all forms of brute +force but short enough that the base64 encoding is only 16 characters +meaning it can be easily typed out. + +Coincidentally, 96 is also evenly divisible by 6 so the base64 encoding +has no padding. + +Base64 was chosen as the limited use of symbols makes it easier to type. + +Storing the keys as plain SHA-256 is sufficiently secure as: +- Brute forcing the keys would, as mentioned before, be infeasible. +- If someone recovers the hash, they would have an easier time brute + forcing, but still infeasible with current computing power. +- There is a timing attack but it would require predictably generating + hashes with longer and longer prefixes which is infeasible. + +But, to further reduce the chances of a recovered hash being brute +forced, a KDF with a small number of rounds could be used to seriously +increase the time required. @@ -0,0 +1,20 @@ +Copyright 2023 Tomasz Kramkowski <tomasz@kramkow.ski> + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. @@ -0,0 +1,3 @@ +PASTE + +A basic WSGI paste site. @@ -0,0 +1,3 @@ +#!/bin/sh +[ "$#" -eq 0 ] && set . +poetry run black -q "$@" diff --git a/paste/__init__.py b/paste/__init__.py new file mode 100644 index 0000000..2a15f43 --- /dev/null +++ b/paste/__init__.py @@ -0,0 +1,181 @@ +from base64 import b64decode, b64encode +from functools import wraps +from hashlib import sha256 +from wsgiref.util import request_uri +from typing import Optional, Any +from collections.abc import Callable, Iterable +import binascii +import traceback +from sqlite3 import Connection + +from . import db +from . import store + +StartResponse = Callable[[str, list[tuple[str, str]]], Optional[tuple]] +Env = dict[str, Any] +App = Callable[[Env, StartResponse], Iterable[bytes]] +Response = Iterable[bytes] + +DB_PATH = "paste.sqlite3" + + +def simple_response(start_response: StartResponse, status: str) -> Response: + body = (status + "\n").encode() + start_response( + status, + [ + ("Content-Type", "text/plain"), + ("Content-Length", str(len(body))), + ], + ) + return [body] + + +def redirect(start_response: StartResponse, location: bytes, typ: str) -> Response: + status = { + "text/x.redirect.301": "301 Moved Permanently", + "text/x.redirect.302": "302 Found", + }.get(typ) + if not status: + return simple_response(start_response, "500 Internal Server Error") + body = location + start_response( + status, + [ + ("Location", location.decode()), + ("Content-Type", "text/plain"), + ("Content-Length", str(len(body))), + ], + ) + return [body] + + +ProtoMiddleware = Callable[[App, Env, StartResponse], Response] +Middleware = Callable[[App], App] + + +def middleware(f: ProtoMiddleware) -> Middleware: + @wraps(f) + def outer(app: App): + @wraps(app) + def inner(environ: Env, start_response: StartResponse): + return f(app, environ, start_response) + + return inner + + return outer + + +@middleware +def catch_exceptions(app: App, environ: Env, start_response: StartResponse) -> Response: + try: + return app(environ, start_response) + except Exception as e: + print("".join(traceback.format_exception(type(e), e, e.__traceback__))) + return simple_response(start_response, "500 Internal Server Error") + + +@middleware +def validate_method(app: App, environ: Env, start_response: StartResponse) -> Response: + if environ["REQUEST_METHOD"] in {"GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"}: + return app(environ, start_response) + if environ["REQUEST_METHOD"] in {"CONNECT", "TRACE", "PATCH"}: + return simple_response(start_response, "405 Method Not Allowed") + return simple_response(start_response, "501 Not Implemented") + + +@middleware +def options(app: App, environ: Env, start_response: StartResponse) -> Response: + if environ["REQUEST_METHOD"] != "OPTIONS": + return app(environ, start_response) + start_response( + "204 No Content", + [ + ("Allow", "GET, HEAD, POST, PUT, DELETE, OPTIONS"), + ], + ) + return [] + + +@middleware +def open_database(app: App, environ: Env, start_response: StartResponse) -> Response: + db_path = environ.get("PASTE_DB", DB_PATH) + with db.connect(db_path) as conn: + environ["paste.db_conn"] = conn + return app(environ, start_response) + + +def check_auth(conn: Connection, auth: Optional[str]) -> bool: + if not auth or not auth.startswith("Bearer "): + return False + try: + token = b64decode(auth.removeprefix("Bearer ").encode()) + except binascii.Error: + return False + (count,) = conn.execute( + "SELECT COUNT(*) FROM token WHERE hash = sha256(?)", (token,) + ).fetchone() + return count == 1 + + +@middleware +def authenticate(app: App, environ: Env, start_response: StartResponse) -> Response: + conn = environ["paste.db_conn"] + token = environ.get("HTTP_AUTHORIZATION") + if environ["REQUEST_METHOD"] in {"GET", "HEAD"} or check_auth(conn, token): + return app(environ, start_response) + return simple_response(start_response, "401 Unauthorized") + + +@catch_exceptions +@validate_method +@options +@open_database +@authenticate +def application(environ: Env, start_response: StartResponse) -> Response: + conn = environ["paste.db_conn"] + name = environ["PATH_INFO"] + if environ["REQUEST_METHOD"] == "GET": + row = store.get(conn, name) + if not row: + return simple_response(start_response, "404 Not Found") + content_type, content_hash, content = row + if content_type.startswith("text/x.redirect"): + return redirect(start_response, content, content_type) + start_response( + "200 OK", + [ + ("Content-Type", content_type), + ("Content-Length", str(len(content))), + ("ETag", b64encode(content_hash).decode()), + ], + ) + return [content] + elif environ["REQUEST_METHOD"] == "HEAD": + row = store.head(conn, name) + if not row: + return simple_response(start_response, "404 Not Found") + content_type, content_hash, content_length, opt_content = row + if content_type.startswith("text/x.redirect"): + return redirect(start_response, opt_content, content_type) + start_response( + "200 OK", + [ + ("Content-Type", content_type), + ("Content-Length", content_length), + ("ETag", b64encode(content_hash).decode()), + ], + ) + return [] + elif environ["REQUEST_METHOD"] in {"POST", "PUT"}: + content_type = environ.get("CONTENT_TYPE", "text/plain") + content_length = int(environ["CONTENT_LENGTH"]) + content = environ["wsgi.input"].read(content_length) + store.put(conn, name, content, content_type) + return redirect( + start_response, request_uri(environ).encode(), "text/x.redirect.302" + ) + elif environ["REQUEST_METHOD"] == "DELETE": + store.delete(conn, name) + return simple_response(start_response, "204 No Content") + return simple_response(start_response, "500 Internal Server Error") diff --git a/paste/__main__.py b/paste/__main__.py new file mode 100644 index 0000000..f1aa117 --- /dev/null +++ b/paste/__main__.py @@ -0,0 +1,140 @@ +from base64 import b64encode, b64decode +from datetime import datetime, timezone +from hashlib import sha256 +from os import getenv +from secrets import token_bytes +from sys import argv, stderr +from wsgiref.simple_server import make_server +from sqlite3 import Connection +from contextlib import AbstractContextManager + +from . import application, DB_PATH +from . import db + +TOKEN_BYTES = 96 // 8 +PROGRAM_NAME = "paste" + + +def print_usage() -> None: + print( + f"Usage: {PROGRAM_NAME} [-h|serve|new-token|list-tokens|delete-token]", + file=stderr, + ) + + +def print_help() -> None: + print( + f"""A simple WSGI paste site + +Commands: + serve (default) Start a basic HTTP server (do NOT use in production) + new-token Generate a new access token + list-tokens List access token hashes (sha256 hash, created at) + check-token Check if a token is valid and exists in the database + delete-token Delete a token (specify token or hash) + +Options: + -h Show this help + +Environment: + PASTE_HOST The HTTP server host (default: localhost) + PASTE_PORT The HTTP server port (default: 8080) + PASTE_DB The path to the sqlite3 database (default: {DB_PATH})""", + file=stderr, + ) + + +def generate_token(conn: Connection): + token = token_bytes(TOKEN_BYTES) + token_hash = sha256(token).digest() + with conn: + conn.execute("INSERT INTO token (hash) VALUES (?)", (token_hash,)) + return token + + +def get_tokens(conn: Connection): + return conn.execute( + "SELECT hash, created_at FROM token ORDER BY created_at" + ).fetchall() + + +def delete_token_hash(conn: Connection, token_hash: bytes): + if len(token_hash) != 256 // 8: + raise ValueError("Invalid token hash") + with conn: + cur = conn.execute("DELETE FROM token WHERE hash = ?", (token_hash,)) + if cur.rowcount <= 0: + raise KeyError("Token hash does not exist") + + +def delete_token(conn: Connection, token: bytes): + if len(token) != TOKEN_BYTES: + raise ValueError("Invalid token") + return delete_token_hash(conn, sha256(token).digest()) + + +def check_token(conn: Connection, token: bytes): + if len(token) != TOKEN_BYTES: + raise ValueError("Invalid token") + token_hash = sha256(token).digest() + (count,) = conn.execute( + "SELECT COUNT(*) FROM token WHERE hash = ?", (token_hash,) + ).fetchone() + return count > 0 + + +db_path = getenv("PASTE_DB", DB_PATH) + + +def connect() -> AbstractContextManager[Connection]: + return db.connect(db_path) + + +def main(): + if len(argv) <= 1 or len(argv) == 2 and argv[1] == "serve": + host = getenv("PASTE_HOST", "localhost") + port = int(getenv("PASTE_PORT", "8080")) + httpd = make_server(host, port, application) + httpd.serve_forever() + elif len(argv) == 2: + if argv[1] == "new-token": + with connect() as conn: + print(b64encode(generate_token(conn)).decode()) + elif argv[1] == "list-tokens": + with connect() as conn: + for token_hash, created_at in get_tokens(conn): + created_at = datetime.fromtimestamp(created_at, timezone.utc) + print(f"{token_hash.hex()}\t{created_at.ctime()}") + elif argv[1] == "-h" or argv[1] == "--help": + print_usage() + print_help() + else: + print_usage() + exit(1) + elif len(argv) == 3: + if argv[1] == "delete-token": + token = argv[2] + with connect() as conn: + try: + try: + delete_token(conn, b64decode(token)) + except ValueError: + delete_token_hash(conn, bytes.fromhex(token)) + except ValueError: + print("Malformed token", file=stderr) + exit(1) + except KeyError: + print("Token not found", file=stderr) + exit(1) + elif argv[1] == "verify-token": + with connect() as conn: + try: + if not check_token(conn, b64decode(argv[2])): + print("Token not found", file=stderr) + exit(1) + print("Found") + except ValueError: + print("Malformed token", file=stderr) + + +main() diff --git a/paste/db.py b/paste/db.py new file mode 100644 index 0000000..3de42fb --- /dev/null +++ b/paste/db.py @@ -0,0 +1,69 @@ +from contextlib import contextmanager +from hashlib import sha256 +from itertools import count +from collections.abc import Iterator +from typing import Union +import sqlite3 + +migrations = [ + """CREATE TABLE file ( + hash BLOB UNIQUE GENERATED ALWAYS AS (sha256(content)) STORED NOT NULL, + content BLOB NOT NULL, + created_at INTEGER DEFAULT (unixepoch('now')) +) STRICT; + +CREATE UNIQUE INDEX file_hash_ix ON file ( hash ); + +CREATE TABLE link ( + name_hash BLOB UNIQUE GENERATED ALWAYS AS (sha256(name)) STORED NOT NULL, + name TEXT NOT NULL, + content_type TEXT NOT NULL DEFAULT "text/plain", + file_hash BLOB NOT NULL, + created_at INTEGER DEFAULT (unixepoch('now')), + FOREIGN KEY(file_hash) REFERENCES file(hash) +) STRICT; + +CREATE UNIQUE INDEX link_name_hash_ix ON link ( name_hash ); + +CREATE TABLE token ( + hash BLOB PRIMARY KEY, + created_at INTEGER DEFAULT (unixepoch('now')) +) STRICT, WITHOUT ROWID;""", +] + + +def _sha256_udf(b: Union[bytes, str]) -> bytes: + if isinstance(b, str): + b = b.encode() + return sha256(b).digest() + + +@contextmanager +def connect( + database: str, migrations: list[str] = migrations, **kwargs +) -> Iterator[sqlite3.Connection]: + conn = sqlite3.connect(database, **kwargs) + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + conn.row_factory = sqlite3.Row + conn.create_function(name="sha256", narg=1, func=_sha256_udf, deterministic=True) + (user_version,) = conn.execute("PRAGMA user_version").fetchone() + for i in count(user_version + 1): + if i - 1 >= len(migrations): + break + migration = migrations[i - 1] + try: + conn.executescript( + "BEGIN IMMEDIATE TRANSACTION;\n" + f"{migration}\n" + f"PRAGMA user_version = {i:d}" + ) + except: + conn.execute("ROLLBACK TRANSACTION") + raise + else: + conn.execute("COMMIT TRANSACTION") + try: + yield conn + finally: + conn.close() diff --git a/paste/store.py b/paste/store.py new file mode 100644 index 0000000..bae578f --- /dev/null +++ b/paste/store.py @@ -0,0 +1,52 @@ +from sqlite3 import Connection + + +def put(conn: Connection, name: str, content: bytes, content_type: str): + with conn: + conn.execute( + "INSERT OR IGNORE INTO file (content) VALUES (?)", + (content,), + ) + conn.execute( + """ + INSERT INTO link ( + name, content_type, file_hash + ) VALUES (?, ?, sha256(?)) + ON CONFLICT DO UPDATE + SET + content_type = excluded.content_type, + file_hash = excluded.file_hash""", + (name, content_type, content), + ) + + +def get(conn: Connection, name: str): + row = conn.execute( + """SELECT link.content_type, file.hash, file.content + FROM link + JOIN file ON file.hash = link.file_hash + WHERE name_hash = sha256(?)""", + (name,), + ).fetchone() + return row + + +def head(conn: Connection, name: str): + row = conn.execute( + """SELECT link.content_type, file.hash, length(file.content), + CASE + WHEN link.content_type LIKE 'text/x.redirect%' + THEN file.content + ELSE NULL + END + FROM link + JOIN file ON file.hash = link.file_hash + WHERE name_hash = sha256(?)""", + (name,), + ).fetchone() + return row + + +def delete(conn: Connection, name: str): + with conn: + conn.execute("DELETE FROM link WHERE name_hash = sha256(?)", (name,)) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..1c97d5b --- /dev/null +++ b/poetry.lock @@ -0,0 +1,221 @@ +# This file is automatically @generated by Poetry and should not be changed by hand. + +[[package]] +name = "black" +version = "23.1.0" +description = "The uncompromising code formatter." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "black-23.1.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221"}, + {file = "black-23.1.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26"}, + {file = "black-23.1.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b"}, + {file = "black-23.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104"}, + {file = "black-23.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074"}, + {file = "black-23.1.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27"}, + {file = "black-23.1.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648"}, + {file = "black-23.1.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958"}, + {file = "black-23.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a"}, + {file = "black-23.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481"}, + {file = "black-23.1.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad"}, + {file = "black-23.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8"}, + {file = "black-23.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24"}, + {file = "black-23.1.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6"}, + {file = "black-23.1.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd"}, + {file = "black-23.1.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580"}, + {file = "black-23.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468"}, + {file = "black-23.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753"}, + {file = "black-23.1.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651"}, + {file = "black-23.1.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06"}, + {file = "black-23.1.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739"}, + {file = "black-23.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9"}, + {file = "black-23.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555"}, + {file = "black-23.1.0-py3-none-any.whl", hash = "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32"}, + {file = "black-23.1.0.tar.gz", hash = "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=3.10.0.0", markers = "python_version < \"3.10\""} + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "click" +version = "8.1.3" +description = "Composable command line interface toolkit" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, + {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.7.0" +description = "Node.js virtual environment builder" +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, + {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, +] + +[package.dependencies] +setuptools = "*" + +[[package]] +name = "packaging" +version = "23.0" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, + {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, +] + +[[package]] +name = "pathspec" +version = "0.11.0" +description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"}, + {file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"}, +] + +[[package]] +name = "platformdirs" +version = "3.0.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"}, + {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"}, +] + +[package.extras] +docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] + +[[package]] +name = "pyright" +version = "1.1.293" +description = "Command line wrapper for pyright" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyright-1.1.293-py3-none-any.whl", hash = "sha256:afc05309e775a9869c864da4e8c0c7a3e3be9d8fe202e780c3bae981bbb13936"}, + {file = "pyright-1.1.293.tar.gz", hash = "sha256:9397fdfcbc684fe5b87abbf9c27f540fe3b8d75999a5f187519cae1d065be38c"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" + +[package.extras] +all = ["twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] + +[[package]] +name = "setuptools" +version = "67.2.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "setuptools-67.2.0-py3-none-any.whl", hash = "sha256:16ccf598aab3b506593c17378473978908a2734d7336755a8769b480906bec1c"}, + {file = "setuptools-67.2.0.tar.gz", hash = "sha256:b440ee5f7e607bb8c9de15259dba2583dd41a38879a7abc1d43a71c59524da48"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.4.0" +description = "Backported and Experimental Type Hints for Python 3.7+" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, +] + +[[package]] +name = "vermin" +version = "1.5.1" +description = "Concurrently detect the minimum Python versions needed to run code" +category = "dev" +optional = false +python-versions = ">=2.7" +files = [ + {file = "vermin-1.5.1-py2.py3-none-any.whl", hash = "sha256:420995de564ac0c31e2157220259d7ac82556e8fa69c112d8005b78c14b0caf5"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.9" +content-hash = "38150922c883e955841f915d7e837a0b8cf68878210c1013089e34c58dee3441" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..ffa45fe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[tool.poetry] +name = "paste" +version = "0.1.0" +description = "A simple WSGI paste site" +authors = ["Tomasz Kramkowski <tomasz@kramkow.ski>"] +license = "MIT" +readme = "README" + +[tool.poetry.dependencies] +python = "^3.9" + +[tool.poetry.group.dev.dependencies] +black = "^23.1.0" +vermin = "^1.5.1" +pyright = "^1.1.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" @@ -0,0 +1,2 @@ +#!/bin/sh +poetry run vermin --no-tips --target=3.9 --violations . |