Skip to content

Commit c08e960

Browse files
committed
Proposals
1 parent 3ccbbe6 commit c08e960

16 files changed

+547
-28
lines changed

proposals/README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,28 @@
22

33
Lightweight implementation ideas for Plain packages.
44

5+
These are considered living documents. Some implementation details have been thought through, but they haven't been tried yet so here they sit! They are here because we haven't had the need or time yet to fully implement and release them.
6+
7+
These are primarily written with the help of Claude Code.
8+
59
## Format
610

7-
One file per package: `plain-auth.md`, `plain-models.md`, etc.
11+
One file per proposal: `<package>-<feature-name>.md`
812

913
```markdown
10-
# plain-<package>
11-
12-
## Feature Name
14+
# plain-<package>: Feature Name
1315

1416
- Implementation point
1517
- Another point
1618
- Why it matters (optional)
1719

18-
---
19-
20-
## Another Feature
20+
## Optional Section
2121

22-
- Another approach
22+
- Details if needed
2323
```
2424

2525
## Usage
2626

27-
- Add ideas when you have them
28-
- Delete sections when implemented or abandoned
27+
- Add new proposals as separate files
28+
- Delete files when implemented or abandoned
2929
- Git history keeps old ideas

proposals/plain-assets-webp.md

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# plain-assets: Automatic WebP Conversion
2+
3+
**Status:** Core feature, optional based on `cwebp` availability
4+
5+
## Overview
6+
7+
Automatically convert PNG/JPEG images to WebP during `plain build` to reduce file sizes by ~25-35%.
8+
9+
**Enabled when:** User has `cwebp` binary installed (from libwebp package)
10+
11+
**Disabled when:** `cwebp` not found (silent skip, no errors)
12+
13+
## Installation (Optional)
14+
15+
```bash
16+
# macOS
17+
brew install webp
18+
19+
# Ubuntu/Debian
20+
sudo apt install webp
21+
22+
# Alpine (Docker)
23+
apk add libwebp-tools
24+
```
25+
26+
## Key Design Decisions
27+
28+
- **Optional core feature**: Built into plain-assets, no separate package
29+
- If `cwebp` available → WebP variants generated
30+
- If not → Original behavior (no WebP)
31+
- Zero Python dependencies
32+
33+
- **Dual format strategy**: Generate both original and WebP versions
34+
- `hero.jpg``hero.abc123.jpg` + `hero.abc123.webp`
35+
- Both fingerprinted and added to manifest
36+
- No template changes required (backward compatible)
37+
38+
- **Header-based serving**: Use `Accept: image/webp` header
39+
- Browser requests `hero.jpg` with `Accept: image/webp`
40+
- Server transparently serves `hero.webp` if available
41+
- Cleaner than `<picture>` elements
42+
- Works with existing `{% asset %}` tags
43+
44+
- **Browser support**: 96%+ in 2025
45+
- Supported: Chrome 32+, Firefox 65+, Safari 14+, Edge 18+
46+
- Fallback via dual format for old browsers
47+
48+
## Configuration (Optional)
49+
50+
Probably don't need settings - sensible defaults work for everyone. But if needed:
51+
52+
```python
53+
# settings.py
54+
ASSETS_WEBP_QUALITY = 85 # Default: 85 (0-100)
55+
ASSETS_WEBP_ONLY_IF_SMALLER = True # Default: True (skip if WebP is larger)
56+
```
57+
58+
## Build Integration
59+
60+
```python
61+
# In plain/plain/assets/compile.py
62+
63+
def has_webp_support():
64+
"""Check if cwebp binary is available."""
65+
return shutil.which("cwebp") is not None
66+
67+
def compile_assets():
68+
webp_enabled = has_webp_support()
69+
70+
for asset in assets:
71+
# Copy original
72+
copy_file(asset)
73+
fingerprint_file(asset)
74+
75+
# Generate WebP if enabled and applicable
76+
if webp_enabled and is_image(asset):
77+
webp_path = convert_to_webp(asset, quality=85)
78+
if webp_path:
79+
fingerprint_file(webp_path)
80+
```
81+
82+
## Serving Logic
83+
84+
```python
85+
# In plain/plain/assets/views.py
86+
87+
def get(self, request, path):
88+
# Try to serve WebP variant if browser supports it
89+
if path.endswith(('.jpg', '.jpeg', '.png')):
90+
if 'image/webp' in request.headers.get('Accept', ''):
91+
webp_path = get_webp_variant(path)
92+
if webp_path and webp_path in manifest:
93+
path = webp_path
94+
95+
return serve_file(path)
96+
```
97+
98+
## User Experience
99+
100+
**Developer installs cwebp:**
101+
102+
```bash
103+
brew install webp
104+
plain build
105+
# → Images automatically get WebP variants
106+
# → Browsers automatically get smaller files
107+
```
108+
109+
**Developer doesn't install cwebp:**
110+
111+
```bash
112+
plain build
113+
# → Works exactly as before
114+
# → No WebP variants generated
115+
# → No errors or warnings
116+
```
117+
118+
**Optional: Check if WebP is working**
119+
120+
```bash
121+
plain build --verbose
122+
# → "WebP: Converted 12 images (saved 2.3MB)"
123+
# OR
124+
# → "WebP: cwebp not found (install with 'brew install webp')"
125+
```
126+
127+
## Questions to Resolve
128+
129+
1. **Verbosity**: Should we show a one-time hint if `cwebp` not found?
130+
2. **AVIF support**: Also support AVIF via `avifenc` (same pattern)?
131+
3. **Manifest format**: How to represent variants in fingerprint manifest?

proposals/plain-dev-companion.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# plain-dev: WebSocket Development Companion
2+
3+
- WebSocket server integrated into `plain dev` poncho processes
4+
- Browser script injected via middleware or template tag when `DEBUG=True`
5+
- Use [idiomorph](https://github.com/bigskysoftware/idiomorph) for DOM morphing instead of full page refreshes
6+
- Based on ideas from [repaint](https://github.com/dropseed/repaint) project
7+
8+
## Live Reloading
9+
10+
- Template changes → morph DOM with idiomorph (preserves form state, scroll position)
11+
- Python changes → full refresh after server restart
12+
- CSS changes → hot reload stylesheets without page refresh
13+
- Hook into existing `plain.internal.reloader` file watching
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# plain-elements: Development Toolbar Integration
2+
3+
- Visual element inspection during development (inspired by Phoenix LiveView)
4+
- Highlight elements on page with tooltip showing name and template path
5+
- Click element to open template file in editor
6+
- Toolbar button to toggle inspection mode
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# plain-email: Filebased Backend HTML/Text Viewing
2+
3+
- Enhance filebased backend to save `.txt` and `.html` files alongside `.log` files
4+
- Makes email content easily viewable without parsing raw RFC 822 format
5+
- Double-click `.html` to preview email in browser during development
6+
- Cat `.txt` or open in editor for quick reading of plain text content
7+
- Keep `.log` file for debugging (full headers, MIME structure, attachments)
8+
9+
## Current State
10+
11+
- Filebased backend saves emails as `.log` files with raw RFC 822/MIME format
12+
- Includes full headers, multipart boundaries, base64 encoding, etc.
13+
- Not human-readable or easily viewable in browser
14+
- Inherited from Django with unclear use case
15+
- Console backend is more useful for dev (immediate stdout output)
16+
17+
## Proposed Enhancement
18+
19+
Save three files per email with shared timestamp prefix:
20+
21+
```
22+
EMAIL_FILE_PATH/
23+
20250127-143045-123456.log # Raw RFC 822 format (existing)
24+
20250127-143045-123456.txt # Plain text body only
25+
20250127-143045-123456.html # HTML body only (if present)
26+
```
27+
28+
- Parse email message to extract text and HTML parts
29+
- Save each part to separate file for easy access
30+
- Handle multipart emails correctly (extract from appropriate MIME part)
31+
- Skip `.html` if email has no HTML part (text-only emails)
32+
- Backward compatible: still saves `.log` file
33+
34+
## Why It Matters
35+
36+
- **Development workflow**: See how emails actually render in browser
37+
- **Template debugging**: Verify HTML output without SMTP setup
38+
- **Quick testing**: No need to parse MIME format manually
39+
- **No complexity**: Still file-based, no database or server required
40+
- **Email client preview**: See exactly what recipients will see
41+
42+
## Implementation
43+
44+
Modify `plain-email/plain/email/backends/filebased.py`:
45+
46+
- Extract text body: `message.body` attribute
47+
- Extract HTML body: Look for `text/html` alternative in `EmailMultiAlternatives`
48+
- Use same timestamp prefix for all three files
49+
- Write files atomically to avoid partial writes
50+
- Handle encoding correctly (UTF-8 for text/html files)
51+
52+
## Future Enhancements
53+
54+
- Toolbar integration: Browse emails with metadata (subject, from, to, date)
55+
- Use `.eml` extension instead of `.log` (standard email file format)
56+
- Add `.json` metadata file for programmatic access
57+
- Setting to skip `.log` file if not needed (save space)
58+
- Index file (`emails.html`) linking to all captured emails
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# plain-email: Modern Python Email APIs
2+
3+
- Replace legacy `email.mime.*` classes with modern `email.message.EmailMessage` (Python 3.6+)
4+
- Use `email.policy.EmailPolicy` for consistent encoding, line length, and header handling
5+
- Modernize attachment handling with `EmailMessage.add_attachment()` instead of manual MIME construction
6+
- Remove manual `Charset` manipulation - let policy handle it automatically
7+
- Replace `email.header.Header` usage with policy-based approach
8+
- Maintain all existing security features (header injection prevention)
9+
- Keep public API unchanged - all changes are internal implementation
10+
11+
## Benefits
12+
13+
- Cleaner, more Pythonic code
14+
- Better Unicode handling out of the box
15+
- Simpler attachment API
16+
- Future-proof (actively maintained API)
17+
- Potentially 100-200 fewer lines of code
18+
19+
## Risks
20+
21+
- Policy may handle edge cases differently than current implementation
22+
- Must thoroughly test all email features (attachments, alternatives, templates, Unicode)
23+
- Must ensure header injection prevention is preserved
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# plain: File-Based Secrets Loading
2+
3+
- Extend settings loading to support Docker/Kubernetes file-based secrets pattern
4+
- Check for `PLAIN_<VAR>_FILE` environment variable pointing to file path
5+
- Automatically check `/run/secrets/<lowercase_var>` as fallback (Docker convention)
6+
- Maintain backward compatibility - env vars take precedence over files
7+
- Strip whitespace/newlines from file contents
8+
- Use existing type parsing (`_parse_env_value`) for file contents
9+
- No new dependencies required (Python stdlib only)
10+
11+
## Loading Priority
12+
13+
1. `PLAIN_<VAR>` environment variable (highest - existing behavior)
14+
2. `PLAIN_<VAR>_FILE` environment variable → read file at path
15+
3. `/run/secrets/<lowercase_var>` file if exists (Docker/K8s default)
16+
4. Default value from settings (lowest - existing behavior)
17+
18+
## Benefits
19+
20+
- Docker/Kubernetes native - works with container orchestration secrets
21+
- PaaS compatible - Railway, Render, Fly.io continue using env vars
22+
- Secure - files can have restrictive permissions (0400)
23+
- Industry standard - follows MySQL/Postgres official image patterns
24+
- Foundation for future secrets manager integration (optional)
25+
26+
## Implementation
27+
28+
Modify `plain/plain/runtime/user_settings.py`:
29+
30+
- Add `_read_secret_file(file_path, setting_name)` helper method
31+
- Extend `_load_env_settings()` to check file sources after env vars
32+
- Handle errors gracefully (missing files, permissions, etc.)
33+
- Add tests for file loading and priority order
34+
- Update `plain/plain/runtime/README.md` with examples
35+
36+
## Docker Compose Example
37+
38+
```yaml
39+
services:
40+
app:
41+
secrets:
42+
- secret_key
43+
environment:
44+
# Optional: explicit file path
45+
PLAIN_DATABASE_PASSWORD_FILE: /run/secrets/db_password
46+
47+
secrets:
48+
secret_key:
49+
file: ./secrets/secret_key.txt
50+
db_password:
51+
file: ./secrets/db_password.txt
52+
```
53+
54+
## Edge Cases
55+
56+
- Missing file → skip silently, try next source
57+
- No read permission → log warning, continue
58+
- Empty file → valid (empty string)
59+
- Symlinks → follow them (Docker secrets are symlinks)
60+
- Type parsing errors → fail with clear message
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# plain-flags: Toolbar Panel
2+
3+
- Add dev toolbar panel showing flags evaluated on current request
4+
- Display flag name, key, value, and result reason (cached/disabled/targeting_match)
5+
- Session-based temporary overrides (toggles/inputs based on value type)
6+
- Badge on toolbar button showing flag count and override indicator
7+
- No context switching to admin for rapid flag iteration
8+
- Link to admin interface for permanent changes
Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
1-
# plain-models
1+
# plain-models: Custom Base QuerySet
22

3-
## Custom Base QuerySet
4-
5-
- Add `default_queryset()` classmethod to QuerySet that subclasses can override?
3+
- Add `default_queryset()` classmethod to QuerySet that subclasses can override
64
- Replaces functionality lost in Manager/QuerySet merge (commit `bbaee93839`)
75
- Common use cases: soft deletes, multi-tenancy, published content, archived records
86
- More flexible than filters-only (can use `.select_related()`, `.only()`, etc.)
97
- Must preserve `base_queryset` for framework operations (migrations, cascades)
108
- Need some way to bypass defaults when needed (naming/approach TBD)
119
- Usage example:
12-
```python
13-
class SoftDeleteQuerySet(QuerySet["Article"]):
14-
@classmethod
15-
def default_queryset(cls, model):
16-
return super().default_queryset(model).filter(deleted_at__isnull=True)
1710

18-
def with_deleted(self):
19-
# How to get unfiltered queryset? TBD
20-
pass
11+
```python
12+
class SoftDeleteQuerySet(QuerySet["Article"]):
13+
@classmethod
14+
def default_queryset(cls, model):
15+
return super().default_queryset(model).filter(deleted_at__isnull=True)
16+
17+
def with_deleted(self):
18+
# How to get unfiltered queryset? TBD
19+
pass
20+
21+
class Article(Model):
22+
query = SoftDeleteQuerySet()
23+
```
2124

22-
class Article(Model):
23-
query = SoftDeleteQuerySet()
24-
```
2525
- Implementation details TBD (how to integrate with `from_model()`, avoid duplication, bypass mechanism, etc.)

0 commit comments

Comments
 (0)