Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions src/proper/install/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
render_blueprint,
sort_imports_in,
)
from .metadata import record_install


if t.TYPE_CHECKING:
Expand Down Expand Up @@ -49,3 +50,4 @@ def install(app: "App") -> None:

add_dependencies(app.root_path, DEPENDENCIES)
call('proper db create "users"')
record_install(app, "auth")
3 changes: 3 additions & 0 deletions src/proper/install/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from ..helpers import BLUEPRINTS
from ..helpers.render import render_blueprint, sort_imports_in
from .metadata import record_install


if t.TYPE_CHECKING:
Expand All @@ -25,3 +26,5 @@ def install(app: "App") -> None:

for filename in SORT_IMPORTS_IN:
sort_imports_in(app.root_path / filename)

record_install(app, "channels")
2 changes: 2 additions & 0 deletions src/proper/install/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
render_blueprint,
sort_imports_in,
)
from .metadata import record_install


if t.TYPE_CHECKING:
Expand Down Expand Up @@ -43,3 +44,4 @@ def install(app: "App") -> None:
)

add_dependencies(app.root_path, DEPENDENCIES)
record_install(app, "i18n")
79 changes: 79 additions & 0 deletions src/proper/install/metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""File-based tracking of installed Proper addons.

Stores a JSON document at ``app.root_path / ".proper"``:

{
"addons": {
"storage": {"version": "0.10.0", "installed_at": "2026-05-20T10:00:00Z"}
}
}

The file lives with the code in version control, so cloning the app yields
an accurate view of which addons are installed without requiring the
database to be available.
"""
import json
import typing as t
from datetime import datetime, timezone
from importlib.metadata import version as _pkg_version
from pathlib import Path


if t.TYPE_CHECKING:
from ..app import App


METADATA_FILENAME = ".proper"


def _current_version() -> str:
return _pkg_version("proper")


def metadata_path(app: "App") -> Path:
return app.root_path / METADATA_FILENAME


def load_metadata(app: "App") -> dict:
"""Return the parsed metadata dict, or an empty schema if no file exists."""
path = metadata_path(app)
if not path.exists():
return {"addons": {}}
return json.loads(path.read_text())


def is_installed(app: "App", addon: str) -> bool:
"""True if the addon has been recorded as installed in this app."""
data = load_metadata(app)
return addon in data.get("addons", {})


def record_install(
app: "App",
addon: str,
version: str | None = None,
config: dict | None = None,
) -> None:
"""Upsert the addon's install record in the metadata file.

Re-recording the same addon updates its entry rather than duplicating.
If ``version`` is not provided, the current Proper version is used.
"""
if version is None:
version = _current_version()
data = load_metadata(app)
addons = data.setdefault("addons", {})
entry: dict[str, t.Any] = {
"version": version,
"installed_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
if config is not None:
entry["config"] = config
addons[addon] = entry
_atomic_write(metadata_path(app), data)


def _atomic_write(path: Path, data: dict) -> None:
tmp = path.parent / (path.name + ".tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
tmp.replace(path)
2 changes: 2 additions & 0 deletions src/proper/install/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
render_blueprint,
sort_imports_in,
)
from .metadata import record_install


if t.TYPE_CHECKING:
Expand Down Expand Up @@ -38,3 +39,4 @@ def install(app: "App") -> None:

add_dependencies(app.root_path, DEPENDENCIES)
call('proper db create "storage"')
record_install(app, "storage")
11 changes: 6 additions & 5 deletions tests/install/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@

import pytest

from proper.install import auth
from proper.install import auth, metadata


APP_NAME = "myapp"

# Minimal app_controller.py that add_to_concerns can parse
APP_CONTROLLER = """\
from proper.controller import Controller
Expand All @@ -23,7 +21,7 @@ class AppController(
def app_in_tmp(tmp_path, app):
"""Set up a temporary app root with the files that the auth blueprint
expects to already exist (append/prepend targets and sort_imports_in targets)."""
app_root = tmp_path / APP_NAME
app_root = tmp_path / "myapp"

# Directories
for d in (
Expand All @@ -48,7 +46,7 @@ def app_in_tmp(tmp_path, app):
(app_root / "views" / "nav.jx").write_text("<nav></nav>\n")

app.root_path = app_root
app.name = APP_NAME
app.name = "myapp"
return app


Expand Down Expand Up @@ -160,3 +158,6 @@ def test_file_creation(app_in_tmp):
# adds_authentication_concern
text = (root_path / "controllers" / "app_controller.py").read_text()
assert "Authentication," in text

# records the install in .proper
assert metadata.is_installed(app_in_tmp, "auth")
18 changes: 7 additions & 11 deletions tests/install/test_channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,23 @@

import pytest

from proper.install import channels


APP_NAME = "myapp"

CONFIG_INIT = """\
from .main import * # noqa
"""
from proper.install import channels, metadata


@pytest.fixture()
def app_in_tmp(tmp_path, app):
"""Set up a temporary app root with the files that the channels blueprint
expects to already exist."""
app_root = tmp_path / APP_NAME
app_root = tmp_path / "myapp"

for d in ("config", "assets/js"):
(app_root / d).mkdir(parents=True)

CONFIG_INIT = "\nfrom .main import * # noqa\n"
(app_root / "config" / "__init__.py").write_text(CONFIG_INIT)

app.root_path = app_root
app.name = APP_NAME
app.name = "myapp"
return app


Expand All @@ -37,7 +31,6 @@ def test_file_creation(app_in_tmp):
text = path.read_text()
assert "CABLE_PATH" in text
assert "CHANNELS" in text
assert APP_NAME in text

# cable.js asset
path = app_in_tmp.root_path / "assets" / "js" / "cable.js"
Expand All @@ -46,3 +39,6 @@ def test_file_creation(app_in_tmp):
# config __init__ updated with channels import
text = (app_in_tmp.root_path / "config" / "__init__.py").read_text()
assert "from .channels import CHANNELS" in text

# records the install in .proper
assert metadata.is_installed(app_in_tmp, "channels")
11 changes: 6 additions & 5 deletions tests/install/test_i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@

import pytest

from proper.install import i18n
from proper.install import i18n, metadata


APP_NAME = "myapp"

APP_CONTROLLER = """\
from proper.controller import Controller

Expand All @@ -23,15 +21,15 @@ class AppController(
def app_in_tmp(tmp_path, app):
"""Set up a temporary app root with the files that the i18n blueprint
expects to already exist."""
app_root = tmp_path / APP_NAME
app_root = tmp_path / "myapp"

for d in ("controllers", "config"):
(app_root / d).mkdir(parents=True)

(app_root / "controllers" / "app_controller.py").write_text(APP_CONTROLLER)

app.root_path = app_root
app.name = APP_NAME
app.name = "myapp"
return app


Expand Down Expand Up @@ -70,3 +68,6 @@ def test_file_creation(app_in_tmp):
auth_pos = class_body.index("Authentication,")
tz_pos = class_body.index("CurrentTimezone,")
assert tz_pos > auth_pos

# records the install in .properfrom proper.install import metadata
assert metadata.is_installed(app_in_tmp, "i18n")
83 changes: 83 additions & 0 deletions tests/install/test_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Tests for proper.install.metadata — `.proper` file bookkeeping."""
import json

import pytest

from proper.install import metadata


@pytest.fixture()
def app_in_tmp(tmp_path, app):
app.root_path = tmp_path
return app


def test_load_metadata_returns_empty_schema_when_file_missing(app_in_tmp):
assert metadata.load_metadata(app_in_tmp) == {"addons": {}}


def test_is_installed_false_when_file_missing(app_in_tmp):
assert metadata.is_installed(app_in_tmp, "storage") is False


def test_record_install_creates_file_and_entry(app_in_tmp):
metadata.record_install(app_in_tmp, "storage", version="1.2.3")

path = app_in_tmp.root_path / ".proper"
assert path.exists()

data = json.loads(path.read_text())
assert "storage" in data["addons"]
entry = data["addons"]["storage"]
assert entry["version"] == "1.2.3"
assert "installed_at" in entry
assert entry["installed_at"].endswith("Z")
assert "config" not in entry


def test_record_install_with_config(app_in_tmp):
metadata.record_install(
app_in_tmp, "storage", version="1.2.3", config={"bucket": "x"},
)
data = metadata.load_metadata(app_in_tmp)
assert data["addons"]["storage"]["config"] == {"bucket": "x"}


def test_record_install_uses_current_proper_version_by_default(app_in_tmp):
metadata.record_install(app_in_tmp, "storage")
data = metadata.load_metadata(app_in_tmp)
assert data["addons"]["storage"]["version"] == metadata._current_version()


def test_is_installed_after_record(app_in_tmp):
metadata.record_install(app_in_tmp, "storage", version="1.0.0")
assert metadata.is_installed(app_in_tmp, "storage") is True
assert metadata.is_installed(app_in_tmp, "auth") is False


def test_record_install_upserts_does_not_duplicate(app_in_tmp):
metadata.record_install(app_in_tmp, "storage", version="1.0.0")
metadata.record_install(app_in_tmp, "storage", version="2.0.0")

data = metadata.load_metadata(app_in_tmp)
assert list(data["addons"].keys()) == ["storage"]
assert data["addons"]["storage"]["version"] == "2.0.0"


def test_record_install_preserves_other_addons(app_in_tmp):
metadata.record_install(app_in_tmp, "storage", version="1.0.0")
metadata.record_install(app_in_tmp, "auth", version="1.0.0")

data = metadata.load_metadata(app_in_tmp)
assert set(data["addons"].keys()) == {"storage", "auth"}


def test_atomic_write_leaves_no_tmp_file(app_in_tmp):
metadata.record_install(app_in_tmp, "storage", version="1.0.0")

tmp_path_file = app_in_tmp.root_path / ".proper.tmp"
assert not tmp_path_file.exists()


def test_metadata_path_helper(app_in_tmp):
assert metadata.metadata_path(app_in_tmp) == app_in_tmp.root_path / ".proper"
12 changes: 6 additions & 6 deletions tests/install/test_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@

import pytest

from proper.install import storage


APP_NAME = "myapp"
from proper.install import metadata, storage


@pytest.fixture()
def app_in_tmp(tmp_path, app):
"""Set up a temporary app root with the files that the storage blueprint
expects to already exist."""
app_root = tmp_path / APP_NAME
app_root = tmp_path / "myapp"

for d in ("config", "controllers", "models"):
(app_root / d).mkdir(parents=True)
Expand All @@ -22,7 +19,7 @@ def app_in_tmp(tmp_path, app):
(app_root / "models" / "__init__.py").write_text("")

app.root_path = app_root
app.name = APP_NAME
app.name = "myapp"
return app


Expand Down Expand Up @@ -71,3 +68,6 @@ def test_file_creation(app_in_tmp):
# config_has_allowed_inline
text = (app_in_tmp.root_path / "config" / "storage.py").read_text()
assert "STORAGE_ALLOWED_INLINE" in text

# records the install in .proper
assert metadata.is_installed(app_in_tmp, "storage")
Loading