after running opencode /init
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user