# 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.