From c70204e3a6acaea09a70237b97a79f99084a8e6c Mon Sep 17 00:00:00 2001 From: evilchili Date: Wed, 24 Sep 2025 01:28:23 -0700 Subject: [PATCH] WIP --- pyproject.toml | 5 ++-- src/ttfrog/app.py | 49 ++++++++-------------------------- src/ttfrog/cli.py | 16 ++---------- src/ttfrog/db.py | 53 +++++++++++++++++++++++++++++++++++++ src/ttfrog/schema.ph | 1 - src/ttfrog/schema.py | 58 +++++++++++++++++++++++++++++++++++++++++ test/test_db.py | 39 +++++++++++++++++++++++++++ test/test_ttfrog_cli.py | 8 ------ 8 files changed, 166 insertions(+), 63 deletions(-) delete mode 100644 src/ttfrog/schema.ph create mode 100644 test/test_db.py delete mode 100644 test/test_ttfrog_cli.py diff --git a/pyproject.toml b/pyproject.toml index 1d88687..4bd8399 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,9 @@ python-dotenv = "*" rich = "*" typer = "*" flask = "*" -flask-sqlalchemy = "^3.1.1" -flask-migrate = "^4.1.0" +tinydb = "^4.8.2" +pyyaml = "^6.0.2" +nanoid = "^2.0.0" [tool.poetry.group.dev.dependencies] pytest = "*" diff --git a/src/ttfrog/app.py b/src/ttfrog/app.py index be8d347..13e944e 100644 --- a/src/ttfrog/app.py +++ b/src/ttfrog/app.py @@ -1,50 +1,23 @@ -import importlib import os +import sys from flask import Flask -from flask_migrate import Migrate -from flask_sqlalchemy import SQLAlchemy +from tinydb.storages import MemoryStorage -_context = None +from ttfrog.db import Database class ApplicationContext: - """ - Interface for the flask application - """ - - def __init__(self, schema_module_name: str): - self.schema_module = importlib.import_module(schema_module_name) - self.db = SQLAlchemy() - self.migrate = Migrate() - self._app = Flask("flask") - + def __init__(self): + self.web: Flask = Flask("ttfrog") + self.db: Database = Database(storage=MemoryStorage) self._initialized = False - self._enable_migrations = False - self._app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "secret string") - self._app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv( - "DATABASE_URL", "sqlite:////" + os.path.join(self._app.root_path, "data.db") - ) - self._app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False - - @property - def flask(self): + def initialize(self, db: Database = None): if not self._initialized: - raise RuntimeError("You must initialize first.") - return self._app - - def initialize(self): - self.db.init_app(self._app) - if self._enable_migrations: - self.migrate.init_app(self._app, self.db) - self._initialized = True - return self.flask + self.web.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "secret string") + self.db = db or Database("ttfrog.db.json") + self._initialized = True -def initialize(schema_module_name: str = "ttfrog.schema"): - global _context - if not _context: - _context = ApplicationContext(schema_module_name) - _context.initialize() - return _context +sys.modules[__name__] = ApplicationContext() diff --git a/src/ttfrog/cli.py b/src/ttfrog/cli.py index 91455fa..14d0313 100644 --- a/src/ttfrog/cli.py +++ b/src/ttfrog/cli.py @@ -20,7 +20,7 @@ LOG_LEVEL=INFO main_app = typer.Typer() _context = ttfrog.app.initialize() -flask_app = _context.flask +flask_app = _context.app app_state = dict( config_file=Path("~/.config/ttfrog.conf").expanduser(), @@ -67,19 +67,7 @@ def run(context: typer.Context): @flask_app.shell_context_processor def make_shell_context(): - return {"db": flask_app.db, "app": flask_app._app} - - -@flask_app.cli.command() -@click.option("--drop", is_flag=True, help="Create after drop.") -@click.pass_context -def setup(ctx, drop: bool): - """ - (Re)create the database. - """ - if drop: - ctx.db.drop_all() - ctx.db.create_all() + return flask_app @click.group(cls=FlaskGroup, create_app=lambda: flask_app) diff --git a/src/ttfrog/db.py b/src/ttfrog/db.py index e69de29..0b7da82 100644 --- a/src/ttfrog/db.py +++ b/src/ttfrog/db.py @@ -0,0 +1,53 @@ +from tinydb import TinyDB, table +from tinydb.table import Document + +from ttfrog import schema + + +class RecordTable(table.Table): + """ + Wrapper around tinydb Tables that handles Records instead of dicts. + """ + + def __init__(self, storage, name, **kwargs): + self.document_class = getattr(schema, name, Document) + super().__init__(storage, name, **kwargs) + + def insert(self, document): + self._satisfy_constraints(document) + if document.doc_id: + last_insert_id = super().upsert(document)[0] + else: + last_insert_id = super().insert(dict(document)) + return self.get(doc_id=last_insert_id) + + def _satisfy_constraints(self, document): + # check for uniqueness, etc. + pass + + +class Database(TinyDB): + """ + A TinyDB database instance that uses RecordTable instances for each table + and Record instances for each document in the table. + """ + + default_table_name = "Record" + + def table(self, name: str, **kwargs) -> RecordTable: + if name not in self._tables: + self._tables[name] = RecordTable(self.storage, name, **kwargs) + return self._tables[name] + + def save(self, record): + """ + Create or update a record in its table. + """ + return self.table(record._metadata.table).insert(record) + + def __getattr__(self, attr_name): + """ + Make tables attributes of the instance. + """ + if attr_name in self.tables(): + return self.table(attr_name) diff --git a/src/ttfrog/schema.ph b/src/ttfrog/schema.ph deleted file mode 100644 index b540bc0..0000000 --- a/src/ttfrog/schema.ph +++ /dev/null @@ -1 +0,0 @@ -# schema goes here diff --git a/src/ttfrog/schema.py b/src/ttfrog/schema.py index e69de29..62597a1 100644 --- a/src/ttfrog/schema.py +++ b/src/ttfrog/schema.py @@ -0,0 +1,58 @@ +from collections import namedtuple +from dataclasses import dataclass + +import nanoid + +Metadata = namedtuple("Metadata", ["table", "fields"]) + + +@dataclass +class Field: + """ + Represents a single field in a Record. + """ + + name: str + value_type: type = str + default: value_type | None = None + unique: bool = False + + +class Record(dict): + """ + Base type for a single database record. + """ + + _fields = [Field("uid", default="", unique=True)] + + def __init__(self, raw_doc: dict = {}, doc_id: int = None, **params): + # populate the metadata + fields = Record._fields + if self.__class__ != Record: + fields += self._fields + self._metadata = Metadata(table=self.__class__.__name__, fields={f.name: f for f in fields}) + + self.doc_id = doc_id + + vals = dict({field.name: field.default for field in fields}, **raw_doc, **params) + if not vals["uid"]: + vals["uid"] = nanoid.generate(size=8) # 1% collision rate at ~2M records + + super().__init__(vals) + + def __setattr__(self, key, value): + if key in self: + self[key] = value + super().__setattr__(key, value) + + def __getattr__(self, attr_name): + if attr_name in self: + return self.get(attr_name) + return super().__getattr__(attr_name) + + def __repr__(self): + return f"{self.__class__.__name__}[{self.doc_id}]: {self.items()}" + + +class User(Record): + _fields = [Field("name"), Field("email", unique=True)] diff --git a/test/test_db.py b/test/test_db.py new file mode 100644 index 0000000..e254c95 --- /dev/null +++ b/test/test_db.py @@ -0,0 +1,39 @@ +import os + +import pytest + +import ttfrog.app +from ttfrog import schema +from ttfrog.db import Database + + +@pytest.fixture +def app(): + ttfrog.app.initialize(Database("ttfrog-tests")) + ttfrog.app.db.drop_tables() + yield ttfrog.app + ttfrog.app.db.close() + os.unlink("ttfrog-tests") + + +def test_create(app): + user = schema.User(name="john", email="john@foo") + assert user.uid + assert user._metadata.fields["uid"].unique + + # insert + john_something = app.db.save(user) + last_insert_id = john_something.doc_id + + # read back + assert app.db.User.get(doc_id=last_insert_id) == john_something + assert john_something.name == user.name + assert john_something.email == user.email + assert john_something.uid == user.uid + + # update + john_something.name = "james?" + before_update = app.db.User.get(doc_id=john_something.doc_id) + after_update = app.db.save(john_something) + assert after_update == john_something + assert before_update != after_update diff --git a/test/test_ttfrog_cli.py b/test/test_ttfrog_cli.py deleted file mode 100644 index ed09215..0000000 --- a/test/test_ttfrog_cli.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -from ttfrog import cli - - -@pytest.mark.xfail -def test_tests_are_implemented(): - assert cli.main()