diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 67d6d7e23..fcc054d81 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -75,6 +75,18 @@ jobs: curl -L $LINK/$CARGO_C_FILE | tar xz -C ~/.cargo/bin - uses: actions/checkout@v3 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + + - name: Set up Python + run: uv python install + + - name: Install the project + run: uv sync --all-extras --dev --all-packages + - uses: "./.github/shared/install_sqlite" - name: Test run: make test diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..24ee5b1be --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/Makefile b/Makefile index 46ef06c98..623fbb6ce 100644 --- a/Makefile +++ b/Makefile @@ -62,16 +62,20 @@ limbo-wasm: cargo build --package limbo-wasm --target wasm32-wasi .PHONY: limbo-wasm -test: limbo test-compat test-vector test-sqlite3 test-shell test-extensions test-memory +uv-sync: + uv sync --all-packages +.PHONE: uv-sync + +test: limbo uv-sync test-compat test-vector test-sqlite3 test-shell test-extensions test-memory test-write test-update .PHONY: test -test-extensions: limbo +test-extensions: limbo uv-sync cargo build --package limbo_regexp - ./testing/cli_tests/extensions.py + uv run --project limbo_test test-extensions .PHONY: test-extensions -test-shell: limbo - SQLITE_EXEC=$(SQLITE_EXEC) ./testing/cli_tests/cli_test_cases.py +test-shell: limbo uv-sync + SQLITE_EXEC=$(SQLITE_EXEC) uv run --project limbo_test test-shell .PHONY: test-shell test-compat: @@ -94,10 +98,18 @@ test-json: SQLITE_EXEC=$(SQLITE_EXEC) ./testing/json.test .PHONY: test-json -test-memory: - SQLITE_EXEC=$(SQLITE_EXEC) ./testing/cli_tests/memory.py +test-memory: limbo uv-sync + SQLITE_EXEC=$(SQLITE_EXEC) uv run --project limbo_test test-memory .PHONY: test-memory +test-write: limbo uv-sync + SQLITE_EXEC=$(SQLITE_EXEC) uv run --project limbo_test test-write +.PHONY: test-write + +test-update: limbo uv-sync + SQLITE_EXEC=$(SQLITE_EXEC) uv run --project limbo_test test-update +.PHONY: test-update + clickbench: ./perf/clickbench/benchmark.sh .PHONY: clickbench diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..b8d5018cc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +dependencies = [ + "rich>=14.0.0", +] +name = "limbo" +readme = "README.md" +requires-python = ">=3.13" +version = "0.1.0" + +[tool.uv] +package = false + +[tool.uv.sources] +limbo_test = { workspace = true } + +[tool.uv.workspace] +members = ["testing"] diff --git a/testing/README.md b/testing/README.md new file mode 100644 index 000000000..ef4d07cde --- /dev/null +++ b/testing/README.md @@ -0,0 +1 @@ +# Limbo Testing \ No newline at end of file diff --git a/testing/cli_tests/cli_test_cases.py b/testing/cli_tests/cli_test_cases.py index 17035b0bf..ba5e9a38f 100755 --- a/testing/cli_tests/cli_test_cases.py +++ b/testing/cli_tests/cli_test_cases.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -from test_limbo_cli import TestLimboShell +from cli_tests.test_limbo_cli import TestLimboShell from pathlib import Path import time import os +from cli_tests import console def test_basic_queries(): @@ -300,8 +301,8 @@ def test_insert_default_values(): limbo.quit() -if __name__ == "__main__": - print("Running all Limbo CLI tests...") +def main(): + console.info("Running all Limbo CLI tests...") test_basic_queries() test_schema_operations() test_file_operations() @@ -319,4 +320,8 @@ if __name__ == "__main__": test_table_patterns() test_update_with_limit() test_update_with_limit_and_offset() - print("All tests have passed") + console.info("All tests have passed") + + +if __name__ == "__main__": + main() diff --git a/testing/cli_tests/console.py b/testing/cli_tests/console.py new file mode 100644 index 000000000..2f295a90d --- /dev/null +++ b/testing/cli_tests/console.py @@ -0,0 +1,122 @@ +from typing import Any, Optional, Union +from rich.console import Console, JustifyMethod +from rich.theme import Theme +from rich.style import Style + + +custom_theme = Theme( + { + "info": "bold blue", + "error": "bold red", + "debug": "bold blue", + "test": "bold green", + } +) +console = Console(theme=custom_theme, force_terminal=True) + + +def info( + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[Union[str, Style]] = None, + justify: Optional[JustifyMethod] = None, + emoji: Optional[bool] = None, + markup: Optional[bool] = None, + highlight: Optional[bool] = None, + log_locals: bool = False, + _stack_offset: int = 1, +): + console.log( + "[info]INFO[/info]", + *objects, + sep=sep, + end=end, + style=style, + justify=justify, + emoji=emoji, + markup=markup, + highlight=highlight, + log_locals=log_locals, + _stack_offset=_stack_offset + 1, + ) + + +def error( + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[Union[str, Style]] = None, + justify: Optional[JustifyMethod] = None, + emoji: Optional[bool] = None, + markup: Optional[bool] = None, + highlight: Optional[bool] = None, + log_locals: bool = False, + _stack_offset: int = 1, +): + console.log( + "[error]ERROR[/error]", + *objects, + sep=sep, + end=end, + style=style, + justify=justify, + emoji=emoji, + markup=markup, + highlight=highlight, + log_locals=log_locals, + _stack_offset=_stack_offset + 1, + ) + + +def debug( + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[Union[str, Style]] = None, + justify: Optional[JustifyMethod] = None, + emoji: Optional[bool] = None, + markup: Optional[bool] = None, + highlight: Optional[bool] = None, + log_locals: bool = False, + _stack_offset: int = 1, +): + console.log( + "[debug]DEBUG[/debug]", + *objects, + sep=sep, + end=end, + style=style, + justify=justify, + emoji=emoji, + markup=markup, + highlight=highlight, + log_locals=log_locals, + _stack_offset=_stack_offset + 1, + ) + +def test( + *objects: Any, + sep: str = " ", + end: str = "\n", + style: Optional[Union[str, Style]] = None, + justify: Optional[JustifyMethod] = None, + emoji: Optional[bool] = None, + markup: Optional[bool] = None, + highlight: Optional[bool] = None, + log_locals: bool = False, + _stack_offset: int = 1, +): + console.log( + "[test]TEST[/test]", + *objects, + sep=sep, + end=end, + style=style, + justify=justify, + emoji=emoji, + markup=markup, + highlight=highlight, + log_locals=log_locals, + _stack_offset=_stack_offset + 1, + ) \ No newline at end of file diff --git a/testing/cli_tests/extensions.py b/testing/cli_tests/extensions.py index 6d252c543..ab57e4178 100755 --- a/testing/cli_tests/extensions.py +++ b/testing/cli_tests/extensions.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import os -from test_limbo_cli import TestLimboShell +from cli_tests.test_limbo_cli import TestLimboShell +from cli_tests import console sqlite_exec = "./scripts/limbo-sqlite3" sqlite_flags = os.getenv("SQLITE_FLAGS", "-q").split(" ") @@ -81,7 +82,7 @@ def test_regexp(): lambda res: "Parse error: no such function" in res, ) limbo.run_test_fn(f".load {extension_path}", null) - print(f"Extension {extension_path} loaded successfully.") + console.info(f"Extension {extension_path} loaded successfully.") limbo.run_test_fn("SELECT regexp('a.c', 'abc');", true) limbo.run_test_fn("SELECT regexp('a.c', 'ac');", false) limbo.run_test_fn("SELECT regexp('[0-9]+', 'the year is 2021');", true) @@ -522,7 +523,7 @@ def test_vfs(): lambda res: res == "50", "Tested large write to testfs", ) - print("Tested large write to testfs") + console.info("Tested large write to testfs") limbo.quit() @@ -588,7 +589,7 @@ def cleanup(): os.remove("testing/vfs.db-wal") -if __name__ == "__main__": +def main(): try: test_regexp() test_uuid() @@ -601,8 +602,12 @@ if __name__ == "__main__": test_kv() test_drop_virtual_table() except Exception as e: - print(f"Test FAILED: {e}") + console.error(f"Test FAILED: {e}") cleanup() exit(1) cleanup() - print("All tests passed successfully.") + console.info("All tests passed successfully.") + + +if __name__ == "__main__": + main() diff --git a/testing/cli_tests/memory.py b/testing/cli_tests/memory.py index da98bcc1d..a329ba027 100755 --- a/testing/cli_tests/memory.py +++ b/testing/cli_tests/memory.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import os -from test_limbo_cli import TestLimboShell +from cli_tests.test_limbo_cli import TestLimboShell +from cli_tests import console sqlite_flags = os.getenv("SQLITE_FLAGS", "-q").split(" ") @@ -96,15 +97,13 @@ def main(): tests = memory_tests() # TODO see how to parallelize this loop with different subprocesses for test in tests: - limbo = TestLimboShell() try: - stub_memory_test(limbo, **test) + with TestLimboShell("") as limbo: + stub_memory_test(limbo, **test) except Exception as e: - print(f"Test FAILED: {e}") - limbo.quit() + console.error(f"Test FAILED: {e}") exit(1) - limbo.quit() # remove this line when `with` statement is supported for TestLimboShell - print("All tests passed successfully.") + console.info("All tests passed successfully.") if __name__ == "__main__": diff --git a/testing/cli_tests/test_limbo_cli.py b/testing/cli_tests/test_limbo_cli.py index 55c3e548f..626d7defe 100755 --- a/testing/cli_tests/test_limbo_cli.py +++ b/testing/cli_tests/test_limbo_cli.py @@ -5,6 +5,7 @@ from time import sleep import subprocess from pathlib import Path from typing import Callable, List, Optional +from cli_tests import console PIPE_BUF = 4096 @@ -74,12 +75,15 @@ class LimboShell: def _handle_error(self) -> bool: while True: - error_output = self.pipe.stderr.read(PIPE_BUF) - if error_output == b"": - return True - print(error_output.decode(), end="") - return False - + ready, _, errors = select.select( + [self.pipe.stderr], [], [self.pipe.stderr], 0 + ) + if not (ready + errors): + break + error_output = self.pipe.stderr.read(PIPE_BUF).decode() + console.error(error_output, end="", _stack_offset=2) + raise RuntimeError("Error encountered in Limbo shell.") + @staticmethod def _clean_output(output: str, marker: str) -> str: output = output.rstrip().removesuffix(marker) @@ -128,7 +132,7 @@ INSERT INTO t VALUES (zeroblob(1024 - 1), zeroblob(1024 - 2), zeroblob(1024 - 3) self.shell.quit() def run_test(self, name: str, sql: str, expected: str) -> None: - print(f"Running test: {name}") + console.test(f"Running test: {name}", _stack_offset=2) actual = self.shell.execute(sql) assert actual == expected, ( f"Test failed: {name}\n" @@ -138,17 +142,26 @@ INSERT INTO t VALUES (zeroblob(1024 - 1), zeroblob(1024 - 2), zeroblob(1024 - 3) ) def debug_print(self, sql: str): - print(f"debugging: {sql}") + console.debug(f"debugging: {sql}", _stack_offset=2) actual = self.shell.execute(sql) - print(f"OUTPUT:\n{repr(actual)}") + console.debug(f"OUTPUT:\n{repr(actual)}", _stack_offset=2) def run_test_fn( self, sql: str, validate: Callable[[str], bool], desc: str = "" ) -> None: - actual = self.shell.execute(sql) + # Print the test that is executing before executing the sql command + # Printing later confuses the user of the code what test has actually failed if desc: - print(f"Testing: {desc}") + console.test(f"Testing: {desc}", _stack_offset=2) + actual = self.shell.execute(sql) assert validate(actual), f"Test failed\nSQL: {sql}\nActual:\n{repr(actual)}" def execute_dot(self, dot_command: str) -> None: self.shell._write_to_pipe(dot_command) + + # Enables the use of `with` syntax + def __enter__(self): + return self + + def __exit__(self, exception_type, exception_value, exception_traceback): + self.quit() diff --git a/testing/cli_tests/update.py b/testing/cli_tests/update.py new file mode 100644 index 000000000..1d0d23b63 --- /dev/null +++ b/testing/cli_tests/update.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +import os +from cli_tests.test_limbo_cli import TestLimboShell +from pydantic import BaseModel +from cli_tests import console + + +sqlite_flags = os.getenv("SQLITE_FLAGS", "-q").split(" ") + + +class UpdateTest(BaseModel): + name: str + db_schema: str = "CREATE TABLE test (key INTEGER, t1 BLOB, t2 INTEGER, t3 TEXT);" + blob_size: int = 1024 + vals: int = 1000 + updates: int = 1 + db_path: str = "testing/update.db" + + def init_db(self): + with TestLimboShell( + init_commands="", + exec_name="sqlite3", + flags=f"{self.db_path}", + ) as sqlite: + sqlite.execute_dot(f".open {self.db_path}") + zero_blob = "0" * self.blob_size * 2 + t2_val = "1" + t3_val = "2" + stmt = [self.db_schema] + stmt = stmt + [ + f"INSERT INTO test (key, t1, t2, t3) VALUES ({i} ,zeroblob({self.blob_size}), {t2_val}, {t3_val});" + for i in range(self.vals) + ] + stmt.append("SELECT count(*) FROM test;") + + sqlite.run_test( + "Init Update Db in Sqlite", + "".join(stmt), + f"{self.vals}", + ) + + stmt = [ + f"SELECT hex(t1), t2, t3 FROM test LIMIT 1 OFFSET {i};" + for i in range(self.vals) + ] + + expected = [f"{zero_blob}|{t2_val}|{t3_val}" for _ in range(self.vals)] + sqlite.run_test( + "Check Values correctly inserted in Sqlite", + "".join(stmt), + "\n".join(expected), + ) + + def run(self, limbo: TestLimboShell): + limbo.execute_dot(f".open {self.db_path}") + # TODO blobs are hard. Forget about blob updates for now + # one_blob = ("0" * ((self.blob_size * 2) - 1)) + "1" + # TODO For now update just on one row. To expand the tests in the future + # use self.updates and do more than 1 update + t2_update_val = "123" + stmt = f"UPDATE test SET t2 = {t2_update_val} WHERE key = {0};" + limbo.run_test(self.name, stmt, "") + + def test_compat(self): + console.info("Testing in SQLite\n") + + with TestLimboShell( + init_commands="", + exec_name="sqlite3", + flags=f"{self.db_path}", + ) as sqlite: + sqlite.execute_dot(f".open {self.db_path}") + zero_blob = "0" * self.blob_size * 2 + + t2_val = "1" + t2_update_val = "123" + t3_val = "2" + stmt = [] + stmt.append("SELECT count(*) FROM test;") + + sqlite.run_test( + "Check all rows present in Sqlite", + "".join(stmt), + f"{self.vals}", + ) + + stmt = [ + f"SELECT hex(t1), t2, t3 FROM test LIMIT 1 OFFSET {i};" + for i in range(self.vals) + ] + + expected = [ + f"{zero_blob}|{t2_val}|{t3_val}" + if i != 0 + else f"{zero_blob}|{t2_update_val}|{t3_val}" + for i in range(self.vals) + ] + sqlite.run_test( + "Check Values correctly updated in Sqlite", + "".join(stmt), + "\n".join(expected), + ) + console.info() + + +def cleanup(db_fullpath: str): + wal_path = f"{db_fullpath}-wal" + shm_path = f"{db_fullpath}-shm" + paths = [db_fullpath, wal_path, shm_path] + for path in paths: + if os.path.exists(path): + os.remove(path) + + +def main(): + test = UpdateTest(name="Update 1 column", vals=1) + console.info(test) + + db_path = test.db_path + try: + test.init_db() + # Use with syntax to automatically close shell on error + with TestLimboShell("") as limbo: + test.run(limbo) + + test.test_compat() + + except Exception as e: + console.error(f"Test FAILED: {e}") + cleanup(db_path) + exit(1) + # delete db after every compat test so we we have fresh db for next test + cleanup(db_path) + console.info("All tests passed successfully.") + + +if __name__ == "__main__": + main() diff --git a/testing/cli_tests/write.py b/testing/cli_tests/write.py new file mode 100755 index 000000000..e3f7fd04c --- /dev/null +++ b/testing/cli_tests/write.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +import os +from cli_tests.test_limbo_cli import TestLimboShell +from pydantic import BaseModel +from cli_tests import console + + +sqlite_flags = os.getenv("SQLITE_FLAGS", "-q").split(" ") + + +class InsertTest(BaseModel): + name: str + db_schema: str = "CREATE TABLE test (t1 BLOB, t2 INTEGER);" + blob_size: int = 1024**2 + vals: int = 100 + has_blob: bool = True + db_path: str = "testing/writes.db" + + def run(self, limbo: TestLimboShell): + zero_blob = "0" * self.blob_size * 2 + big_stmt = [self.db_schema] + big_stmt = big_stmt + [ + f"INSERT INTO test (t1) VALUES (zeroblob({self.blob_size}));" + if i % 2 == 0 and self.has_blob + else f"INSERT INTO test (t2) VALUES ({i});" + for i in range(self.vals * 2) + ] + expected = [] + for i in range(self.vals * 2): + if i % 2 == 0 and self.has_blob: + big_stmt.append(f"SELECT hex(t1) FROM test LIMIT 1 OFFSET {i};") + expected.append(zero_blob) + else: + big_stmt.append(f"SELECT t2 FROM test LIMIT 1 OFFSET {i};") + expected.append(f"{i}") + + big_stmt.append("SELECT count(*) FROM test;") + expected.append(str(self.vals * 2)) + + big_stmt = "".join(big_stmt) + expected = "\n".join(expected) + + limbo.run_test_fn( + big_stmt, lambda res: validate_with_expected(res, expected), self.name + ) + + def test_compat(self): + console.info("Testing in SQLite\n") + + with TestLimboShell( + init_commands="", + exec_name="sqlite3", + flags=f"{self.db_path}", + ) as sqlite: + sqlite.run_test_fn( + ".show", + lambda res: f"filename: {self.db_path}" in res, + "Opened db file created with Limbo in sqlite3", + ) + sqlite.run_test_fn( + ".schema", + lambda res: self.db_schema in res, + "Tables created by previous Limbo test exist in db file", + ) + sqlite.run_test_fn( + "SELECT count(*) FROM test;", + lambda res: res == str(self.vals * 2), + "Counting total rows inserted", + ) + console.info() + + +def validate_with_expected(result: str, expected: str): + return (expected in result, expected) + + +# TODO no delete tests for now +def blob_tests() -> list[InsertTest]: + tests: list[InsertTest] = [] + + for vals in range(0, 1000, 100): + tests.append( + InsertTest( + name=f"small-insert-integer-vals-{vals}", + vals=vals, + has_blob=False, + ) + ) + + tests.append( + InsertTest( + name=f"small-insert-blob-interleaved-blob-size-{1024}", + vals=10, + blob_size=1024, + ) + ) + tests.append( + InsertTest( + name=f"big-insert-blob-interleaved-blob-size-{1024}", + vals=100, + blob_size=1024, + ) + ) + + for blob_size in range(0, (1024 * 1024) + 1, 1024 * 4**4): + if blob_size == 0: + continue + tests.append( + InsertTest( + name=f"small-insert-blob-interleaved-blob-size-{blob_size}", + vals=10, + blob_size=blob_size, + ) + ) + tests.append( + InsertTest( + name=f"big-insert-blob-interleaved-blob-size-{blob_size}", + vals=100, + blob_size=blob_size, + ) + ) + return tests + + +def cleanup(db_fullpath: str): + wal_path = f"{db_fullpath}-wal" + shm_path = f"{db_fullpath}-shm" + paths = [db_fullpath, wal_path, shm_path] + for path in paths: + if os.path.exists(path): + os.remove(path) + + +def main(): + tests = blob_tests() + for test in tests: + console.info(test) + db_path = test.db_path + try: + # Use with syntax to automatically close shell on error + with TestLimboShell("") as limbo: + limbo.execute_dot(f".open {db_path}") + test.run(limbo) + + test.test_compat() + + except Exception as e: + console.error(f"Test FAILED: {e}") + cleanup(db_path) + exit(1) + # delete db after every compat test so we we have fresh db for next test + cleanup(db_path) + console.info("All tests passed successfully.") + + +if __name__ == "__main__": + main() diff --git a/testing/pyproject.toml b/testing/pyproject.toml new file mode 100644 index 000000000..58292dd91 --- /dev/null +++ b/testing/pyproject.toml @@ -0,0 +1,30 @@ +[project] +description = "Limbo Python Testing Project" +name = "limbo_test" +readme = "README.md" +requires-python = ">=3.13" +version = "0.1.0" +dependencies = [ + "faker>=37.1.0", + "pydantic>=2.11.1", +] + +[project.scripts] +test-write = "cli_tests.write:main" +test-shell = "cli_tests.cli_test_cases:main" +test-extensions = "cli_tests.extensions:main" +test-update = "cli_tests.update:main" +test-memory = "cli_tests.memory:main" + +[tool.uv] +package = true + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling", "hatch-vcs"] + +[tool.hatch.build.targets.wheel] +packages = ["cli_tests"] + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..eaf3e5bff --- /dev/null +++ b/uv.lock @@ -0,0 +1,172 @@ +version = 1 +revision = 1 +requires-python = ">=3.13" + +[manifest] +members = [ + "limbo", + "limbo-test", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "faker" +version = "37.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/a6/b77f42021308ec8b134502343da882c0905d725a4d661c7adeaf7acaf515/faker-37.1.0.tar.gz", hash = "sha256:ad9dc66a3b84888b837ca729e85299a96b58fdaef0323ed0baace93c9614af06", size = 1875707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/a1/8936bc8e79af80ca38288dd93ed44ed1f9d63beb25447a4c59e746e01f8d/faker-37.1.0-py3-none-any.whl", hash = "sha256:dc2f730be71cb770e9c715b13374d80dbcee879675121ab51f9683d262ae9a1c", size = 1918783 }, +] + +[[package]] +name = "limbo" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "rich" }, +] + +[package.metadata] +requires-dist = [{ name = "rich", specifier = ">=14.0.0" }] + +[[package]] +name = "limbo-test" +version = "0.1.0" +source = { editable = "testing" } +dependencies = [ + { name = "faker" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "faker", specifier = ">=37.1.0" }, + { name = "pydantic", specifier = ">=2.11.1" }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "pydantic" +version = "2.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/a3/698b87a4d4d303d7c5f62ea5fbf7a79cab236ccfbd0a17847b7f77f8163e/pydantic-2.11.1.tar.gz", hash = "sha256:442557d2910e75c991c39f4b4ab18963d57b9b55122c8b2a9cd176d8c29ce968", size = 782817 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/12/f9221a949f2419e2e23847303c002476c26fbcfd62dc7f3d25d0bec5ca99/pydantic-2.11.1-py3-none-any.whl", hash = "sha256:5b6c415eee9f8123a14d859be0c84363fec6b1feb6b688d6435801230b56e0b8", size = 442648 }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/05/91ce14dfd5a3a99555fce436318cc0fd1f08c4daa32b3248ad63669ea8b4/pydantic_core-2.33.0.tar.gz", hash = "sha256:40eb8af662ba409c3cbf4a8150ad32ae73514cd7cb1f1a2113af39763dd616b3", size = 434080 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/20/de2ad03ce8f5b3accf2196ea9b44f31b0cd16ac6e8cfc6b21976ed45ec35/pydantic_core-2.33.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f00e8b59e1fc8f09d05594aa7d2b726f1b277ca6155fc84c0396db1b373c4555", size = 2032214 }, + { url = "https://files.pythonhosted.org/packages/f9/af/6817dfda9aac4958d8b516cbb94af507eb171c997ea66453d4d162ae8948/pydantic_core-2.33.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a73be93ecef45786d7d95b0c5e9b294faf35629d03d5b145b09b81258c7cd6d", size = 1852338 }, + { url = "https://files.pythonhosted.org/packages/44/f3/49193a312d9c49314f2b953fb55740b7c530710977cabe7183b8ef111b7f/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff48a55be9da6930254565ff5238d71d5e9cd8c5487a191cb85df3bdb8c77365", size = 1896913 }, + { url = "https://files.pythonhosted.org/packages/06/e0/c746677825b2e29a2fa02122a8991c83cdd5b4c5f638f0664d4e35edd4b2/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4ea04195638dcd8c53dadb545d70badba51735b1594810e9768c2c0b4a5da", size = 1986046 }, + { url = "https://files.pythonhosted.org/packages/11/ec/44914e7ff78cef16afb5e5273d480c136725acd73d894affdbe2a1bbaad5/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41d698dcbe12b60661f0632b543dbb119e6ba088103b364ff65e951610cb7ce0", size = 2128097 }, + { url = "https://files.pythonhosted.org/packages/fe/f5/c6247d424d01f605ed2e3802f338691cae17137cee6484dce9f1ac0b872b/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae62032ef513fe6281ef0009e30838a01057b832dc265da32c10469622613885", size = 2681062 }, + { url = "https://files.pythonhosted.org/packages/f0/85/114a2113b126fdd7cf9a9443b1b1fe1b572e5bd259d50ba9d5d3e1927fa9/pydantic_core-2.33.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f225f3a3995dbbc26affc191d0443c6c4aa71b83358fd4c2b7d63e2f6f0336f9", size = 2007487 }, + { url = "https://files.pythonhosted.org/packages/e6/40/3c05ed28d225c7a9acd2b34c5c8010c279683a870219b97e9f164a5a8af0/pydantic_core-2.33.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5bdd36b362f419c78d09630cbaebc64913f66f62bda6d42d5fbb08da8cc4f181", size = 2121382 }, + { url = "https://files.pythonhosted.org/packages/8a/22/e70c086f41eebd323e6baa92cc906c3f38ddce7486007eb2bdb3b11c8f64/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2a0147c0bef783fd9abc9f016d66edb6cac466dc54a17ec5f5ada08ff65caf5d", size = 2072473 }, + { url = "https://files.pythonhosted.org/packages/3e/84/d1614dedd8fe5114f6a0e348bcd1535f97d76c038d6102f271433cd1361d/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:c860773a0f205926172c6644c394e02c25421dc9a456deff16f64c0e299487d3", size = 2249468 }, + { url = "https://files.pythonhosted.org/packages/b0/c0/787061eef44135e00fddb4b56b387a06c303bfd3884a6df9bea5cb730230/pydantic_core-2.33.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:138d31e3f90087f42aa6286fb640f3c7a8eb7bdae829418265e7e7474bd2574b", size = 2254716 }, + { url = "https://files.pythonhosted.org/packages/ae/e2/27262eb04963201e89f9c280f1e10c493a7a37bc877e023f31aa72d2f911/pydantic_core-2.33.0-cp313-cp313-win32.whl", hash = "sha256:d20cbb9d3e95114325780f3cfe990f3ecae24de7a2d75f978783878cce2ad585", size = 1916450 }, + { url = "https://files.pythonhosted.org/packages/13/8d/25ff96f1e89b19e0b70b3cd607c9ea7ca27e1dcb810a9cd4255ed6abf869/pydantic_core-2.33.0-cp313-cp313-win_amd64.whl", hash = "sha256:ca1103d70306489e3d006b0f79db8ca5dd3c977f6f13b2c59ff745249431a606", size = 1956092 }, + { url = "https://files.pythonhosted.org/packages/1b/64/66a2efeff657b04323ffcd7b898cb0354d36dae3a561049e092134a83e9c/pydantic_core-2.33.0-cp313-cp313-win_arm64.whl", hash = "sha256:6291797cad239285275558e0a27872da735b05c75d5237bbade8736f80e4c225", size = 1908367 }, + { url = "https://files.pythonhosted.org/packages/52/54/295e38769133363d7ec4a5863a4d579f331728c71a6644ff1024ee529315/pydantic_core-2.33.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7b79af799630af263eca9ec87db519426d8c9b3be35016eddad1832bac812d87", size = 1813331 }, + { url = "https://files.pythonhosted.org/packages/4c/9c/0c8ea02db8d682aa1ef48938abae833c1d69bdfa6e5ec13b21734b01ae70/pydantic_core-2.33.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eabf946a4739b5237f4f56d77fa6668263bc466d06a8036c055587c130a46f7b", size = 1986653 }, + { url = "https://files.pythonhosted.org/packages/8e/4f/3fb47d6cbc08c7e00f92300e64ba655428c05c56b8ab6723bd290bae6458/pydantic_core-2.33.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8a1d581e8cdbb857b0e0e81df98603376c1a5c34dc5e54039dcc00f043df81e7", size = 1931234 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "rich" +version = "14.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/53/830aa4c3066a8ab0ae9a9955976fb770fe9c6102117c8ec4ab3ea62d89e8/rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725", size = 224078 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/63f4c7ebc259242c89b3acafdb37b41d1185c07ff0011164674e9076b491/rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0", size = 243229 }, +] + +[[package]] +name = "typing-extensions" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/3e/b00a62db91a83fff600de219b6ea9908e6918664899a2d85db222f4fbf19/typing_extensions-4.13.0.tar.gz", hash = "sha256:0a4ac55a5820789d87e297727d229866c9650f6521b64206413c4fbada24d95b", size = 106520 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/86/39b65d676ec5732de17b7e3c476e45bb80ec64eb50737a8dce1a4178aba1/typing_extensions-4.13.0-py3-none-any.whl", hash = "sha256:c8dd92cc0d6425a97c18fbb9d1954e5ff92c1ca881a309c45f06ebc0b79058e5", size = 45683 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 }, +]