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.pyonly inserts theadminuser; 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.pydeleted theportfolioroute, buttemplates/layout.jinja2:50still callsrequest.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:26hardcodesreturn_url = "/portfolio"forblog_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.
loginis 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_debugtoolbarsquashes exceptions into HTTP 200 indevelopment.ini, so status codes there are unreliable too. Useproduction.iniwhen you need trustworthy status codes.production.inisetsurl_scheme = httpswhile waitress serves plain HTTP. Every generated URL and redirect becomeshttps://localhost:9280/, which breaks a local plain-HTTP run (confirmed: login redirects tohttps://localhost:9280/). Usedevelopment.inifor local work.- Post id 95 is load-bearing.
views/default.py::homedoesBlogRecordService.by_id(request, 95)and reads.title/.bodyunconditionally, so/raises if that row is gone or renumbered.home.jinja2also linksblog_edit id=95. Row 95 currently holds aprivéChappatte cartoon whosebodyis used as an image URL. (get_last_createdis named for 10 but limits to 15.) - Post bodies assume the literal substring
static/.views/blog.pyrewritesstatic/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 inservices/blog_record.py(by_criteria,by_tag,get_last_createdall restrict topubliéwhenrequest.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 intorequest.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 enforcestitleandusers.nameas 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.idinviews/blog.py,del form.nameinviews/default.py::user_edit. Preserve that. - Every flash message is French and rendered through
layout.jinja2's queue loop. ctp_blogr/tests.pyhas only two tests and both assert the same tautology (aproposreturnspage_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.