Skip to content

Fix #282, #283, #284 — footer link override, settings env namespacing, package-update pin style - #287

Merged
antosubash merged 7 commits into
mainfrom
claude/github-issues-rnukh8
Sep 1, 2026
Merged

Fix #282, #283, #284 — footer link override, settings env namespacing, package-update pin style#287
antosubash merged 7 commits into
mainfrom
claude/github-issues-rnukh8

Conversation

@antosubash

@antosubash antosubash commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Fixes the three open issues that had no PR. Each is a separate commit; a fourth generalises #283's root cause, which turned out to affect five more settings classes, and two later commits fix findings from reviewing the branch.

Closes #282, closes #283, closes #284.

#284smpy package-update rewrote pin style, not just versions

package-update rewrote every constraint to name>=, so bumping versions silently changed a project's pinning policy. A host pinned at ==0.0.32 came back as >=0.0.32 while the module wheels it depends on still pin exactly, leaving the effective version decided by whichever wheel pins hardest.

The bump now changes the version and nothing else:

Constraint Latest Result
==0.0.32 0.0.33 ==0.0.33
>=0.0.32 0.0.33 >=0.0.33
==0.0.33 0.0.32 unchanged — never rewrite a pin backwards
>=0.0.33 0.0.32 unchanged — a floor only rises
~=1.4.2 1.4.9 ~=1.4.9 — same compatible band
~=1.4 1.5.0 ~=1.5 — same band width, not ~=1.5.0
~=1.4 2.0.0 unchanged, reported — ~=1.4 means >=1.4, ==1.*
==1.0.* 1.0.5 unchanged — the band already allows it
==1.0.* 1.1.0 unchanged, reported
>=1.0,!=1.0.* 1.0.5 unchanged, reported
>0.0.1 0.0.33 unchanged — already satisfied, and >0.0.33 would exclude the release being installed
>=0.1,<1.0 2.0.0 unchanged, reported as 2.0.0 excluded by <1.0
>=0.1,<3.0 2.0.0 >=2.0.0,<3.0
(no constraint) 0.0.33 >=0.0.33 — no pin style to preserve

Wildcards and ~= are the subtle half. version_key maps * to 0, so comparing !=1.0.* numerically reads it as !=1.0.0 and it never fires — which produced >=1.0.5,!=1.0.*, a specifier nothing can satisfy, right before the tool tells you to run uv sync. Wildcards now match on a release-segment prefix and never reach the numeric comparison.

~= moves only within the band it implies, and only at its own width: ~=1.4 becomes ~=1.5, never ~=1.5.0. The wider form means >=1.4, ==1.*; rewriting it with an extra segment narrows that to ==1.5.*, and the next run then reports 1.6.0 excluded by ~=1.5.0 and refuses to move — the dependency freezes one update in, which is the pin-style change this command exists to avoid.

Nothing moves backwards, either. PyPI's newest release is legitimately older than the file whenever a version has been bumped in the workspace but not yet published — which is this repo's own steady state, since every module pins simple_module_core==<next> ahead of the release. With exact pins now preserved in place rather than loosened to >=, rewriting ==0.0.33 to ==0.0.32 would be a downgrade the following uv sync installs.

--loosen restores the previous blanket >= rewrite.

Two incidental fixes fall out of parsing requirements properly rather than splitting on the first operator: extras (pkg[redis]) and environment markers (; python_version >= '3.12') now survive, where before they were dropped.

Requirement parsing moves to requirements.py and the PyPI lookup to pypi.py, keeping every file under the 300-line cap.

#283BackgroundTasksSettings read unprefixed env vars

SettingsConfigDict(extra="ignore") with no env_prefix doesn't disable env reads — it un-namespaces them, so pydantic resolved each field from its bare name. The documented SM_BG_TASKS_* names did nothing while broker_url, result_backend, retention_days and friends were live reads.

Setting env_prefix=ENV_PREFIX gives the class one rule and aligns it with the docstring, settings.env_vars, the docker-compose recipe, tasks.py and _assert_broker_isolated — all of which already assumed the prefix. The one-off default_factory reads and the env_bool call on task_always_eager become redundant and go — the env source answers those names now. (The broker and result-backend factories come back in the merge below, for SM_REDIS_URL rather than for their own names.)

The localhost validator also names the mechanism it expects. It was most people's first encounter with this and said only "set these to the Redis service host", so the natural guess was the prefixed name that had no effect. It now leads with SM_REDIS_URL — one URL seeds both halves — and marks the per-field names as deprecated overrides, so it agrees with the deprecation warning rather than recommending what that warning flags.

Breaking: deployments relying on the accidental bare names must rename them to SM_BG_TASKS_*.

The same bug in five more classes

The cause wasn't specific to background_tasks. Verified on main, with these variables set in the environment:

SiteLockSettings()     -> enabled=True  password='hunter2'
UsersSettings()        -> smtp_host='evil.example.com' base_url='http://evil.example.com'
FileStorageSettings()  -> backend='s3'  s3_bucket='attacker-bucket'

enabled, password, backend, base_url and client_secret are common enough in a container that an unrelated component setting one silently reconfigures the app — and site_lock's pair is the site gate, while users' base_url is the origin of password-reset links.

DbBackedSettings (new, in simple_module_core.settings_base) keeps only the init source, so values come from the constructor — which is how DB hydration already sets them — and nothing else. site_lock, users, file_storage, settings and branding now subclass it. This is what _module_settings.py already told the admin UI was true, and what the 2026-04-21 DB-backed-settings plan intended by dropping env_prefix.

background_tasks deliberately keeps BaseSettings + an explicit prefix: its broker URL must be readable before any DB row exists.

HostSettings was in that list until #290 landed on main. #290 makes it deliberately env-readable — its docstring states "Precedence is env → DB → the defaults declared here" — and _preapp_config.merge_host_settings reads it before create_app builds anything, so dropping the env source would break the first-run setup wizard. Its env_prefix="SM_" already closes the bare-name hole this issue reported, which is the part that mattered; the merge takes main's version, and the Settings shim goes back to main's too, since the source override it needed only existed to work around HostSettings having no environment.

background_tasks also picks up #290's SM_REDIS_URL, with one interaction worth naming: with env_prefix set, pydantic's env source answers SM_BG_TASKS_BROKER_URL / SM_BG_TASKS_RESULT_BACKEND before any default factory runs, so #290's deprecation warning — which lived inside that factory — would have gone silent. It moves to a validator that fires when the legacy variable is the value's actual source, and #290's own warning tests pass unchanged.

#282 — footer links hardcoded to the framework repo

BRAND_FOOTER_LINKS was a module-level constant and BrandingFooter mapped over it directly, so every app advertised antosubash/simple_module_python on every page. The configurable footer from #222 was removed by #273/#275, taking the only override with it.

footer_links joins the other branding values: DB-backed, in the branding shared prop as footerLinks, and edited at /admin/branding without a redeploy. BrandingFooter takes an optional links prop and falls back to BRAND_FOOTER_LINKS when it is absent, null or empty — so a deployment that never sets any keeps today's footer, and clearing the list is how you go back to it.

Deliberately just {label, href}, capped at 6. What #273 removed had grown columns, social icons and a tagline; what hosts actually lost was the ability to stop advertising the framework's repository.

href is checked against an allow-list — http://, https://, mailto:, or a site-relative path starting with a single /. The value is rendered straight into an <a href> on every page, signed-in or not, so javascript: and data: would make this screen a stored-XSS sink for anyone holding branding.manage. Scheme-relative //host is rejected, and so are raw backslashes: browsers normalise \ to / in the authority position, so /\evil.example.com reads as a same-site path but navigates off-site. %5C still works for a backslash in a path, since percent-decoding happens after the authority is parsed.

Also fixes change detection in apply_changes_and_reload, which compared changes against the settings attribute. A field typed as a list of models holds model instances while changes carries plain dicts, so an unchanged list never compared equal and was rewritten on every save.

Verification

On the current head (3ac681f):

  • uv run pytest — 2382 passed, 46 deselected
  • make test-js — 139 passed across 23 files
  • make lint — clean (the one Biome warning, an unused import in background_tasks/pages/components/ExecutionRow.tsx, is pre-existing on main and untouched here)
  • make doctor — 0 diagnostics

New tests: framework/cli/tests/test_cli_package_update_pins.py (pin styles, wildcards, ~= bands and band width, upper bounds, extras/markers, never-downgrade, --loosen), framework/core/tests/test_settings_base.py, modules/background_tasks/tests/test_bg_settings_env.py, modules/branding/tests/test_footer_links.py (including the href allow-list), and packages/ui/src/components/BrandingFooter.test.tsx.

Notes for review

main moved four merges ahead while this branch sat, and #290 reworked the same env-vs-DB question #283 touched — see the merge commit 88d4c81 for how each conflict was decided. One non-conflict casualty: SidebarLayout.tsx crossed the 300-line cap once #290's growth met the footerLinks prop, so the prop is hoisted to a local to keep the call on one line.

Two design forks in the issues were resolved as follows, both the option the issue leaned toward:

https://claude.ai/code/session_012k5QBVkMXJqJLVqtWbZJht


Generated by Claude Code

claude added 4 commits August 27, 2026 05:40
`smpy package-update` rewrote every constraint to `name>=<latest>`, so
bumping versions silently changed a project's pinning policy. A host that
pinned `==0.0.32` came back loosened to `>=0.0.32` while the module wheels
it depends on still pin exactly — leaving the effective version decided by
whichever wheel pins hardest rather than by the host.

The bump now changes the version and nothing else:

- `==`, `===`, `~=`, `>=` have their version replaced in place.
- `>` is left alone; anything newer already satisfies it, and `>latest`
  would exclude the release being installed.
- `<`, `<=`, `!=` are preserved verbatim, and a dependency whose upper
  bound excludes the latest release (`>=0.1,<1.0` when 2.0.0 is out) is
  reported and skipped instead of having its ceiling dropped.
- A dependency with no constraint has no pin style to preserve, so it
  still gets `>=<latest>`.

`--loosen` restores the previous blanket `>=` rewrite.

Two incidental fixes fall out of parsing requirements properly rather
than splitting on the first operator: extras (`pkg[redis]`) and
environment markers (`; python_version >= '3.12'`) now survive the
rewrite, where before they were dropped.

Requirement parsing moves to `requirements.py` and the PyPI lookup to
`pypi.py`, keeping every file under the 300-line cap.
…#283)

`BackgroundTasksSettings` subclassed `BaseSettings` with no `env_prefix`,
so pydantic-settings resolved every field from its bare, case-insensitive
name. The documented `SM_BG_TASKS_*` variables did nothing, while
`broker_url`, `result_backend`, `task_default_queue`, `retention_days`
and `max_retries` were live environment reads under names generic enough
that another component setting them for its own purposes would silently
reconfigure Celery. It also made the DB the source of truth only when
nobody happened to have those names in the environment.

Setting `env_prefix=ENV_PREFIX` gives the class a single rule and aligns
it with the docstring, `settings.env_vars`, the `smpy` docker-compose
recipe, `seed_dev_settings.py`, `tasks.py` and the worker's
`_assert_broker_isolated` — all of which already assume the prefix. The
one-off `default_factory` reads on `broker_url`/`result_backend` and the
`env_bool` call on `task_always_eager` are now redundant and gone.

The localhost validator also names the mechanism it expects. It was most
people's first encounter with this and said only "set these to the Redis
service host", so the natural guess was the prefixed name that had no
effect; it now names `SM_BG_TASKS_BROKER_URL` /
`SM_BG_TASKS_RESULT_BACKEND` and says why an env var is the only thing
that can satisfy it before hydration.

Deployments relying on the accidental bare names must rename them.
Generalises the root cause of GH #283. Every bundled settings class
subclassed `BaseSettings` and simply omitted `env_prefix`, which does not
disable environment reads — it un-namespaces them. pydantic-settings
still installs its env source and, with no prefix, resolves each field
from the bare, case-insensitive field name.

Verified before this change, with the named variables set in the
environment:

    SiteLockSettings()     -> enabled=True  password='hunter2'
    UsersSettings()        -> smtp_host='evil.example.com'
                              base_url='http://evil.example.com'
    FileStorageSettings()  -> backend='s3'  s3_bucket='attacker-bucket'

`enabled`, `password`, `backend`, `base_url`, `client_secret` and
`maintenance_mode` are common enough in a container that an unrelated
component setting one silently reconfigures the app — and site_lock's
pair is the site gate, while users' `base_url` is the origin of
password-reset links. It also made the DB the source of truth only when
nobody happened to have those names in the environment.

`DbBackedSettings` (new, in `simple_module_core.settings_base`) keeps
only the init source, so values come from the constructor — which is how
DB hydration already sets them — and from nothing else. site_lock, users,
file_storage, settings, branding, keycloak and HostSettings now subclass
it. This is what `_module_settings.py` already told the admin UI was
true, and what the 2026-04-21 DB-backed-settings plan intended by
dropping `env_prefix`.

`background_tasks` deliberately keeps `BaseSettings` + an explicit
`SM_BG_TASKS_` prefix: its broker URL must be readable before any DB row
exists.

The `Settings` shim needed an explicit override. It combines
`HostSettings` with `BootstrapSettings`, and `HostSettings` now comes
first in the MRO carrying the source override with it — which would have
stripped the environment from the bootstrap half, where
`SM_DATABASE_URL`, `SM_SECRET_KEY` and `SM_AUTH_PROVIDER` are read by
design. It restores pydantic's default ordering, so the shim behaves
exactly as before.

`i18n_supported_locales` moves to `default_factory` on the way past:
ruff's RUF012 pydantic exemption keys off the literal `BaseSettings`
base, so the mutable default became visible once the base changed.
Since 0.0.32 there was no supported way for a deployment to change the
footer links: `BRAND_FOOTER_LINKS` was a module-level constant in
`@simple-module-py/ui`, and `BrandingFooter` mapped over it directly. The
configurable footer shipped in 0.0.21 (#222) was removed by #273/#275,
which took the only override with it — so every app advertised
`antosubash/simple_module_python` under "Docs", "Changelog" and "GitHub"
on every page, guest and authenticated. The one workaround, aliasing the
brand module in the host's `vite.config.ts`, silently diverges from the
package on every bump.

`footer_links` joins the other branding values: DB-backed, in the
`branding` shared prop as `footerLinks`, and edited at `/admin/branding`
without a redeploy. `BrandingFooter` takes an optional `links` prop and
falls back to `BRAND_FOOTER_LINKS` when it is absent, null or empty, so a
deployment that never sets any keeps exactly the footer it has today —
and clearing the list is how you go back to them.

Deliberately just `{label, href}` and a cap of 6. What #273 removed had
grown columns, social icons and a tagline; what hosts actually lost was
the ability to stop advertising the framework's repository.

`href` is checked against an allow-list — `http://`, `https://`,
`mailto:` or a site-relative path starting with a single `/`. The value
is rendered straight into an `<a href>` on every page, signed-in or not,
so `javascript:` and `data:` would make this screen a stored-XSS sink for
anyone holding `branding.manage`; scheme-relative `//host` is rejected
too, since it reads as a relative path but navigates off-site. Labels are
bounded and reject control characters, as `app_name` already does.

Also fixes the change detection in `apply_changes_and_reload`, which
compared `changes` against the settings *attribute*. A field typed as a
list of models holds model instances while `changes` carries the plain
dicts a DTO dumps to, so an unchanged list never compared equal and was
rewritten to the store on every save. It now compares against the dumped
current value, which is identical for scalars.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 27, 2026

Copy link
Copy Markdown

Deploying simple-module-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3ac681f
Status: ✅  Deploy successful!
Preview URL: https://d05b0503.simple-module-python.pages.dev
Branch Preview URL: https://claude-github-issues-rnukh8.simple-module-python.pages.dev

View logs

claude added 3 commits August 27, 2026 10:00
Four defects found reviewing this branch.

**`!=1.0.*` never excluded the latest, producing an unsatisfiable pin.**
`version_key` maps `*` to 0, so `!=1.0.*` compared numerically read as
`!=1.0.0` and `_excluded_by` returned nothing. `>=1.0,!=1.0.*` with latest
1.0.5 was rewritten to `>=1.0.5,!=1.0.*`, which nothing can satisfy — and
the tool's own closing message tells you to run `uv sync` next. Wildcards
now match on a release-segment prefix and never go through the numeric
comparison.

**`~=` and `==X.Y.*` were treated as pure floors, ignoring their implicit
ceilings.** `~=1.4` means `>=1.4, ==1.*`, so bumping it to `~=2.0.0` moved
it to a different compatible band entirely instead of taking the documented
"upper bound excludes latest → skip and report" path. `==1.0.*` with latest
1.0.5 was narrowed to `==1.0.5`, though the band already allowed it.
`~=` now moves only within its band, and a wildcard band that already
covers the latest is left exactly as written.

**`clean_footer_href` accepted `/\evil.example.com`.** The guard was
`startswith("/") and not startswith("//")`, but browsers normalise `\` to
`/` in the authority position of a special-scheme URL, so that href reads
as a site-relative path and navigates to `https://evil.example.com` — the
exact bypass the `//host` rule exists to close, reachable by anyone with
`branding.manage` and landing on every page including the anonymous public
shell. Raw backslashes are now rejected outright; `%5C` still works for one
in a path, since percent-decoding happens after the authority is parsed.

**`BrandingFooter` keyed its links on `link.href`.** That is
admin-supplied data and nothing enforces href uniqueness, so two links to
the same target collided on their React key. Keyed on the index instead,
matching `FooterLinksField`.

The constraint-rewriting tests move to `test_cli_package_update_pins.py`
to stay under the 300-line cap, and the two helpers they shared with the
original file become `fake_pypi` / `write_pyproject` fixtures — this
directory has no `__init__.py`, so its conftest is where helpers are shared.
#290 landed a config-to-settings pass that overlaps this branch's #283 work,
so three of the five conflicts are about which env story wins:

- HostSettings takes main's side. #290 makes it deliberately env-readable
  ("Precedence is env → DB → defaults") and reads it pre-app in
  merge_host_settings, so DbBackedSettings would break the setup wizard.
  main's env_prefix="SM_" already closes the bare-name hole #283 reported,
  which also makes the Settings shim's source override unnecessary — reverted.
- users, keycloak and the other module settings keep DbBackedSettings: main
  left those on bare SettingsConfigDict(extra="ignore"), so the hole is still
  open there.
- background_tasks keeps env_prefix and gains main's SM_REDIS_URL fallback.
  With the prefix set, pydantic's env source answers the legacy
  SM_BG_TASKS_BROKER_URL / _RESULT_BACKEND before any default factory runs,
  so the deprecation warning moves to a validator that fires when the legacy
  var is the value's actual source.

SidebarLayout hit 301 lines once main's growth met the footerLinks prop;
hoisting the prop to a local keeps the call on one line and the file at 299.

uv run pytest 2379 passed, make test-js 139 passed, make lint clean,
make doctor 0 diagnostics.

Claude-Session: https://claude.ai/code/session_012k5QBVkMXJqJLVqtWbZJht
Three findings from reviewing the branch.

`_bump` moved a floor to PyPI's latest unconditionally, so when the
newest published release is *older* than the constraint already in the
file the pin was rewritten backwards. This workspace bumps its own
`simple_module_*==<next>` pins before publishing, which is exactly that
window — and now that exact pins are preserved in place rather than
loosened to `>=`, `==0.0.33` -> `==0.0.32` is a downgrade the next
`uv sync` installs. A floor now only ever rises.

`~=` was bumped to the full latest string, changing the segment count
and so the band width: `~=1.4` + 1.5.0 gave `~=1.5.0`, narrowing
`>=1.4,==1.*` to `>=1.5.0,==1.5.*`. The next run then reports
`1.6.0 excluded by ~=1.5.0` and refuses to move, freezing the
dependency after one update — the pin-style change this module exists
to avoid. It now moves within its own width, to `~=1.5`.

The production-boot localhost error told operators to set
`SM_BG_TASKS_BROKER_URL` / `SM_BG_TASKS_RESULT_BACKEND` while
`_warn_on_legacy_redis_vars`, added in the same commit, logs a
deprecation warning for those very names. It now leads with
`SM_REDIS_URL` and marks the per-field variables as deprecated
overrides, and drops the claim that an env var is the only thing that
can satisfy it — a DB override does too.

Claude-Session: https://claude.ai/code/session_012k5QBVkMXJqJLVqtWbZJht
@antosubash
antosubash marked this pull request as ready for review August 31, 2026 18:38
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-08-31T18:45:04.084508Z 3ac681f Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@antosubash
antosubash merged commit 3f64f3c into main Sep 1, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants