after running opencode /init

This commit is contained in:
2026-09-30 17:12:54 +02:00
parent f4caf01361
commit 66c2ab944a
7 changed files with 159 additions and 29 deletions
+146
View File
@@ -0,0 +1,146 @@
# AGENTS.md
Personal blog app: legacy **Pyramid 2.0 WSGI** + **SQLAlchemy 1.4** + **SQLite**, server-rendered
**Jinja2**, Bootstrap 3.4.1 from CDN. UI, templates, routes and DB status values are all in **French** —
match that when adding user-facing text or filtering by post status.
## Commands
```bash
pip install -e ".[testing]" # deps come from setup.py install_requires; NO requirements.txt/lockfile
initialize_ctp_blogr_db development.ini # inserts the 'admin' user (see warning below)
pserve development.ini # waitress on http://localhost:9280, debugtoolbar on, template reload on
pserve production.ini # binds *:9280, debugtoolbar off, log level WARN
pshell development.ini # ctp_blogr/pshell.py already calls request.tm.begin() for you
pytest # pytest.ini: testpaths=ctp_blogr, python_files=test*.py
pytest ctp_blogr/tests.py::TestMyViewSuccessCondition::test_passing_view # single test
alembic -c development.ini upgrade head # there is NO alembic.ini — the [alembic] section lives in the .ini files
```
**`pytest` is not currently installed in the venv** (`python -m pytest` → "No module named pytest"). Run
`pip install -e ".[testing]"` first, or there is no automated check at all.
**No linter, formatter, typechecker, pre-commit, Makefile, or CI config exists.** Do not invent a
`lint`/`typecheck` step or run `make`. `pytest` is the only automated check.
## Stack: trust the installed env, not the metadata
Actually installed and in use: Python 3.9.6, Pyramid 2.0.2, SQLAlchemy 1.4.54, WTForms 3.2.1,
WebHelpers2 2.1, Markdown 3.9, pyramid_jinja2 2.10.1, passlib 1.7.4, zope.sqlalchemy 2.0.
`setup.py` pins only `SQLAlchemy==1.4.54` and `zope.sqlalchemy==2.0`; everything else floats.
`pyramid_layout` is **no longer** in `install_requires` (removed in the working tree) and is not imported
anywhere, though it is still present in the shared venv for sibling projects.
The package was renamed twice (`pyramid_blogr` → `cao_blogr` → `ctp_blogr`), so stale names survive in
metadata: `.coveragerc` says `source = pyramid_blogr`, `rtd.txt` says `pylons-sphinx-themes`,
`CHANGES.txt` says 0.1 while `setup.py` says 1.0. (`README.md` and `MANIFEST.in` were corrected in the
working tree — don't "fix" them back.)
## Architecture
```
ctp_blogr/__init__.py main() — Configurator, root_factory=security.RootFactory,
config.include('pyramid_jinja2') then '.models' then '.routes' then config.scan()
ctp_blogr/models/ SQLAlchemy models + session/engine factories. includeme() hooks
pyramid_tm (explicit_manager) and pyramid_retry, and registers
`request.dbsession` (reified, transaction-managed)
ctp_blogr/services/ all DB queries live here (BlogRecordService, UserService)
ctp_blogr/views/ scanned by config.scan(); default.py, blog.py, notfound.py
ctp_blogr/forms.py WTForms; still holds dead portfolio forms (HistoForm, AllocationForm, ActifForm…)
ctp_blogr/templates/ Jinja2; static/blog/*.png is image content written into post bodies
```
Write path: view → service → `request.dbsession`. **Never call `session.commit()`** — `pyramid_tm`
commits or aborts at the end of the request. Pass `request` into services; they read `request.dbsession`
and `request.authenticated_userid` themselves.
Renderer paths are relative to the *calling package*, so `views/*.py` use `'../templates/x.jinja2'`.
One outlier remains: `views/default.py::user_edit` uses the asset spec `'ctp_blogr:templates/user_edit.jinja2'`.
Both styles work — match the file you're editing.
## Schema & migrations — the biggest trap
`alembic upgrade head` **does not create the blog schema.** Verified on an empty DB: the only migration
(`ctp_blogr/alembic/versions/20230129_b8f8216d72c7.py`) creates a single portfolio-era `allocation` table.
The real `users` / `entries` / `tags` tables exist *only* inside the committed `ctp_blogr.sqlite`, created ad
hoc. (`actifs`, `histo` and `allocation` are also in that file, hand-made; `alembic_version` is pinned at
`b8f8216d72c7`.)
- **Never delete or recreate `ctp_blogr.sqlite`** — it is the only source of the schema and of all content.
- To add a table/column: add the model, then either write a new hand-authored migration or mirror
`ctp_blogr/tests.py::BaseTest.init_database` (`Base.metadata.create_all(engine)`).
- `scripts/initialize_db.py` only inserts the `admin` user; it does **not** create tables (its
"run alembic" hint is misleading).
**`ctp_blogr.sqlite` is git-tracked even though `.gitignore` lists `*.sqlite`.** Any local run dirties
`git status`. Don't stage it in an unrelated commit.
## Auth model
Username-based, no roles column. `services/user.py::groupfinder` grants `group:administrators` to the
literal username `admin` and `[]` to everyone else. `security.py::RootFactory` allows `view` to
`Authenticated` and `manage` to `group:administrators`; `DENY_ALL` catches the rest.
Permissions: `users` → `manage` (admin only); `blog_edit`, `user_edit`, `tags`, `tag_edit` → `view`.
Passwords: `passlib.apps.custom_app_context`. `verify_password` transparently upgrades a matching
cleartext value to a hash, so `User.password` can legitimately contain a plaintext legacy value.
`set_password()` takes **bytes** — callers must `.encode('utf8')`.
`initialize_ctp_blogr_db` writes the admin password as **plaintext** (`scripts/initialize_db.py`), relying on
that upgrade path. It is not idempotent — a second run violates the `users.name` unique constraint.
## Known-broken / surprising behavior — don't mistake these for your regression
Verified by running `pserve` in both dev and prod on 2026-09-30:
- **Every logged-in page returns HTTP 500.** Uncommitted `routes.py` deleted the `portfolio` route, but
`templates/layout.jinja2:50` still calls `request.route_url('portfolio')` in the logged-in dropdown →
`KeyError: 'No such route named portfolio'` during render. Anonymous pages are fine. This is the
regression in progress in the working tree — fix the template or restore the route.
- **Post id 2 links to a dead `/portfolio`.** `views/blog.py:26` hardcodes `return_url = "/portfolio"` for
`blog_id == '2'`; that URL is now a 404.
- **`/portal` → HTTP 404.** Route and view were removed in the working tree (no template was ever
committed either).
- **Protected pages return HTTP 200 with the login page, not 403.** `login` is double-registered as
`@forbidden_view_config`, so the 403 status is lost when the view returns a plain dict. Do not use HTTP
status codes to test authorization — check the body, or log in.
- **`pyramid_debugtoolbar` squashes exceptions into HTTP 200** in `development.ini`, so status codes there
are unreliable too. Use `production.ini` when you need trustworthy status codes.
- **`production.ini` sets `url_scheme = https` while waitress serves plain HTTP.** Every generated URL and
redirect becomes `https://localhost:9280/`, which breaks a local plain-HTTP run (confirmed: login redirects
to `https://localhost:9280/`). Use `development.ini` for local work.
- **Post id 95 is load-bearing.** `views/default.py::home` does `BlogRecordService.by_id(request, 95)` and
reads `.title` / `.body` unconditionally, so `/` raises if that row is gone or renumbered.
`home.jinja2` also links `blog_edit id=95`. Row 95 currently holds a `privé` Chappatte cartoon whose
`body` is used as an image URL. (`get_last_created` is named for 10 but limits to 15.)
- **Post bodies assume the literal substring `static/`.** `views/blog.py` rewrites `static/` to an absolute
URL before running Markdown, so image references in existing post bodies depend on it.
## Conventions
- Post status values are French strings: `brouillon`, `privé`, `publié`. Anonymity filtering happens in
`services/blog_record.py` (`by_criteria`, `by_tag`, `get_last_created` all restrict to `publié` when
`request.authenticated_userid is None`) — the list on `/` is the only place this is *not* applied.
- `blog_bytag`'s `{retour}` matchdict is a **route name**, fed straight into `request.route_url(retour)`
(`views/blog.py:106`). Templates pass `'home'` / `'blog_search'`, never a path.
- Post titles must not contain `/`: create/edit replaces it with `.` because the title feeds the URL slug
(`webhelpers2.text.urlify`). DB also enforces `title` and `users.name` as unique.
- Views use `id='0'` as a magic sentinel meaning "create new" (`blog_edit`, `user_edit`, `tag_edit`).
- Forms are plain WTForms bound directly to model instances with `form.populate_obj(entry)`. Before that,
handlers delete the bound primary key to avoid clobbering it — `del form.id` in `views/blog.py`,
`del form.name` in `views/default.py::user_edit`. Preserve that.
- Every flash message is French and rendered through `layout.jinja2`'s queue loop.
- `ctp_blogr/tests.py` has only two tests and both assert the same tautology (`apropos` returns
`page_title == 'A propos'`). Treat the suite as a smoke test, not coverage.
## Secrets & repo hygiene
Hardcoded secrets already committed — do not copy this pattern and do not add new ones:
`ctp_blogr/__init__.py` holds the session-factory secret (`SignedCookieSessionFactory`) and the
`AuthTktAuthenticationPolicy` secret inline; `scripts/initialize_db.py` hardcodes the admin password.
The root-level `*.py` files (`gmail_*_purge.py`, `imap-*.py`, `smtp-*.py`, `email_avnes_purge.py`) are
standalone one-off IMAP/SMTP cleanup utilities with **plaintext mail credentials**. They do not import the
app, are not part of the Pyramid package, and are unrelated to any blog work — leave them alone.