Unify static asset cache-busting with the auto-bumped app version
DevPlace CI / test (push) Failing after 27m37s
DevPlace CI / test (push) Failing after 27m37s
config.STATIC_VERSION becomes f"{APP_VERSION}-{BOOT_ID}": APP_VERSION is
read live from pyproject.toml's version (auto-bumped on every commit by
.githooks/pre-commit), BOOT_ID is the same per-process launch marker as
before (DEVPLACE_STATIC_VERSION env or a wall-clock fallback). The static
asset URL /static/v<version>/... now names the actual commit's version
alongside the boot marker, instead of a bare timestamp.
Fixes nginx/nginx.conf.template's versioned-mount location regex, which
matched digits only (^/static/v\d+/) and would have silently dropped the
immutable, max-age=31536000 cache header for every asset in production
once the version segment carried a dot or hyphen. Verified live against a
running instance that the header still applies to the new URL shape.
Updates README, devplacepy/static/js/CLAUDE.md, and the
/docs/static-caching.html page to describe the new APP_VERSION/BOOT_ID
composition.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TwLhnueWrsK15wrieXE5m7
This commit is contained in:
@@ -267,7 +267,7 @@ The log is **administrator-only**. `/admin/audit-log` is a paginated, filterable
|
|||||||
| `DEVPLACE_INTERNAL_BASE_URL` | `http://localhost:10500` | Base URL the platform's own services dial for the AI gateway |
|
| `DEVPLACE_INTERNAL_BASE_URL` | `http://localhost:10500` | Base URL the platform's own services dial for the AI gateway |
|
||||||
| `DEVPLACE_XMLRPC_PORT` | `10550` | Loopback port the forking XML-RPC bridge binds; the app and nginx reverse-proxy `/xmlrpc` to it |
|
| `DEVPLACE_XMLRPC_PORT` | `10550` | Loopback port the forking XML-RPC bridge binds; the app and nginx reverse-proxy `/xmlrpc` to it |
|
||||||
| `DEVPLACE_XMLRPC_BIND` | `127.0.0.1` | Bind address for the XML-RPC bridge (loopback; the app and nginx are the intended front doors) |
|
| `DEVPLACE_XMLRPC_BIND` | `127.0.0.1` | Bind address for the XML-RPC bridge (loopback; the app and nginx are the intended front doors) |
|
||||||
| `DEVPLACE_STATIC_VERSION` | server boot unix timestamp | Cache-busting version stamped into every static asset URL (`/static/v<version>/...`). Set it at launch so multiple workers agree (the `prod` target and Docker image do this); leave unset in dev to refresh on each reload. See [Static asset caching](#static-asset-caching) |
|
| `DEVPLACE_STATIC_VERSION` | server boot unix timestamp | The boot-id half of the cache-busting version stamped into every static asset URL (`/static/v<app-version>-<boot-id>/...`, e.g. `/static/v1.0.1-1718040000/...`). Set it at launch so multiple workers agree (the `prod` target and Docker image do this); leave unset in dev to refresh on each reload. See [Static asset caching](#static-asset-caching) |
|
||||||
| `DEEPSEEK_API_KEY` / `OPENROUTER_API_KEY` | unset | Upstream provider keys; migrated into the gateway settings on first boot |
|
| `DEEPSEEK_API_KEY` / `OPENROUTER_API_KEY` | unset | Upstream provider keys; migrated into the gateway settings on first boot |
|
||||||
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) |
|
| `DEVPLACE_PRESENCE_TIMEOUT_SECONDS` | `60` | Online-presence window: a user counts as online for this many seconds after their last activity. `last_seen` is refreshed by a throttled in-place update at most once per half this interval per worker (no per-load inserts, no data growth) |
|
||||||
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) |
|
| `DEVPLACE_PRESENCE_ONLINE_LIMIT` | `30` | Maximum avatars shown in the feed's live "Online now" panel (ordered alphabetically by username) |
|
||||||
@@ -1202,7 +1202,7 @@ reverse_proxy localhost:10500 {
|
|||||||
|
|
||||||
### Static asset caching
|
### Static asset caching
|
||||||
|
|
||||||
Static assets (CSS, JS, vendored libraries) are served with a **one-year immutable cache** for the best Lighthouse "efficient cache policy" score, while deploys still take effect immediately. Every app-owned static URL carries a boot-time version path segment, `/static/v<timestamp>/...`, where `<timestamp>` is the unix time the server process started (`config.STATIC_VERSION`). A restart changes the segment, so every asset URL changes and returning browsers refetch on their next page load - no cache purge, no hashing build step.
|
Static assets (CSS, JS, vendored libraries) are served with a **one-year immutable cache** for the best Lighthouse "efficient cache policy" score, while deploys still take effect immediately. Every app-owned static URL carries a version path segment, `/static/v<app-version>-<boot-id>/...` (`config.STATIC_VERSION`) - `<app-version>` is `pyproject.toml`'s `version` (auto-bumped on every commit, see "Version bumping" in `CLAUDE.md`) and `<boot-id>` is the unix time the server process started. A restart changes the segment, so every asset URL changes and returning browsers refetch on their next page load - no cache purge, no hashing build step.
|
||||||
|
|
||||||
The version sits in the **path**, not a query string, because the frontend is unbundled ES6 modules wired with relative imports: a path segment is inherited automatically by every transitively imported module and relative CSS `url()`, so the whole graph busts on deploy. Templates emit URLs through the `static_url` Jinja global and runtime JavaScript through the `assetUrl` helper (`static/js/assetVersion.js`, reading `<meta name="asset-version">`). User uploads under `/static/uploads/` and the `service-worker.js` route are excluded. Set `DEVPLACE_STATIC_VERSION` at launch so multiple workers share one value (the `prod` target and Docker image do this). Full detail: `/docs/static-caching.html`.
|
The version sits in the **path**, not a query string, because the frontend is unbundled ES6 modules wired with relative imports: a path segment is inherited automatically by every transitively imported module and relative CSS `url()`, so the whole graph busts on deploy. Templates emit URLs through the `static_url` Jinja global and runtime JavaScript through the `assetUrl` helper (`static/js/assetVersion.js`, reading `<meta name="asset-version">`). User uploads under `/static/uploads/` and the `service-worker.js` route are excluded. Set `DEVPLACE_STATIC_VERSION` at launch so multiple workers share one value (the `prod` target and Docker image do this). Full detail: `/docs/static-caching.html`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# retoor <retoor@molodetz.nl>
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
import tomllib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from os import environ
|
from os import environ
|
||||||
@@ -68,7 +69,9 @@ PRESENCE_ONLINE_MARGIN_SECONDS = int(
|
|||||||
XMLRPC_BIND = environ.get("DEVPLACE_XMLRPC_BIND", "127.0.0.1")
|
XMLRPC_BIND = environ.get("DEVPLACE_XMLRPC_BIND", "127.0.0.1")
|
||||||
XMLRPC_PORT = int(environ.get("DEVPLACE_XMLRPC_PORT", "10550"))
|
XMLRPC_PORT = int(environ.get("DEVPLACE_XMLRPC_PORT", "10550"))
|
||||||
|
|
||||||
STATIC_VERSION = environ.get("DEVPLACE_STATIC_VERSION") or str(int(time.time()))
|
APP_VERSION = tomllib.loads((BASE_DIR / "pyproject.toml").read_text())["project"]["version"]
|
||||||
|
BOOT_ID = environ.get("DEVPLACE_STATIC_VERSION") or str(int(time.time()))
|
||||||
|
STATIC_VERSION = f"{APP_VERSION}-{BOOT_ID}"
|
||||||
|
|
||||||
TEMPLATE_AUTO_RELOAD = environ.get("DEVPLACE_TEMPLATE_AUTO_RELOAD", "1") != "0"
|
TEMPLATE_AUTO_RELOAD = environ.get("DEVPLACE_TEMPLATE_AUTO_RELOAD", "1") != "0"
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ This file documents JS module organization, custom web components, and shared fr
|
|||||||
- Javascript files must be in module (ES6) format and imported as module format. It must be as object oriented as possible and a file per class. There must always be a main application class called `Application`, instantiated as `app`, accessible everywhere.
|
- Javascript files must be in module (ES6) format and imported as module format. It must be as object oriented as possible and a file per class. There must always be a main application class called `Application`, instantiated as `app`, accessible everywhere.
|
||||||
- Never use JavaScript 3rd party frameworks unless specified.
|
- Never use JavaScript 3rd party frameworks unless specified.
|
||||||
- CDN scripts referenced from `base.html` (marked, highlight.js, emoji-picker-element) MUST use `defer` or `type="module"` - otherwise `wait_until="domcontentloaded"` in Playwright tests will time out.
|
- CDN scripts referenced from `base.html` (marked, highlight.js, emoji-picker-element) MUST use `defer` or `type="module"` - otherwise `wait_until="domcontentloaded"` in Playwright tests will time out.
|
||||||
- **Static asset URLs are boot-versioned for cache-busting.** Assets are served `public, immutable, max-age=31536000` (1 year, for the Lighthouse "efficient cache policy" score) while a restart still busts every browser. Never hardcode a bare `/static/...` href/src: templates wrap it in the `static_url(path)` Jinja global (`templating.py`) and runtime JS that builds an absolute static URL uses `assetUrl(path)` (`static/js/assetVersion.js`, reads `<meta name="asset-version">` rendered in `base.html`). Both emit a `/static/v<STATIC_VERSION>/...` path segment (`config.STATIC_VERSION` = `DEVPLACE_STATIC_VERSION` env or `int(time.time())` at process start), so a deploy = a restart = a new timestamp = a new URL for every asset. Both helpers no-op for non-`/static/` paths and for `/static/uploads/`, so they are safe to wrap around dynamic values.
|
- **Static asset URLs are boot-versioned for cache-busting.** Assets are served `public, immutable, max-age=31536000` (1 year, for the Lighthouse "efficient cache policy" score) while a restart still busts every browser. Never hardcode a bare `/static/...` href/src: templates wrap it in the `static_url(path)` Jinja global (`templating.py`) and runtime JS that builds an absolute static URL uses `assetUrl(path)` (`static/js/assetVersion.js`, reads `<meta name="asset-version">` rendered in `base.html`). Both emit a `/static/v<STATIC_VERSION>/...` path segment where `config.STATIC_VERSION` = `f"{APP_VERSION}-{BOOT_ID}"` - `APP_VERSION` is read straight from `pyproject.toml`'s `version` (see root `CLAUDE.md` "Version bumping" - it moves on every commit via the tracked `pre-commit` hook) and `BOOT_ID` is `DEVPLACE_STATIC_VERSION` env or `int(time.time())` at process start - so a deploy = a restart = a new URL for every asset, and the version segment itself tells you which commit is live. Both helpers no-op for non-`/static/` paths and for `/static/uploads/`, so they are safe to wrap around dynamic values.
|
||||||
- **Path segment, never a query string - do NOT switch this to `?v=`.** The frontend is unbundled ES6 with ~130 relative imports (`./Http.js`) and zero absolute ones. A `?v=` only versions the entry `<script>`; its transitive relative imports would resolve to unversioned URLs and stay frozen under `immutable`, so a deploy would not propagate JS for a year. A path segment is inherited automatically by the entire module graph and by relative CSS `url()`.
|
- **Path segment, never a query string - do NOT switch this to `?v=`.** The frontend is unbundled ES6 with ~130 relative imports (`./Http.js`) and zero absolute ones. A `?v=` only versions the entry `<script>`; its transitive relative imports would resolve to unversioned URLs and stay frozen under `immutable`, so a deploy would not propagate JS for a year. A path segment is inherited automatically by the entire module graph and by relative CSS `url()`.
|
||||||
- **Serving:** `main.py` mounts a `CachedStaticFiles` at `/static/v{STATIC_VERSION}` (immutable 1y; `service-worker.js` -> `no-cache`) plus the plain `/static` mount as the unversioned fallback; nginx mirrors this with `location ~ ^/static/v\d+/` (immutable 1y) and a short `max-age=3600` on the unversioned `/static/`. `/static/uploads/` (user content, stable DB paths) is never versioned; `service-worker.js` keeps a stable unversioned URL with `no-cache` so its registration scope is stable and a new worker always deploys.
|
- **Serving:** `main.py` mounts a `CachedStaticFiles` at `/static/v{STATIC_VERSION}` (immutable 1y; `service-worker.js` -> `no-cache`) plus the plain `/static` mount as the unversioned fallback; nginx mirrors this with `location ~ ^/static/v[\w.-]+/` (immutable 1y, matches the `APP_VERSION-BOOT_ID` shape, not pure digits) and a short `max-age=3600` on the unversioned `/static/`. `/static/uploads/` (user content, stable DB paths) is never versioned; `service-worker.js` keeps a stable unversioned URL with `no-cache` so its registration scope is stable and a new worker always deploys.
|
||||||
- **Multi-worker:** the version is captured **once at launch** and shared via `DEVPLACE_STATIC_VERSION` (set in `make prod` = `$(date +%s)` and the Dockerfile's `sh -c` CMD) - otherwise two workers could compute timestamps a second apart and serve mismatched versioned mounts (a 404 on the other worker). Unset in dev (`--reload`) so each reload refreshes it.
|
- **Multi-worker:** `BOOT_ID` is captured **once at launch** and shared via `DEVPLACE_STATIC_VERSION` (set in `make prod` = `$(date +%s)` and the Dockerfile's `sh -c` CMD) - otherwise two workers could compute timestamps a second apart and serve mismatched versioned mounts (a 404 on the other worker); `APP_VERSION` is read from the same `pyproject.toml` by every worker so it never needs pinning. Unset `DEVPLACE_STATIC_VERSION` in dev (`--reload`) so each reload refreshes `BOOT_ID`.
|
||||||
|
|
||||||
## Custom web components (`static/js/components/`)
|
## Custom web components (`static/js/components/`)
|
||||||
|
|
||||||
|
|||||||
@@ -93,5 +93,5 @@ The server block sets `X-Content-Type-Options`, `X-XSS-Protection`, and `Referre
|
|||||||
- **A WebSocket reports `connection failed` with no close code** - the route has no dedicated upgrade `location` and fell through to `location /`, which strips the upgrade headers. Add a matching `location` block (see WebSockets above) and reload nginx. This is what breaks the SEO Diagnostics live progress if the `location ~ ^/tools/seo/[^/]+/ws$` block is missing.
|
- **A WebSocket reports `connection failed` with no close code** - the route has no dedicated upgrade `location` and fell through to `location /`, which strips the upgrade headers. Add a matching `location` block (see WebSockets above) and reload nginx. This is what breaks the SEO Diagnostics live progress if the `location ~ ^/tools/seo/[^/]+/ws$` block is missing.
|
||||||
- **Uploads fail with 413** - `NGINX_MAX_BODY_SIZE` is below `max_upload_size_mb`; raise it and restart nginx.
|
- **Uploads fail with 413** - `NGINX_MAX_BODY_SIZE` is below `max_upload_size_mb`; raise it and restart nginx.
|
||||||
- **Uploaded file renders inline instead of downloading** - the `/static/uploads/` location lost its `Content-Disposition` rule.
|
- **Uploaded file renders inline instead of downloading** - the `/static/uploads/` location lost its `Content-Disposition` rule.
|
||||||
- **Static assets stale after deploy** - every app-owned asset URL carries the boot-version path segment (`/static/v<timestamp>/...`), so a restart busts the cache automatically; if assets still look stale, confirm the app actually restarted (the `<meta name="asset-version">` value in page source changed). See [Static asset caching](/docs/static-caching.html).
|
- **Static assets stale after deploy** - every app-owned asset URL carries the version path segment (`/static/v<app-version>-<boot-id>/...`), so a restart busts the cache automatically; if assets still look stale, confirm the app actually restarted (the `<meta name="asset-version">` value in page source changed). See [Static asset caching](/docs/static-caching.html).
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,12 +15,15 @@ See also [nginx and networking](/docs/production-nginx.html) and
|
|||||||
Every static URL the app emits carries a version path segment:
|
Every static URL the app emits carries a version path segment:
|
||||||
|
|
||||||
```
|
```
|
||||||
/static/css/base.css -> /static/v1718040000/css/base.css
|
/static/css/base.css -> /static/v1.0.1-1718040000/css/base.css
|
||||||
/static/js/Application.js -> /static/v1718040000/js/Application.js
|
/static/js/Application.js -> /static/v1.0.1-1718040000/js/Application.js
|
||||||
```
|
```
|
||||||
|
|
||||||
`v1718040000` is the unix timestamp captured once when the server process starts
|
`v1.0.1-1718040000` is `config.STATIC_VERSION`, an `<app-version>-<boot-id>` pair:
|
||||||
(`config.STATIC_VERSION`). Because the value is fixed for the life of the process:
|
`1.0.1` is read straight from `pyproject.toml`'s `version` (auto-bumped on every commit by
|
||||||
|
the tracked `pre-commit` hook - see "Version bumping" in the root `CLAUDE.md`), and
|
||||||
|
`1718040000` is the unix timestamp captured once when the server process starts. Because the
|
||||||
|
value is fixed for the life of the process:
|
||||||
|
|
||||||
- **Heavy caching is safe.** Each versioned URL is unique per deploy, so the browser may
|
- **Heavy caching is safe.** Each versioned URL is unique per deploy, so the browser may
|
||||||
cache it for a full year (`Cache-Control: public, immutable, max-age=31536000`).
|
cache it for a full year (`Cache-Control: public, immutable, max-age=31536000`).
|
||||||
@@ -38,8 +41,8 @@ URLs and stay frozen under the immutable cache - a deploy would not propagate Ja
|
|||||||
changes for up to a year.
|
changes for up to a year.
|
||||||
|
|
||||||
Putting the version in the path solves this for free: a module loaded from
|
Putting the version in the path solves this for free: a module loaded from
|
||||||
`/static/v1718040000/js/Application.js` resolves `./Http.js` to
|
`/static/v1.0.1-1718040000/js/Application.js` resolves `./Http.js` to
|
||||||
`/static/v1718040000/js/Http.js`, so the **entire module graph and every relative CSS
|
`/static/v1.0.1-1718040000/js/Http.js`, so the **entire module graph and every relative CSS
|
||||||
`url()`** inherits the version automatically with no per-file rewriting.
|
`url()`** inherits the version automatically with no per-file rewriting.
|
||||||
|
|
||||||
## The helpers
|
## The helpers
|
||||||
@@ -70,10 +73,10 @@ importing module's URL.
|
|||||||
|
|
||||||
## Serving and cache headers
|
## Serving and cache headers
|
||||||
|
|
||||||
| Layer | Versioned `/static/v<n>/...` | Unversioned `/static/...` |
|
| Layer | Versioned `/static/v<app-version>-<boot-id>/...` | Unversioned `/static/...` |
|
||||||
|-------|------------------------------|---------------------------|
|
|-------|----------------------------------------------------|---------------------------|
|
||||||
| App (dev / `make dev`) | `CachedStaticFiles` mount sets `public, immutable, max-age=31536000` | plain mount, validator-based revalidation |
|
| App (dev / `make dev`) | `CachedStaticFiles` mount sets `public, immutable, max-age=31536000` | plain mount, validator-based revalidation |
|
||||||
| nginx (prod) | regex `location ~ ^/static/v\d+/` sets `public, immutable, max-age=31536000` | `public, max-age=3600` (one hour, not immutable) |
|
| nginx (prod) | regex `location ~ ^/static/v[\w.-]+/` sets `public, immutable, max-age=31536000` | `public, max-age=3600` (one hour, not immutable) |
|
||||||
|
|
||||||
The unversioned `/static/` location stays a short, non-immutable cache because a few
|
The unversioned `/static/` location stays a short, non-immutable cache because a few
|
||||||
unversioned URLs still exist (the service worker's precache icons, direct hits); they must
|
unversioned URLs still exist (the service worker's precache icons, direct hits); they must
|
||||||
@@ -90,22 +93,30 @@ cache and stable stored paths; it is never boot-versioned.
|
|||||||
## Multi-worker consistency
|
## Multi-worker consistency
|
||||||
|
|
||||||
In production the app runs multiple uvicorn workers. Each worker is a separate process, so
|
In production the app runs multiple uvicorn workers. Each worker is a separate process, so
|
||||||
if every worker computed its own timestamp they could disagree by a second and emit
|
`config.STATIC_VERSION` is split into two halves that need different treatment:
|
||||||
mismatched asset URLs. The version is therefore fixed **once at launch** and shared through
|
|
||||||
the `DEVPLACE_STATIC_VERSION` environment variable:
|
|
||||||
|
|
||||||
- `make prod` prefixes the command with `DEVPLACE_STATIC_VERSION=$(date +%s)`.
|
- **`APP_VERSION`** (the `pyproject.toml` `version`, e.g. `1.0.1`) is read from the same file
|
||||||
- The Docker image launches uvicorn through `sh -c` so a single `date +%s` is captured and
|
by every worker, so it agrees automatically with no coordination needed.
|
||||||
exported before the workers fork.
|
- **`BOOT_ID`** (a unix timestamp) would NOT agree automatically - if every worker computed
|
||||||
|
its own `time.time()` at import they could disagree by a second and emit mismatched asset
|
||||||
|
URLs. It is therefore fixed **once at launch** and shared through the
|
||||||
|
`DEVPLACE_STATIC_VERSION` environment variable:
|
||||||
|
- `make prod` prefixes the command with `DEVPLACE_STATIC_VERSION=$(date +%s)`.
|
||||||
|
- The Docker image launches uvicorn through `sh -c` so a single `date +%s` is captured and
|
||||||
|
exported before the workers fork.
|
||||||
|
|
||||||
When `DEVPLACE_STATIC_VERSION` is unset (dev with `--reload`), each process boot computes a
|
When `DEVPLACE_STATIC_VERSION` is unset (dev with `--reload`), each process boot computes a
|
||||||
fresh timestamp, so a reload after editing a file naturally produces a new version. You can
|
fresh `BOOT_ID`, so a reload after editing a file naturally produces a new version even
|
||||||
also pin the variable to a build id or git sha in CI for reproducible URLs.
|
without a new commit. You can also pin the variable to a build id or git sha in CI for
|
||||||
|
reproducible URLs.
|
||||||
|
|
||||||
## Verify
|
## Verify
|
||||||
|
|
||||||
```
|
```
|
||||||
curl -sI http://localhost:10500/static/v$(date +%s)/css/base.css | grep -i cache-control
|
curl -s http://localhost:10500/ | grep -o 'name="asset-version" content="[^"]*"'
|
||||||
|
# -> name="asset-version" content="1.0.1-1718040000"
|
||||||
|
|
||||||
|
curl -sI http://localhost:10500/static/v1.0.1-1718040000/css/base.css | grep -i cache-control
|
||||||
# -> Cache-Control: public, max-age=31536000, immutable
|
# -> Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|
||||||
curl -sI http://localhost:10500/service-worker.js | grep -i cache-control
|
curl -sI http://localhost:10500/service-worker.js | grep -i cache-control
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ server {
|
|||||||
log_not_found off;
|
log_not_found off;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~ ^/static/v\d+/(?<asset>.+)$ {
|
location ~ ^/static/v[\w.-]+/(?<asset>.+)$ {
|
||||||
alias /app/static/$asset;
|
alias /app/static/$asset;
|
||||||
add_header X-Content-Type-Options nosniff;
|
add_header X-Content-Type-Options nosniff;
|
||||||
expires 1y;
|
expires 1y;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "devplacepy"
|
name = "devplacepy"
|
||||||
version = "1.0.2"
|
version = "1.0.3"
|
||||||
description = "DevPlace - The Developer Social Network"
|
description = "DevPlace - The Developer Social Network"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Reference in New Issue
Block a user