Files
ctp_blogr/AGENTS.md
T
2026-10-06 16:23:08 +02:00

15 KiB

AGENTS.md

Single-package Pyramid 2.0 blog app (ctp_blogr). Python 3.14, SQLite, Jinja2, WTForms, SQLAlchemy 2.1, pyramid_tm transactions. Sign-in is Google OAuth only: Authlib protocol/crypto primitives plus joserfc and urllib.request for HTTP. There are no passwords anywhere — no password column, no password form fields, no local credential path. UI strings, comments, and flash messages are French; match that when editing views/templates.

Environment

The venv is the parent directory (/Users/phuoc/pyramid2), not inside the repo. ctp_blogr is installed there in editable mode, so run commands from the repo root with the venv's python (already on PATH as python). The venv is shared and also carries a stale editable install of a wiki_test project whose source is gone — don't pip install into it casually, and don't assume the venv contents match this repo's setup.py.

No linter, formatter, type-checker, pre-commit, CI workflow, or task runner exists. Don't invent one; don't claim you ran one.

Commands

ENV_GOOGLE_CLIENT_ID=... ENV_GOOGLE_CLIENT_SECRET=... pserve development.ini    # waitress on localhost:9280 + debugtoolbar
ENV_GOOGLE_CLIENT_ID=... ENV_GOOGLE_CLIENT_SECRET=... pserve production.ini     # binds *:9280, url_scheme https
ENV_GOOGLE_CLIENT_ID=... ENV_GOOGLE_CLIENT_SECRET=... pshell development.ini    # preloaded: request, tm, dbsession, models
python -m pytest                                        # 34 tests, all offline (2 in tests.py + 32 in test_oauth.py)
python -m pytest ctp_blogr/tests.py::TestMyViewSuccessCondition   # single test/class
ENV_GOOGLE_CLIENT_ID=... ENV_GOOGLE_CLIENT_SECRET=... python -c "from pyramid.paster import get_app; get_app('development.ini')"   # boot check
  • pserve, pshell, and the boot check all refuse to start without both env vars: __init__.py raises ConfigurationError with a French message naming the missing ones. PasteDeploy %(ENV_...)s interpolation does not work (plaster_pastedeploy 1.0.1 ships no os.environ support), so the app reads os.environ itself; an .ini value, if set, wins over the environment.
  • pytest.ini sets testpaths = ctp_blogr and python_files = test*.py, so a new test file must be named test*.py inside the package to be collected.
  • coverage is broken: .coveragerc still says source = pyramid_blogr (old package name). coverage run -m pytest collects nothing. Fix the name before trusting any coverage number; don't report "no coverage" as a finding.
  • initialize_ctp_blogr_db is declared in setup.py but ctp_blogr/scripts/initialize_db.py does not exist (only scripts/__init__.py) — the console script fails with ModuleNotFoundError. There is no working DB-init command.

Architecture / wiring

  • ctp_blogr/__init__.py::main is the PasteDeploy app factory. Auth is Pyramid's SessionAuthenticationPolicy + ACLAuthorizationPolicy with root_factory='ctp_blogr.security.RootFactory'. The whole ACL is 3 lines in security.py: view → Authenticated, manage → group:administrators, then DENY_ALL.
  • The session factory is explicit: 85-char secret, timeout=36000 (10 h; the SignedCookieSessionFactory default is 1200 s / 20 min), httponly=True always, secure=True only when session.cookie_secure = true (set in production.ini).
  • Identity is the email, stored as request.authenticated_userid (set via remember() in the OAuth callback).
  • Admin is not a DB flag. services/user.py::make_groupfinder(admin_emails) returns the auth callback built from the auth.admin_emails setting in the .ini files. It must not query the DB: pyramid_tm resolves request.authenticated_userid inside manager.begin(), so a DB query there would run per request inside the transaction and any DB error would 500 every route including /login. Falsy userid → None, admin email → ['group:administrators'], anything else → [] (logged in, no manage rights). Callers test 'group:administrators' in request.effective_principals (views and templates). /users and /user_edit require manage; blog_edit, tag_edit, and /tags require only view.
  • services/google_oauth.py::GoogleOAuth is registered as an attribute, config.registry.google_oauth (read as request.registry.google_oauth). Pyramid's Registry subclasses dict but has no __getattr__, so a registry['google_oauth'] dict-key assignment would be invisible to the views. It resolves authorization_endpoint / token_endpoint / jwks_uri / issuer from the Google discovery document, cached, plus a cached JWKS (CACHE_TTL = 3600, counted by jwks_fetches). Authlib has no Pyramid integration, so the app owns state / PKCE / nonce itself; authorize_redirect / save_authorize_data are not used. Authlib is used only for protocol primitives (generate_token, create_s256_code_challenge) and the 3 HTTP calls go through urllib.request — do not add requests (authlib.integrations.requests_client hard-imports it). id_token is verified with joserfc, not authlib.jose. Scope is openid email with no hd parameter (the second account is a personal Gmail address, so an hd lock would reject it).
  • Routes added: login_google = /login/google, login_google_callback = /login/google/callback.
  • The callback pops state / nonce / code_verifier from the session before the token exchange, and sets request.environ['retry.attempts'] = attempt + 1 to declare itself the last attempt. Both are required: a pyramid_retry replay restarts from the original request cookie (which still carries oauth_state), which would re-send an already-consumed authorization code and fail invalid_grant.
  • Sign-in is gated on the users table (allowlist). No row is ever auto-created. Unknown email sets request.response.status_int = 403 explicitly and renders templates/denied.jinja2, without raising HTTPForbidden. New template denied.jinja2 renders the refusal in French.
  • The callback validates three ID-token claims before touching the allowlist, and each one is pinned by a test that fails if the guard is removed:
    • a missing code param is refused before any network call (fetch_calls == 0);
    • email_verified must be exactly True. The identity is the email, so accepting an unverified address would let an unverified Google account impersonate an allowlisted one. Google returns this claim; don't "simplify" the check away;
    • sub must be non-empty. Without it the matched row would get google_sub = '', which both collides with the UNIQUE constraint and leaves the record rebindable by any other account presenting the same address.
  • views/default.py::denied is registered with @view_config(context=HTTPForbidden, ...) and sets a real 403. Unauthorized requests now return HTTP 403 with the denied page — assert 403 in tests, and don't assume 200 means "the page worked" when smoke-testing protected routes (/users, /tags, /blog_edit/{id}, /tag_edit/{id}, /user_edit/{name}).
  • user_edit is permission='manage' (any logged-in user could previously open or create fiches). The create-user branch (name='0') is gone; unknown names flash "Utilisateur non trouvé" and redirect. UserEditForm (renamed from UserCreateForm) has only id + name, and the view deletes form.name (not form.id) before populate_obj, since the email is the identity.
  • Views bind to routes via @view_config(route_name=...); routes are declared centrally in routes.py. Adding a URL means editing both.
  • models/__init__.py includes pyramid_tm + pyramid_retry and reifies request.dbsession. settings['tm.manager_hook'] = 'pyramid_tm.explicit_manager' is set there — removing it breaks commit/abort behaviour.
  • Write path: view → request.dbsession.add(...) → implicit commit by pyramid_tm. Do not call commit() yourself.
  • Query layer lives in services/ (BlogRecordService, UserService, plus the Google collaborator) — all classmethods taking request. Keep DB access out of views.
  • Read filtering is duplicated per-method: anonymous users only ever see status == 'publié' (by_criteria, by_tag, get_last_created). by_id does not filter — any post is readable by direct URL. Preserve that asymmetry or you change access behaviour.
  • Post bodies are Markdown; views/blog.py runs markdown.markdown(..., extensions=['footnotes']) and rewrites static/ → <app_url>/static/ so relative image paths resolve.
  • Renderer strings are inconsistent on purpose-ish: most views use '../templates/x.jinja2' (relative to the package), but default.py::user_edit uses the asset spec 'ctp_blogr:templates/user_edit.jinja2'. Both work; don't "normalize" one without checking the jinja2 search path.
  • login.jinja2 has a single "Se connecter avec Google" control and no password markup; layout.jinja2 shows the Utilisateurs nav item only for admins; the password-change nav item and the "Nouvel utilisateur" button are gone. users.jinja2 is NULL-safe for last_logged (renders "jamais"); user_edit.jinja2 shows the email readonly.
  • forms.py contains dead forms from another app (HistoForm, AllocationForm, ActifForm, Actif2Form) matching orphaned DB tables histo, allocation, actifs. Ignore them.

Database

  • ctp_blogr.sqlite is committed and is the real dev database (~99 posts, 8 tags; users are now phuoc@caotek.fr (admin, via the .ini setting) and ctphuoc@gmail.com (authenticated, no manage)). Both development.ini and production.ini point at it via %(here)s. .gitignore lists *.sqlite, but the file is already tracked, so the ignore rule does nothing — every write view's pyramid_tm commit is a real diff to review.
  • users: password dropped, google_sub added (nullable, UNIQUE), name still NOT NULL
    • UNIQUE and now stores the email. entries.creator / entries.editor are Unicode(255).
  • The DB contains an alembic_version table (rev b8f8216d72c7) but there is no alembic.ini and no migrations/ directory in this repo. Alembic is installed in the venv, yet nothing here is wired to it. The OAuth schema changes were hand-applied SQL, so the schema cannot be regenerated.
  • The DB file is the source of truth for schema. Changing a model means: apply the equivalent ALTER TABLE to ctp_blogr.sqlite by hand, and tell the user migrations are missing rather than pretending Base.metadata.create_all() covers them. create_all() is only used by the in-memory test fixtures (tests.py::BaseTest.init_database, test_oauth.py::demarrer).
  • Tags is defined in models/blog_record.py but is not imported in models/__init__.py (only User and BlogRecord are, before configure_mappers()). It still works only because importing blog_record defines the class as a side effect. Import it explicitly if you touch mapper config.

Known-broken (verified, do not re-diagnose)

  • views/default.py:18 — home hardcodes BlogRecordService.by_id(request, 95) and then reads entry.title / entry.body with no None check. The home page raises AttributeError on any database where id 95 doesn't exist (e.g. a freshly created one). home.jinja2:18 hardcodes the same id for the "select another drawing" link.
  • services/blog_record.py uses query.get(_id) — legacy SQLAlchemy 1.x API. Works on 2.1.3 but emits LegacyAPIWarning; prefer session.get(Model, id) when touching that line.
  • routes.py:5 — '/blog/{id:\d+}/{slug}' is a non-raw string, so Python 3.12+ emits SyntaxWarning: "\d" is an invalid escape sequence. Prefix with r if you edit it.
  • models/meta.py uses sqlalchemy.ext.declarative.declarative_base (MovedIn20Warning).
  • Draft leak in by_id (no status filter) and the LIKE search leak in by_criteria (anonymous search matches drafts/privates, only the display is filtered elsewhere) are pre-existing and deliberately not fixed by the OAuth work.
  • Any logged-in user (including the non-admin account) can edit and delete ANY post, because security.py grants view to every authenticated user and blog_edit / tag_edit only require view. Pre-existing, deliberately unchanged (README documents an "admin ou redacteur" role). Pinned by test_DEFAUT_connu_un_utilisateur_non_administrateur_peut_modifier_un_billet.
  • UserService.delete has no FK cascade: deleting a user leaves their email stranded in entries.creator / entries.editor.
  • Pre-existing DB defect, not caused by the OAuth work: PRAGMA foreign_key_check aborts with "foreign key mismatch" because fk_allocation_classe_actifs references actifs.classe, which is neither UNIQUE nor a PK.
  • No CSRF protection on the pre-existing POST views, and logout is a state-mutating GET. Accepted risk, not fixed. Pyramid 2.1's cookie session exposes new_csrf_token() / get_csrf_token() if someone wants to add it.
  • Identity lives entirely in the signed client-side cookie (no server-side session backend). Payload is ~250 bytes against Pyramid's hard 4064-byte limit, so the only growth vector is unbounded flash accumulation.

Testing

ctp_blogr/tests.py still has only its two original duplicate tests (both assert the same success case; test_failing_view is a copy-paste of test_passing_view). New ctp_blogr/test_oauth.py adds 32 tests on a real WSGI app over sqlite:///:memory: with the Google collaborator faked and sockets blocked by an autouse fixture — 34 passing in total, all fully offline. Tests named DEFAUT_* pin known defects on purpose (observed behaviour, not desired). BaseTest wraps its session in transaction.manager, aborting in teardown. WebTest is installed; test_oauth.py shows the pattern for new view tests.

Conventions

  • 4-space indent, no type hints, no docstrings on views. Comments are frequently French and slightly garbled English/French mixes (# interdire le car '/' dans le titre) — leave them.
  • @view_config renderer paths and view return dicts follow the existing pattern; views return plain dicts consumed by the matching Jinja2 template.
  • Form submission is detected by string key presence: if 'form.submitted' in request.params, plus 'form.update_created' and 'form.deleted' for the extra buttons. Buttons must set name="form.submitted" etc.
  • del form.id (blog/tag forms) / del form.name (user_edit) before populate_obj is a deliberate guard against overwriting the primary key / identity. Keep it.
  • /blog_bytag/{tag}/{retour} — retour is a route name (request.route_url(retour)), not a URL. Callers pass e.g. retour='home'.
  • Currently on branch feat/google-oauth, uncommitted, nothing pushed (master tracks origin/master at the same commit, so branch choice is still not settled). A verified pre-migration DB backup exists outside the tree at /var/folders/cm/x5cx1bfd749gxd1x_zt6bvp40000gn/T/opencode/ctp_blogr-backup-20261005-202240.sqlite (PRAGMA integrity_check returns ok); restore with cp over ctp_blogr.sqlite. Ask before creating branches, committing, or pushing to the self-hosted https://git.caotek.fr/phuoc/ctp_blogr.git. Commit only when asked.