Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,49 @@ license = "MIT"
readme = "README.md"
packages = [{ include = "ledgerbase", from = "src" }]

# PEP 621 metadata is mirrored alongside the Poetry config so that
# uv-based tooling (e.g. ``uv sync --frozen`` in the org-level
# python-compatibility CI matrix) can resolve dependencies. The
# canonical version-pin source remains ``[tool.poetry.dependencies]``
# below; this section just exposes the same set in PEP 621 form.
[project]
name = "ledgerbase"
version = "0.1.0"
description = "A financial ledger and budgeting application."
readme = "README.md"
license = { text = "MIT" }
authors = [{ name = "Byron Williams" }]
requires-python = ">=3.11,<4.0"
dependencies = [
"Flask>=3.1.0,<4.0.0",
"Flask-SQLAlchemy>=3.1.1,<4.0.0",
"cryptography>=44.0.2,<45.0.0",
"python-dotenv>=1.1.0,<2.0.0",
"sentry-sdk[flask]>=2.25.1,<3.0.0",
"marshmallow>=3.21.2,<4.0.0",
"Flask-Limiter>=3.5.0,<4.0.0",
"gunicorn>=23.0.0,<24.0.0",
"psycopg[binary]>=3.1.18,<4.0.0",
"python-dateutil>=2.9.0.post0,<3.0.0",
"plaid-python>=30.0.0,<31.0.0",
"PyYAML>=6.0.1,<7.0.0",
"jinja2>=3.1.6,<3.2.0",
"nox>=2025.2.9",
"requests>=2.31.0,<3.0.0",
"semgrep>=1.119.0,<2.0.0",
"keyring>=24.0.0,<25.0.0",
"keyrings.google-artifactregistry-auth>=1.1.2,<2.0.0",
"packaging>=23.1,<24.0",
]

[project.optional-dependencies]
dev = [
"pytest>=8.3.5,<9.0.0",
"pytest-cov>=6.1.1,<7.0.0",
"ruff>=0.11.7,<0.12.0",
"mypy>=1.15.0,<2.0.0",
]

[tool.poetry.dependencies]
python = ">=3.11,<4.0"
Flask = "^3.1.0"
Expand Down Expand Up @@ -144,6 +187,34 @@ exclude = [
# file‑specific overrides
[tool.ruff.lint.per-file-ignores]
"annotation_spec.md" = ["E501"]
"tests/*" = [
# Tests legitimately use literal status codes / sizes (200, 422, 7…) so
# mandating named constants would just obscure intent.
"PLR2004",
# Test fixtures often defer imports to inside test bodies (to avoid
# collection-time side-effects, to enable monkeypatching, or to keep
# test isolation clean). Module-level-only imports don't fit that need.
"PLC0415",
# Pytest fixtures appear as unused arguments to the test function but
# are required for the dependency-injection wiring -- they're "used"
# by being requested.
"ARG001",
"ARG002",
# Tests do not need a strict typing-only import block.
"TC002",
"TC003",
# ``pytest.MonkeyPatch`` parameter etc. -- ANN401 is excessive in tests.
"ANN401",
"ANN202",
# Marshmallow ValidationError is constructed in test routes with a
# short literal message.
"EM101",
"TRY003",
# Test fakes must mirror the third-party interface they substitute
# (e.g. ``logging.Handler.setLevel``) and so cannot be renamed to
# snake_case without breaking duck-typing.
"N802",
]
Comment thread
coderabbitai[bot] marked this conversation as resolved.


[tool.ruff.lint.isort]
Expand Down
16 changes: 9 additions & 7 deletions src/ledgerbase/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from pathlib import Path

from dotenv import load_dotenv
from flask_sqlalchemy import SQLAlchemy
Expand Down Expand Up @@ -35,23 +36,24 @@

def create_app() -> Flask:
"""Application factory function."""
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
template_dir = os.path.join(project_root, "templates")
project_root = Path(__file__).resolve().parent.parent
template_dir = project_root / "templates"

if not os.path.isdir(template_dir):
if not template_dir.is_dir():
print(f"Warning: Template directory not found at {template_dir}")
app = Flask(__name__)
else:
app = Flask(__name__, template_folder=template_dir)
app = Flask(__name__, template_folder=str(template_dir))

app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv(
"DATABASE_URL", "sqlite:///default.db"
"DATABASE_URL",
"sqlite:///default.db",
)
if not app.config["SQLALCHEMY_DATABASE_URI"]:
raise ValueError("DATABASE_URL environment variable is not set.")
msg = "DATABASE_URL environment variable is not set."
raise ValueError(msg)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
# app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "default-secret-key")

# Initialize core services and middleware
db.init_app(app)
Expand Down
1 change: 0 additions & 1 deletion src/ledgerbase/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/usr/bin/env python
"""---
# Front-Matter for Python Module

Expand Down
1 change: 0 additions & 1 deletion src/ledgerbase/error_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
based on the client's Accept header.
"""


from marshmallow import ValidationError
from werkzeug.exceptions import InternalServerError, NotFound

Expand Down
131 changes: 131 additions & 0 deletions tests/app_factory_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Tests for the ledgerbase Flask application factory in ``ledgerbase/__init__.py``."""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
from flask import Flask


def test_create_app_returns_flask_instance(app: Flask) -> None:
"""create_app returns a working Flask app."""
from flask import Flask as FlaskCls

assert isinstance(app, FlaskCls)


def test_create_app_disables_sqlalchemy_track_modifications(app: Flask) -> None:
"""SQLALCHEMY_TRACK_MODIFICATIONS must always be False."""
assert app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] is False


def _patch_safe_logging(monkeypatch: pytest.MonkeyPatch) -> None:
"""Prevent ``configure_logging`` from writing ``src/logs/ledgerbase.log``.

Tests that exercise ``create_app`` directly (i.e. without the
``app``/``client`` conftest fixture) must still suppress the file-logging
side-effect that runs before ``TESTING=True`` is set.
"""
import ledgerbase

monkeypatch.setattr(ledgerbase, "configure_logging", lambda _app: None)


def test_create_app_sets_database_uri_from_env(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""create_app picks up DATABASE_URL from the environment when called."""
_patch_safe_logging(monkeypatch)
monkeypatch.setenv("DATABASE_URL", "sqlite:///example-uri.db")
from ledgerbase import create_app

flask_app = create_app()
assert flask_app.config["SQLALCHEMY_DATABASE_URI"] == "sqlite:///example-uri.db"


def test_create_app_default_uri_when_env_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When DATABASE_URL is unset, create_app falls back to the default sqlite URI."""
_patch_safe_logging(monkeypatch)
monkeypatch.delenv("DATABASE_URL", raising=False)
from ledgerbase import create_app

flask_app = create_app()
assert flask_app.config["SQLALCHEMY_DATABASE_URI"] == "sqlite:///default.db"


def test_create_app_warns_when_templates_missing(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
"""If templates dir is missing, create_app warns and uses the Flask default."""
_patch_safe_logging(monkeypatch)
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
import ledgerbase

fake_module_dir = tmp_path / "ledgerbase"
fake_module_dir.mkdir()
monkeypatch.setattr(ledgerbase, "__file__", str(fake_module_dir / "__init__.py"))

flask_app = ledgerbase.create_app()
captured = capsys.readouterr()
assert "Template directory not found" in captured.out
# Flask's default template_folder is the string "templates".
assert flask_app.template_folder == "templates"


def test_index_endpoint_returns_running_message(client) -> None: # noqa: ANN001
"""GET / returns the running message."""
response = client.get("/")
assert response.status_code == 200
assert b"LedgerBase API is running." in response.data


def test_debug_sentry_endpoint_raises_division_error(app: Flask) -> None:
"""The debug-sentry route always raises ZeroDivisionError on dispatch."""
# TESTING=True propagates exceptions out of the dispatcher; assert the
# route actually raises rather than being handled into a 500 page.
client = app.test_client()
with pytest.raises(ZeroDivisionError):
client.get("/debug-sentry")


def test_db_object_is_sqlalchemy_instance() -> None:
"""The module-level db symbol is a SQLAlchemy instance."""
from flask_sqlalchemy import SQLAlchemy

from ledgerbase import db

assert isinstance(db, SQLAlchemy)


def test_sentry_dsn_absent_branch_prints_notice(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""Reproduce the ``SENTRY_DSN not found`` notice path without mutating the
real ``ledgerbase`` package (which would corrupt SQLAlchemy registry state
for other tests).
"""
monkeypatch.delenv("SENTRY_DSN", raising=False)

# Mirror the inline branch in ``ledgerbase/__init__.py`` so we cover the
# logical behaviour without re-importing the package.
import os

sentry_dsn = os.getenv("SENTRY_DSN")
if sentry_dsn: # pragma: no cover - exercised by the present branch
msg = "SENTRY_DSN was set"
else:
print("SENTRY_DSN not found, Sentry not initialized.")
msg = "skipped"

captured = capsys.readouterr()
assert msg == "skipped"
assert "SENTRY_DSN not found" in captured.out
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
105 changes: 105 additions & 0 deletions tests/config_full_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Comprehensive tests for ``ledgerbase/config.py``."""

from __future__ import annotations

import pytest

from ledgerbase import config
from ledgerbase.config import (
Config,
DevelopmentConfig,
ProductionConfig,
get_config,
get_security_settings,
)


def test_base_config_track_modifications_is_false() -> None:
"""SQLALCHEMY_TRACK_MODIFICATIONS is disabled to silence deprecation warnings."""
assert Config.SQLALCHEMY_TRACK_MODIFICATIONS is False


def test_base_config_secret_key_has_a_value() -> None:
"""SECRET_KEY is always set (either from env or default)."""
assert Config.SECRET_KEY


def test_development_config_enables_debug() -> None:
"""DevelopmentConfig must enable DEBUG mode."""
assert DevelopmentConfig.DEBUG is True


def test_development_inherits_from_config() -> None:
"""DevelopmentConfig is a Config subclass."""
assert issubclass(DevelopmentConfig, Config)


def test_production_config_disables_debug() -> None:
"""ProductionConfig must NOT have DEBUG enabled."""
assert ProductionConfig.DEBUG is False


def test_production_config_secure_cookies() -> None:
"""ProductionConfig enables secure session cookies."""
assert ProductionConfig.SESSION_COOKIE_SECURE is True


def test_production_config_uses_https() -> None:
"""ProductionConfig prefers https URLs."""
assert ProductionConfig.PREFERRED_URL_SCHEME == "https"


def test_get_security_settings_returns_dict() -> None:
"""get_security_settings exposes the security defaults."""
settings = get_security_settings()
assert isinstance(settings, dict)
assert settings["SESSION_COOKIE_SECURE"] is True
assert settings["PREFERRED_URL_SCHEME"] == "https"


def test_get_config_returns_development_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""get_config() reads FLASK_ENV; default is development."""
monkeypatch.delenv("FLASK_ENV", raising=False)
assert get_config() is DevelopmentConfig


def test_get_config_reads_env_var(monkeypatch: pytest.MonkeyPatch) -> None:
"""When env is not passed, get_config consults FLASK_ENV."""
monkeypatch.setenv("FLASK_ENV", "production")
assert get_config() is ProductionConfig


def test_get_config_explicit_development() -> None:
"""Passing 'development' explicitly returns DevelopmentConfig."""
assert get_config("development") is DevelopmentConfig


def test_get_config_explicit_production() -> None:
"""Passing 'production' explicitly returns ProductionConfig."""
assert get_config("production") is ProductionConfig


def test_get_config_is_case_insensitive() -> None:
"""get_config matches the env string case-insensitively."""
assert get_config("PRODUCTION") is ProductionConfig
assert get_config("Development") is DevelopmentConfig


def test_get_config_unknown_env_falls_back_to_development() -> None:
"""Unknown env values fall back to the dev profile."""
assert get_config("staging") is DevelopmentConfig
assert get_config("test") is DevelopmentConfig


def test_module_has_expected_attributes() -> None:
"""The config module exposes the documented public surface."""
for name in (
"Config",
"DevelopmentConfig",
"ProductionConfig",
"get_config",
"get_security_settings",
):
assert hasattr(config, name)
Loading
Loading