Files
ctp_blogr/AGENTS.md
T
2026-09-30 17:12:54 +02:00

9.6 KiB

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

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.