Template
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
740ad9f1a3 | ||
|
|
e6f92ca563 | ||
|
|
450b7155b1 | ||
|
|
1bd5c72ce5 | ||
|
|
e01dc843b2 | ||
|
|
5e7f722bd9 |
No files matched your search
@@ -1,148 +1,38 @@
|
|||||||
# exteraGram Plugin Base
|
<p align="center">
|
||||||
|
<img src="images/icon.svg" width="128" height="128" alt="exteraGram Plugin Base icon">
|
||||||
|
</p>
|
||||||
|
|
||||||
A build toolchain and ADB automation for writing [exteraGram](https://exteragram.app) plugins
|
<h1 align="center">exteraGram Plugin Base</h1>
|
||||||
as ordinary multi-file Python projects instead of hand-maintaining one giant `.py` file.
|
|
||||||
|
|
||||||
## How exteraGram plugins actually work
|
**TL;DR:** a template repo for writing [exteraGram](https://exteragram.app) plugins as normal
|
||||||
|
multi-file Python projects. Write code in `src/`, run one command, get a single `.plugin`
|
||||||
|
file deployed to your phone or published to Releases.
|
||||||
|
|
||||||
A plugin, as exteraGram loads it, is exactly **one Python file** on the device:
|
## Description
|
||||||
`/data/user/0/com.exteragram.messenger/files/plugins/<plugin_id>.py`. The distribution
|
|
||||||
convention names the same content `<id>.plugin`.
|
|
||||||
|
|
||||||
The app reads plugin metadata **statically via AST** — it looks for top-level constant
|
exteraGram loads every plugin from exactly one Python file. This template lets you split a
|
||||||
assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`,
|
plugin into ordinary modules and packages anyway: a bundler compiles `src/` and `plugin.json`
|
||||||
`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the
|
into that single file, an offline harness verifies the result without a device, ADB scripts
|
||||||
file. Only `__id__` and `__name__` are required.
|
push it to a connected phone and reload it, and CI publishes every version to the repo's
|
||||||
|
Releases tab.
|
||||||
|
|
||||||
The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import
|
## Features
|
||||||
BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and
|
|
||||||
hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`,
|
|
||||||
`on_app_event`) are all called by the host; hooks must be registered inside
|
|
||||||
`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a
|
|
||||||
`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare
|
|
||||||
`HookResult()` to pass through. Settings rows returned from `create_settings` come from
|
|
||||||
`ui.settings` (`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider`) and persist through
|
|
||||||
`self.get_setting`/`self.set_setting`. Logging goes through `android_utils.log`; Chaquopy also
|
|
||||||
redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.stderr`.
|
|
||||||
|
|
||||||
This repo does not use the official Elyx/ElyxBuilder archive format — see
|
None for the end user. The bundled sample plugin (`Hello Bundle`) only proves that the
|
||||||
`docs/sdk-notes.md` for why.
|
toolchain works. This repo is meant to be used as a **template** for creating other plugins:
|
||||||
|
click "Use this template" (or clone it), then replace `plugin.json` and `src/` with your own.
|
||||||
|
|
||||||
## Repo layout
|
## Documentation
|
||||||
|
|
||||||
```
|
- [Development](docs/development.md): getting started, repo layout, building and verifying, icon export
|
||||||
plugin.json single source of truth for plugin metadata
|
- [How exteraGram plugins work](docs/how-plugins-work.md): the plugin format and lifecycle
|
||||||
src/ the plugin, written as an ordinary Python package
|
- [Device setup and ADB automation](docs/device-and-adb.md): deploy, watch, logs, debugger
|
||||||
main.py entry module — exactly one BasePlugin subclass, absolute imports only
|
- [Versioning and releases](docs/versioning-and-releases.md): git hooks, auto-bump, CI publishing
|
||||||
greeting/ sample package (proves relative + cross-module imports survive bundling)
|
- [SDK notes](docs/sdk-notes.md): condensed exteraGram SDK API surface
|
||||||
textutil.py sample top-level sibling module
|
|
||||||
tools/
|
|
||||||
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
|
|
||||||
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
|
|
||||||
verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
|
|
||||||
release.py publishes a built bundle to a Gitea release (used by CI)
|
|
||||||
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
|
|
||||||
scripts/ bash automation for the ADB debug loop (see below)
|
|
||||||
docs/sdk-notes.md condensed SDK API surface gathered from the official docs
|
|
||||||
```
|
|
||||||
|
|
||||||
## The three commands that matter
|
## Quick start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 tools/bundle.py --release # compile src/ -> dist/<id>.plugin
|
python3 tools/bundle.py --check # once after cloning: enables the git hooks
|
||||||
python3 tools/verify_bundle.py dist/<id>.plugin # offline proof the bundle works, no device needed
|
bash scripts/deploy.sh --enable # build + push to a connected device + enable
|
||||||
bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`bundle.py` walks `src/` recursively, embeds every `.py` file's source and dotted module name
|
|
||||||
into a compressed, base64-encoded payload, and appends a small runtime preamble that installs
|
|
||||||
a private import hook (scoped to a per-module `__builtins__` dict, never touching
|
|
||||||
`sys.modules`) so cross-module and cross-package imports resolve exactly as they would if the
|
|
||||||
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
|
|
||||||
resolves breakpoints against your real source files; `--release` embeds repo-relative paths
|
|
||||||
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
|
|
||||||
|
|
||||||
## Device prerequisites
|
|
||||||
|
|
||||||
- exteraGram 12.5.1+.
|
|
||||||
- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
|
|
||||||
- USB debugging enabled, device authorized (`adb devices`).
|
|
||||||
|
|
||||||
Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the
|
|
||||||
app's private plugin directory. The on-device **dev server** (`tools/devclient.py`,
|
|
||||||
`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP
|
|
||||||
socket forwarded with `adb forward`, and the app writes the file into its own storage.
|
|
||||||
|
|
||||||
## ADB automation
|
|
||||||
|
|
||||||
All scripts live in `scripts/`, are runnable from any working directory, and honor the
|
|
||||||
standard `ANDROID_SERIAL` env var for device selection.
|
|
||||||
|
|
||||||
- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the
|
|
||||||
`tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server.
|
|
||||||
- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the
|
|
||||||
plugin to the device, optionally enables it, reloads it.
|
|
||||||
- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change
|
|
||||||
(uses `inotifywait` if available, otherwise polls).
|
|
||||||
- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
|
|
||||||
raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first).
|
|
||||||
- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and
|
|
||||||
tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or
|
|
||||||
`pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
|
|
||||||
`plugin.json` if it's missing.
|
|
||||||
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
|
|
||||||
for editor autocomplete.
|
|
||||||
|
|
||||||
## Publishing a release
|
|
||||||
|
|
||||||
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
|
|
||||||
Releases tab on every push to `main`. `dist/` is gitignored, so the artifact is always built
|
|
||||||
by CI rather than committed. Nothing in the workflow is plugin-specific — it reads the id and
|
|
||||||
version straight out of `plugin.json`, so it keeps working after you rename the plugin.
|
|
||||||
|
|
||||||
The `version` field in `plugin.json` is the single source of truth:
|
|
||||||
|
|
||||||
| Push to `main` with… | Result |
|
|
||||||
| --- | --- |
|
|
||||||
| a new `version` | creates tag `v<version>` and a new release |
|
|
||||||
| the same `version` | rebuilds the existing release, replacing its attachment |
|
|
||||||
|
|
||||||
So every push leaves a downloadable build attached to the current version, and bumping
|
|
||||||
`version` is what cuts a new release. Pushing a `v*` tag by hand still works and uses that
|
|
||||||
tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
|
|
||||||
|
|
||||||
Every path runs `--check`, builds with `--release`, and gates the upload on
|
|
||||||
`verify_bundle.py`, so a bundle that fails verification is never published. A rebuild
|
|
||||||
replaces the attachment rather than piling up duplicates, and refreshes the release notes to
|
|
||||||
name the commit it came from. The tag itself is never moved: it keeps pointing at the commit
|
|
||||||
that first introduced that version.
|
|
||||||
|
|
||||||
Authentication uses `secrets.API_TOKEN`, an organisation-level Actions secret holding a user
|
|
||||||
token with the `write:repository` scope (which covers releases). Set it once under
|
|
||||||
**Org → Settings → Actions → Secrets** and every repo in the organisation inherits it; a
|
|
||||||
repo-level secret of the same name takes precedence if one repo ever needs different
|
|
||||||
credentials.
|
|
||||||
|
|
||||||
The workflow passes it to `release.py` as `RELEASE_TOKEN`, **not** `GITEA_TOKEN`. The Actions
|
|
||||||
runner injects its own `GITEA_TOKEN` into every step, and that value overrides a step-level
|
|
||||||
`env:` entry of the same name, so the org secret would be silently ignored.
|
|
||||||
|
|
||||||
If the org secret does not reach a repository the publish still works: `release.py` tries
|
|
||||||
`RELEASE_TOKEN` first and then falls back to the runner's `GITEA_TOKEN`, which can publish
|
|
||||||
releases in its own repository, and prints which one it used. An organisation secret is only
|
|
||||||
exposed to a repository that has Actions enabled, so check **Repo → Settings → Actions** if a
|
|
||||||
repo unexpectedly falls back.
|
|
||||||
|
|
||||||
If you would rather not manage a token at all, that injected `GITEA_TOKEN` can create
|
|
||||||
releases in its own repo: pass `--token-env GITEA_TOKEN` and drop the org secret. It is
|
|
||||||
scoped to the single job and expires with it, so it is the safer default; `API_TOKEN` is what
|
|
||||||
you want when publishing needs to reach beyond this repo.
|
|
||||||
|
|
||||||
## Writing your own plugin
|
|
||||||
|
|
||||||
1. Edit `plugin.json` (id, name, description, etc.).
|
|
||||||
2. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
|
|
||||||
only — it's executed at plugin-module top level, not inside a package).
|
|
||||||
3. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
|
|
||||||
(substitute your `id` from `plugin.json`).
|
|
||||||
4. `bash scripts/deploy.sh --enable` with a device connected.
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
## Writing your own plugin
|
||||||
|
|
||||||
|
1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the
|
||||||
|
version on commit (see [Git hooks and versioning](versioning-and-releases.md#git-hooks-and-versioning)).
|
||||||
|
2. Edit `plugin.json` (id, name, description, etc.).
|
||||||
|
3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports
|
||||||
|
only — it's executed at plugin-module top level, not inside a package).
|
||||||
|
4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/<id>.plugin`
|
||||||
|
(substitute your `id` from `plugin.json`).
|
||||||
|
5. `bash scripts/deploy.sh --enable` with a device connected.
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
plugin.json single source of truth for plugin metadata
|
||||||
|
src/ the plugin, written as an ordinary Python package
|
||||||
|
main.py entry module — exactly one BasePlugin subclass, absolute imports only
|
||||||
|
greeting/ sample package (proves relative + cross-module imports survive bundling)
|
||||||
|
textutil.py sample top-level sibling module
|
||||||
|
tools/
|
||||||
|
bundle.py compiles plugin.json + src/ into dist/<id>.plugin
|
||||||
|
devclient.py speaks the on-device dev-server protocol (127.0.0.1:42690)
|
||||||
|
verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed
|
||||||
|
release.py publishes a built bundle to a Gitea release (used by CI)
|
||||||
|
icon_png.py renders images/icon.svg to a square PNG
|
||||||
|
images/icon.svg the plugin icon, source of truth for any PNG export
|
||||||
|
.gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases
|
||||||
|
scripts/ ADB debug-loop automation and the git hooks
|
||||||
|
docs/ these notes + sdk-notes.md, the condensed SDK API surface
|
||||||
|
```
|
||||||
|
|
||||||
|
## The three commands that matter
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/bundle.py --release # compile src/ -> dist/<id>.plugin
|
||||||
|
python3 tools/verify_bundle.py dist/<id>.plugin # offline proof the bundle works, no device needed
|
||||||
|
bash scripts/deploy.sh --enable # build (--dev) + push to a connected device + enable + reload
|
||||||
|
```
|
||||||
|
|
||||||
|
`bundle.py` walks `src/` recursively, embeds every `.py` file's source and dotted module name
|
||||||
|
into a compressed, base64-encoded payload, and appends a small runtime preamble that installs
|
||||||
|
a private import hook (scoped to a per-module `__builtins__` dict, never touching
|
||||||
|
`sys.modules`) so cross-module and cross-package imports resolve exactly as they would if the
|
||||||
|
files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger
|
||||||
|
resolves breakpoints against your real source files; `--release` embeds repo-relative paths
|
||||||
|
for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`.
|
||||||
|
Its first run in a clone also enables the repo's git hooks (see
|
||||||
|
[Git hooks and versioning](versioning-and-releases.md#git-hooks-and-versioning)).
|
||||||
|
|
||||||
|
## Exporting the icon as PNG
|
||||||
|
|
||||||
|
`tools/icon_png.py` renders `images/icon.svg` to `images/icon.png`, 512x512 by default.
|
||||||
|
`--resolution` sets the size of both sides, so `--resolution 1024` gives 1024x1024:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/icon_png.py # images/icon.png, 512x512
|
||||||
|
python3 tools/icon_png.py --resolution 1024 # images/icon.png, 1024x1024
|
||||||
|
python3 tools/icon_png.py some.svg -o out.png
|
||||||
|
```
|
||||||
|
|
||||||
|
The output is always PNG; any other `-o` extension is rejected. The script itself is stdlib
|
||||||
|
only and hands the rendering to the first renderer it finds: the `cairosvg` Python package,
|
||||||
|
`rsvg-convert` (librsvg), ImageMagick or Inkscape. Install any one of them.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Device setup and ADB automation
|
||||||
|
|
||||||
|
## Device prerequisites
|
||||||
|
|
||||||
|
- exteraGram 12.5.1+.
|
||||||
|
- In-app: Preferences → Plugins → enable the plugin engine, then enable developer mode.
|
||||||
|
- USB debugging enabled, device authorized (`adb devices`).
|
||||||
|
|
||||||
|
Release-build APKs disallow `adb run-as`, so you cannot `adb push` a file straight into the
|
||||||
|
app's private plugin directory. The on-device **dev server** (`tools/devclient.py`,
|
||||||
|
`scripts/deploy.sh`) is the actual install path — it talks to the running app over a TCP
|
||||||
|
socket forwarded with `adb forward`, and the app writes the file into its own storage.
|
||||||
|
|
||||||
|
## ADB automation
|
||||||
|
|
||||||
|
All scripts live in `scripts/`, are runnable from any working directory, and honor the
|
||||||
|
standard `ANDROID_SERIAL` env var for device selection.
|
||||||
|
|
||||||
|
- `scripts/adb-connect.sh [--debug]` — starts adb, waits for the device, (re)creates the
|
||||||
|
`tcp:42690` port forward (and `tcp:5678` with `--debug`), pings the dev server.
|
||||||
|
- `scripts/deploy.sh [--release] [--enable] [--no-connect]` — bundles, connects, writes the
|
||||||
|
plugin to the device, optionally enables it, reloads it.
|
||||||
|
- `scripts/watch.sh [deploy args…]` — watches `src/` and `plugin.json`, redeploys on change
|
||||||
|
(uses `inotifywait` if available, otherwise polls).
|
||||||
|
- `scripts/logs.sh [-c] [-t] [-g PATTERN]` — streams logcat for the running app (`-t` for the
|
||||||
|
raw Chaquopy stdio tags, `-g` to grep, `-c` to clear first).
|
||||||
|
- `scripts/debug.sh start|stop [--platform vscode|pycharm]` — forwards the debugger port and
|
||||||
|
tells the dev server to start/stop the in-app debugger. Requires `debugpy` (vscode) or
|
||||||
|
`pydevd-pycharm` (pycharm) present in the app's Python runtime — add it to `requirements` in
|
||||||
|
`plugin.json` if it's missing.
|
||||||
|
- `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/`
|
||||||
|
for editor autocomplete.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# How exteraGram plugins work
|
||||||
|
|
||||||
|
## How exteraGram plugins actually work
|
||||||
|
|
||||||
|
A plugin, as exteraGram loads it, is exactly **one Python file** on the device:
|
||||||
|
`/data/user/0/com.exteragram.messenger/files/plugins/<plugin_id>.py`. The distribution
|
||||||
|
convention names the same content `<id>.plugin`.
|
||||||
|
|
||||||
|
The app reads plugin metadata **statically via AST** — it looks for top-level constant
|
||||||
|
assignments (`__id__`, `__name__`, `__description__`, `__author__`, `__version__`,
|
||||||
|
`__icon__`, `__app_version__`, `__sdk_version__`, `__requirements__`) without executing the
|
||||||
|
file. Only `__id__` and `__name__` are required.
|
||||||
|
|
||||||
|
The file must define exactly one class inheriting `BasePlugin` (`from base_plugin import
|
||||||
|
BasePlugin`). Lifecycle methods (`on_plugin_load`, `on_plugin_unload`, `create_settings`) and
|
||||||
|
hooks (`on_send_message_hook`, `pre_request_hook`, `post_request_hook`, `on_update_hook(s)`,
|
||||||
|
`on_app_event`) are all called by the host; hooks must be registered inside
|
||||||
|
`on_plugin_load` (e.g. `self.add_on_send_message_hook()`). A hook returns a
|
||||||
|
`HookResult(strategy=HookStrategy.MODIFY, params=params)` to apply changes, or a bare
|
||||||
|
`HookResult()` to pass through. Settings rows returned from `create_settings` come from
|
||||||
|
`ui.settings` (`Header`, `Input`, `Switch`, `Text`, `Selector`, `Divider`) and persist through
|
||||||
|
`self.get_setting`/`self.set_setting`. Logging goes through `android_utils.log`; Chaquopy also
|
||||||
|
redirects `sys.stdout`/`sys.stderr` to the logcat tags `python.stdout`/`python.stderr`.
|
||||||
|
|
||||||
|
This repo does not use the official Elyx/ElyxBuilder archive format — see
|
||||||
|
[`sdk-notes.md`](sdk-notes.md) for why.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Versioning and releases
|
||||||
|
|
||||||
|
## Git hooks and versioning
|
||||||
|
|
||||||
|
`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit
|
||||||
|
(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages
|
||||||
|
`plugin.json` afterwards, because commits of a file selection (`git commit -- <paths>`, which
|
||||||
|
some IDEs use) are built from a temporary index and would otherwise leave it looking modified.
|
||||||
|
|
||||||
|
**The hooks enable themselves.** Git never activates hooks from repository content, so a
|
||||||
|
fresh clone, or a repo created from this template, starts without them. The first run of
|
||||||
|
`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every
|
||||||
|
workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That
|
||||||
|
includes `--check`, so after cloning run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/bundle.py --check
|
||||||
|
```
|
||||||
|
|
||||||
|
once, before your first commit. A commit made before any build runs without the hooks.
|
||||||
|
`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on
|
||||||
|
purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and
|
||||||
|
never fails a build over it. To turn the hooks off for a clone, run
|
||||||
|
`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts
|
||||||
|
until the next build.
|
||||||
|
|
||||||
|
The bump is skipped when:
|
||||||
|
|
||||||
|
- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or
|
||||||
|
`[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work),
|
||||||
|
- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way
|
||||||
|
for commits written in an editor, since their message doesn't exist yet when the hook runs,
|
||||||
|
- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a
|
||||||
|
minor or major release),
|
||||||
|
- a merge, rebase, cherry-pick or revert is in progress.
|
||||||
|
|
||||||
|
Because every version change publishes a release (see [Publishing a release](#publishing-a-release)), a normal commit + push to
|
||||||
|
`main` ships a new patch release. Use a stop word for commits that should not.
|
||||||
|
|
||||||
|
## Publishing a release
|
||||||
|
|
||||||
|
`.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's
|
||||||
|
Releases tab on every push to `main`. `dist/` is gitignored, so the artifact is always built
|
||||||
|
by CI rather than committed. Nothing in the workflow is plugin-specific — it reads the id and
|
||||||
|
version straight out of `plugin.json`, so it keeps working after you rename the plugin.
|
||||||
|
|
||||||
|
The `version` field in `plugin.json` is the single source of truth:
|
||||||
|
|
||||||
|
| Push to `main` with… | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| a new `version` | creates tag `v<version>` and a new release |
|
||||||
|
| the same `version` | rebuilds the existing release, replacing its attachment |
|
||||||
|
|
||||||
|
So every push leaves a downloadable build attached to the current version, and bumping
|
||||||
|
`version` is what cuts a new release. With the git hooks enabled every commit bumps it,
|
||||||
|
so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that
|
||||||
|
tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow).
|
||||||
|
|
||||||
|
Every path runs `--check`, builds with `--release`, and gates the upload on
|
||||||
|
`verify_bundle.py`, so a bundle that fails verification is never published. A rebuild
|
||||||
|
replaces the attachment rather than piling up duplicates, and refreshes the release notes to
|
||||||
|
name the commit it came from. The tag itself is never moved: it keeps pointing at the commit
|
||||||
|
that first introduced that version.
|
||||||
|
|
||||||
|
Authentication uses `secrets.API_TOKEN`, an organisation-level Actions secret holding a user
|
||||||
|
token with the `write:repository` scope (which covers releases). Set it once under
|
||||||
|
**Org → Settings → Actions → Secrets** and every repo in the organisation inherits it; a
|
||||||
|
repo-level secret of the same name takes precedence if one repo ever needs different
|
||||||
|
credentials.
|
||||||
|
|
||||||
|
The workflow passes it to `release.py` as `RELEASE_TOKEN`, **not** `GITEA_TOKEN`. The Actions
|
||||||
|
runner injects its own `GITEA_TOKEN` into every step, and that value overrides a step-level
|
||||||
|
`env:` entry of the same name, so the org secret would be silently ignored.
|
||||||
|
|
||||||
|
If the org secret does not reach a repository the publish still works: `release.py` tries
|
||||||
|
`RELEASE_TOKEN` first and then falls back to the runner's `GITEA_TOKEN`, which can publish
|
||||||
|
releases in its own repository, and prints which one it used. An organisation secret is only
|
||||||
|
exposed to a repository that has Actions enabled, so check **Repo → Settings → Actions** if a
|
||||||
|
repo unexpectedly falls back.
|
||||||
|
|
||||||
|
If you would rather not manage a token at all, that injected `GITEA_TOKEN` can create
|
||||||
|
releases in its own repo: pass `--token-env GITEA_TOKEN` and drop the org secret. It is
|
||||||
|
scoped to the single job and expires with it, so it is the safer default; `API_TOKEN` is what
|
||||||
|
you want when publishing needs to reach beyond this repo.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="640" viewBox="0 0 640 640">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0.3" y1="0" x2="0.7" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F89B2E"/><stop offset="0.5" stop-color="#F7724A"/><stop offset="1" stop-color="#F5517A"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="glow" cx="0.5" cy="0.45" r="0.5">
|
||||||
|
<stop offset="0" stop-color="#FF5A3C" stop-opacity="0.18"/><stop offset="1" stop-color="#FF5A3C" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="paper" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#F7F7F7"/><stop offset="1" stop-color="#E2E2E2"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="star" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FFE27A"/><stop offset="1" stop-color="#FFC53D"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#7CC8FF"/><stop offset="1" stop-color="#CFEBFF"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="metal" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#9C9C9C"/><stop offset="0.45" stop-color="#E4E4E4"/><stop offset="1" stop-color="#A8A8A8"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="photo"><rect x="166" y="196" width="228" height="208" rx="18"/></clipPath>
|
||||||
|
<filter id="sh" x="-20%" y="-20%" width="140%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="10" stdDeviation="10" flood-color="#7A1E2E" flood-opacity="0.28"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="640" height="640" fill="url(#bg)"/>
|
||||||
|
<rect width="640" height="640" fill="url(#glow)"/>
|
||||||
|
|
||||||
|
<g transform="translate(0,-14)">
|
||||||
|
<rect filter="url(#sh)" x="140" y="470" width="360" height="54" rx="20" fill="url(#paper)" stroke="#B9B9B9" stroke-width="6" stroke-linejoin="round"/>
|
||||||
|
<line x1="176" y1="497" x2="464" y2="497" stroke="#CFCFCF" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
<path filter="url(#sh)" transform="translate(-32,0)" fill="url(#star)" stroke="#F59A2E" stroke-width="8" stroke-linejoin="round" d="M200,220 H285 C275,180 290,152 320,152 C350,152 365,180 355,220 H440 V300 C480,290 508,305 508,340 C508,375 480,390 440,380 V460 H355 C365,420 350,398 320,398 C290,398 275,420 285,460 H200 Z"/>
|
||||||
|
<path transform="translate(-32,0)" d="M222,250 V300" stroke="#FFF3C4" stroke-width="12" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
+2
-2
@@ -2,9 +2,9 @@
|
|||||||
"id": "hello_bundle",
|
"id": "hello_bundle",
|
||||||
"name": "Hello Bundle",
|
"name": "Hello Bundle",
|
||||||
"description": "Multi-file exteraGram plugin built by tools/bundle.py",
|
"description": "Multi-file exteraGram plugin built by tools/bundle.py",
|
||||||
"author": "@yourhandle",
|
"author": "@lyuksovannyy",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"icon": "exteraPlugins/1",
|
"icon": "exteraGramPluginIcons/0",
|
||||||
"app_version": ">=12.5.1",
|
"app_version": ">=12.5.1",
|
||||||
"sdk_version": ">=1.4.4.3",
|
"sdk_version": ">=1.4.4.3",
|
||||||
"requirements": []
|
"requirements": []
|
||||||
|
|||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Companion to scripts/pre-commit. Both are enabled per clone by tools/bundle.py
|
||||||
|
# (it sets `core.hooksPath scripts` on its first run).
|
||||||
|
#
|
||||||
|
# `git commit -- <pathspec>` (also how some IDEs commit a file selection) builds
|
||||||
|
# the commit from a temporary index. The pre-commit bump therefore lands in the
|
||||||
|
# commit, but the real index keeps the pre-bump blob and plugin.json is left
|
||||||
|
# looking modified. The real index cannot be updated during the commit because
|
||||||
|
# git holds index.lock, so refresh it here instead.
|
||||||
|
#
|
||||||
|
# Must never fail a commit that already succeeded.
|
||||||
|
git add -- plugin.json 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
Executable
+265
@@ -0,0 +1,265 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Increment the patch component of plugin.json's version as part of the commit.
|
||||||
|
|
||||||
|
Enabled per clone by tools/bundle.py, which sets `core.hooksPath scripts` on its
|
||||||
|
first run (git never enables hooks from repository content by itself).
|
||||||
|
|
||||||
|
Runs as a pre-commit hook, which is the only commit hook whose `git add` is
|
||||||
|
picked up by the commit currently being created:
|
||||||
|
|
||||||
|
* pre-commit -> staged changes land in THIS commit (correct)
|
||||||
|
* prepare-commit-msg -> staged changes land in the NEXT commit
|
||||||
|
* commit-msg -> staged changes are ignored entirely
|
||||||
|
|
||||||
|
The commit message is read from the invoking `git` process' argv (-m/-F/-C and
|
||||||
|
long forms). `.git/COMMIT_EDITMSG` is deliberately NOT consulted: at pre-commit
|
||||||
|
time it still holds the PREVIOUS commit's message, so a stop word there would
|
||||||
|
silently suppress every later bump.
|
||||||
|
|
||||||
|
Bump is skipped when:
|
||||||
|
* the commit message carries a stop word (see STOP_PATTERNS),
|
||||||
|
* NO_BUMP / GIT_NO_BUMP is set in the environment,
|
||||||
|
* plugin.json's version already differs from HEAD (manual bump),
|
||||||
|
* a merge or rebase is in progress,
|
||||||
|
* plugin.json is absent or its version is not x.y.z.
|
||||||
|
|
||||||
|
For editor-driven commits (`git commit` with no -m) the message does not exist
|
||||||
|
yet when this hook runs, so use `NO_BUMP=1 git commit` to skip the bump there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PLUGIN_FILE = "plugin.json"
|
||||||
|
|
||||||
|
STOP_PATTERNS = (
|
||||||
|
r"\[\s*no[-_ ]?bump\s*\]",
|
||||||
|
r"\[\s*skip[-_ ]?bump\s*\]",
|
||||||
|
r"\[\s*no[-_ ]?version[-_ ]?bump\s*\]",
|
||||||
|
r"\[\s*skip[-_ ]?version\s*\]",
|
||||||
|
)
|
||||||
|
STOP_RE = re.compile("|".join(STOP_PATTERNS), re.IGNORECASE)
|
||||||
|
|
||||||
|
VERSION_RE = re.compile(r"\A(\d+)\.(\d+)\.(\d+)(.*)\Z", re.DOTALL)
|
||||||
|
|
||||||
|
# options whose value IS the message text
|
||||||
|
TEXT_OPTS = ("-m", "--message")
|
||||||
|
TEXT_OPTS_EQ = ("--message=",)
|
||||||
|
# options whose value NAMES a file containing the message
|
||||||
|
FILE_OPTS = ("-F", "--file")
|
||||||
|
FILE_OPTS_EQ = ("--file=",)
|
||||||
|
# options whose value names a commit to take the message from
|
||||||
|
COMMIT_OPTS = ("-C", "--reuse-message", "-c", "--reedit-message")
|
||||||
|
COMMIT_OPTS_EQ = ("--reuse-message=", "--reedit-message=")
|
||||||
|
|
||||||
|
|
||||||
|
def repo_root() -> Path:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "rev-parse", "--show-toplevel"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if out.returncode != 0:
|
||||||
|
raise RuntimeError(out.stderr.strip() or "not inside a git work tree")
|
||||||
|
return Path(out.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def git_dir() -> Path:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "rev-parse", "--absolute-git-dir"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if out.returncode != 0:
|
||||||
|
raise RuntimeError(out.stderr.strip() or "cannot resolve git dir")
|
||||||
|
return Path(out.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def bump_patch(version: str) -> str:
|
||||||
|
m = VERSION_RE.match(version.strip())
|
||||||
|
if not m:
|
||||||
|
raise ValueError(f"version {version!r} is not x.y.z")
|
||||||
|
major, minor, patch, suffix = m.groups()
|
||||||
|
return f"{major}.{minor}.{int(patch) + 1}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def head_version(root: Path) -> str | None:
|
||||||
|
"""Version recorded for plugin.json in HEAD, or None when unavailable."""
|
||||||
|
if subprocess.run(
|
||||||
|
["git", "rev-parse", "--verify", "--quiet", "HEAD"],
|
||||||
|
cwd=root,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
).returncode != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
show = subprocess.run(
|
||||||
|
["git", "show", f"HEAD:{PLUGIN_FILE}"],
|
||||||
|
cwd=root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if show.returncode != 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return str(json.loads(show.stdout)["version"])
|
||||||
|
except (json.JSONDecodeError, KeyError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def commit_message_text(root: Path) -> str:
|
||||||
|
"""Commit message text of the git process that spawned this hook.
|
||||||
|
|
||||||
|
Covers `-m/--message` (inline), `-F/--file` (read the file) and
|
||||||
|
`-C/-c/--reuse-message/--reedit-message` (read that commit's message).
|
||||||
|
Returns "" for editor-driven commits, where no message exists yet.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
argv = Path(f"/proc/{os.getppid()}/cmdline").read_bytes().decode("utf-8", "replace").split("\0")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
texts: list[str] = []
|
||||||
|
pending: str | None = None # "text" | "file" | "commit"
|
||||||
|
|
||||||
|
def take(kind: str, value: str) -> None:
|
||||||
|
if kind == "text":
|
||||||
|
texts.append(value)
|
||||||
|
elif kind == "file":
|
||||||
|
try:
|
||||||
|
texts.append(Path(value).read_text(encoding="utf-8", errors="replace"))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
out = subprocess.run(
|
||||||
|
["git", "log", "-1", "--format=%B", value],
|
||||||
|
cwd=root,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if out.returncode == 0:
|
||||||
|
texts.append(out.stdout)
|
||||||
|
|
||||||
|
for arg in argv:
|
||||||
|
if not arg:
|
||||||
|
continue
|
||||||
|
if pending:
|
||||||
|
take(pending, arg)
|
||||||
|
pending = None
|
||||||
|
continue
|
||||||
|
if arg in TEXT_OPTS:
|
||||||
|
pending = "text"
|
||||||
|
elif arg in FILE_OPTS:
|
||||||
|
pending = "file"
|
||||||
|
elif arg in COMMIT_OPTS:
|
||||||
|
pending = "commit"
|
||||||
|
elif arg.startswith(TEXT_OPTS_EQ):
|
||||||
|
take("text", arg.split("=", 1)[1])
|
||||||
|
elif arg.startswith(FILE_OPTS_EQ):
|
||||||
|
take("file", arg.split("=", 1)[1])
|
||||||
|
elif arg.startswith(COMMIT_OPTS_EQ):
|
||||||
|
take("commit", arg.split("=", 1)[1])
|
||||||
|
elif len(arg) > 1 and arg[0] == "-" and arg[1] != "-":
|
||||||
|
# clustered short flags, e.g. -am "text" or -am"text"
|
||||||
|
for kind, letter in (("text", "m"), ("file", "F"), ("commit", "C")):
|
||||||
|
idx = arg.find(letter, 1)
|
||||||
|
if idx == -1:
|
||||||
|
continue
|
||||||
|
if idx + 1 < len(arg):
|
||||||
|
take(kind, arg[idx + 1:])
|
||||||
|
else:
|
||||||
|
pending = kind
|
||||||
|
break
|
||||||
|
|
||||||
|
return "\n".join(texts)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_comments(text: str) -> str:
|
||||||
|
return " ".join(
|
||||||
|
line for line in text.splitlines() if not line.lstrip().startswith("#")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_word_reason(root: Path) -> str | None:
|
||||||
|
if os.environ.get("NO_BUMP") or os.environ.get("GIT_NO_BUMP"):
|
||||||
|
return "NO_BUMP is set"
|
||||||
|
match = STOP_RE.search(strip_comments(commit_message_text(root)))
|
||||||
|
if match:
|
||||||
|
return f"commit message contains {match.group(0)!r}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def merge_or_rebase_in_progress(gitdir: Path) -> bool:
|
||||||
|
return any(
|
||||||
|
(gitdir / name).exists()
|
||||||
|
for name in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
root = repo_root()
|
||||||
|
gitdir = git_dir()
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(f"pre-commit: {exc}", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if merge_or_rebase_in_progress(gitdir):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
reason = stop_word_reason(root)
|
||||||
|
if reason:
|
||||||
|
print(f"pre-commit: {reason}, keeping version unchanged")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
path = root / PLUGIN_FILE
|
||||||
|
if not path.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
print(f"pre-commit: {PLUGIN_FILE} is not valid JSON: {exc}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
current = str(data.get("version", ""))
|
||||||
|
previous = head_version(root)
|
||||||
|
if previous is not None and current != previous:
|
||||||
|
print(
|
||||||
|
f"pre-commit: version already changed ({previous} -> {current}), keeping it"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_version = bump_patch(current)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"pre-commit: {exc}, keeping version unchanged", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
data["version"] = new_version
|
||||||
|
trailing = "\n" if raw.endswith("\n") else ""
|
||||||
|
path.write_text(json.dumps(data, indent=2) + trailing, encoding="utf-8")
|
||||||
|
|
||||||
|
# Stage into the index git is actually building for this commit. For
|
||||||
|
# `git commit -- <pathspec>` that is a temporary index named by
|
||||||
|
# GIT_INDEX_FILE, so honouring the inherited environment is what makes the
|
||||||
|
# bump part of THIS commit.
|
||||||
|
add = subprocess.run(["git", "add", "--", PLUGIN_FILE], cwd=root)
|
||||||
|
if add.returncode != 0:
|
||||||
|
print(f"pre-commit: failed to stage {PLUGIN_FILE}", file=sys.stderr)
|
||||||
|
return add.returncode
|
||||||
|
|
||||||
|
# The real index may still hold the pre-bump blob after a pathspec commit;
|
||||||
|
# scripts/post-commit refreshes it once git releases index.lock.
|
||||||
|
|
||||||
|
print(f"pre-commit: version {current} -> {new_version}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable → Regular
+46
@@ -2,6 +2,9 @@
|
|||||||
"""Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file.
|
"""Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file.
|
||||||
|
|
||||||
Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check]
|
Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check]
|
||||||
|
|
||||||
|
Also enables the repo's git hooks (scripts/pre-commit, scripts/post-commit) on
|
||||||
|
first run; see ensure_git_hooks().
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -9,13 +12,18 @@ import argparse
|
|||||||
import ast
|
import ast
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import zlib
|
import zlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
SRC_DIR = REPO_ROOT / "src"
|
SRC_DIR = REPO_ROOT / "src"
|
||||||
|
HOOKS_DIR = "scripts"
|
||||||
|
HOOK_NAMES = ("pre-commit", "post-commit")
|
||||||
PLUGIN_JSON = REPO_ROOT / "plugin.json"
|
PLUGIN_JSON = REPO_ROOT / "plugin.json"
|
||||||
|
|
||||||
RECOGNIZED_KEYS = {
|
RECOGNIZED_KEYS = {
|
||||||
@@ -300,6 +308,43 @@ def assemble_bundle(data: dict, modules: dict, release: bool) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_git_hooks() -> None:
|
||||||
|
"""Point git at scripts/ for hooks, once per clone.
|
||||||
|
|
||||||
|
Git never enables hooks from repository content, so a fresh clone (or a
|
||||||
|
repo created from the template) starts without them. Every workflow runs
|
||||||
|
this bundler first, directly or via scripts/deploy.sh and scripts/watch.sh,
|
||||||
|
which makes it the one place that can switch them on without an extra
|
||||||
|
setup step. Skipped in CI, outside a git work tree, and when
|
||||||
|
core.hooksPath is already set (including to something else on purpose).
|
||||||
|
Never fails the build.
|
||||||
|
"""
|
||||||
|
if os.environ.get("CI"):
|
||||||
|
return
|
||||||
|
hooks = [REPO_ROOT / HOOKS_DIR / name for name in HOOK_NAMES]
|
||||||
|
if not any(h.is_file() for h in hooks):
|
||||||
|
return
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.run(["git", *args], cwd=REPO_ROOT, capture_output=True, text=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
top = git("rev-parse", "--show-toplevel")
|
||||||
|
if top.returncode != 0 or Path(top.stdout.strip()).resolve() != REPO_ROOT:
|
||||||
|
return
|
||||||
|
if git("config", "--get", "core.hooksPath").stdout.strip():
|
||||||
|
return
|
||||||
|
# Git silently skips hooks that are not executable.
|
||||||
|
for hook in hooks:
|
||||||
|
if hook.is_file():
|
||||||
|
mode = hook.stat().st_mode
|
||||||
|
hook.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
if git("config", "core.hooksPath", HOOKS_DIR).returncode == 0:
|
||||||
|
print(f"enabled git hooks from {HOOKS_DIR}/ (git config core.hooksPath {HOOKS_DIR})", file=sys.stderr)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv):
|
def parse_args(argv):
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Bundle a multi-file exteraGram plugin into a single .plugin file."
|
description="Bundle a multi-file exteraGram plugin into a single .plugin file."
|
||||||
@@ -318,6 +363,7 @@ def parse_args(argv):
|
|||||||
|
|
||||||
def main(argv=None) -> int:
|
def main(argv=None) -> int:
|
||||||
args = parse_args(sys.argv[1:] if argv is None else argv)
|
args = parse_args(sys.argv[1:] if argv is None else argv)
|
||||||
|
ensure_git_hooks()
|
||||||
release = bool(args.release)
|
release = bool(args.release)
|
||||||
try:
|
try:
|
||||||
data = load_metadata()
|
data = load_metadata()
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Renders images/icon.svg to a square PNG (512x512 by default).
|
||||||
|
|
||||||
|
Stdlib only: the actual rasterising is delegated to the first renderer found,
|
||||||
|
in this order: the cairosvg Python package, rsvg-convert (librsvg),
|
||||||
|
ImageMagick (magick/convert), Inkscape. Install any one of them.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
tools/icon_png.py # images/icon.svg -> images/icon.png, 512x512
|
||||||
|
tools/icon_png.py --resolution 1024 # 1024x1024
|
||||||
|
tools/icon_png.py other.svg -o out.png # explicit input/output
|
||||||
|
|
||||||
|
The output is always PNG, always <resolution>x<resolution>.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
DEFAULT_SVG = REPO_ROOT / "images" / "icon.svg"
|
||||||
|
DEFAULT_RESOLUTION = 512
|
||||||
|
|
||||||
|
|
||||||
|
def fail(message: str) -> "None":
|
||||||
|
print(f"error: {message}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def cli_command(svg: Path, png: Path, size: int) -> Optional[List[str]]:
|
||||||
|
"""Returns the command line for the first installed CLI renderer, if any."""
|
||||||
|
if shutil.which("rsvg-convert"):
|
||||||
|
return ["rsvg-convert", "-w", str(size), "-h", str(size), "-f", "png",
|
||||||
|
"-o", str(png), str(svg)]
|
||||||
|
magick = shutil.which("magick") or shutil.which("convert")
|
||||||
|
if magick:
|
||||||
|
# A high density rasterises the vector sharply before scaling down;
|
||||||
|
# "!" forces the exact size.
|
||||||
|
return [magick, "-background", "none", "-density", "1200", str(svg),
|
||||||
|
"-resize", f"{size}x{size}!", f"png:{png}"]
|
||||||
|
if shutil.which("inkscape"):
|
||||||
|
return ["inkscape", str(svg), "--export-type=png", f"--export-filename={png}",
|
||||||
|
"-w", str(size), "-h", str(size)]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def render(svg: Path, png: Path, size: int) -> str:
|
||||||
|
"""Renders svg into png at size x size, returning the renderer's name."""
|
||||||
|
try:
|
||||||
|
import cairosvg # type: ignore[import-not-found]
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
cairosvg.svg2png(url=str(svg), write_to=str(png),
|
||||||
|
output_width=size, output_height=size)
|
||||||
|
return "cairosvg"
|
||||||
|
|
||||||
|
command = cli_command(svg, png, size)
|
||||||
|
if command is None:
|
||||||
|
fail("no SVG renderer found; install one of: cairosvg (pip), "
|
||||||
|
"rsvg-convert (librsvg), ImageMagick, Inkscape")
|
||||||
|
result = subprocess.run(command, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
fail(f"{Path(command[0]).name} failed:\n{result.stderr.strip()}")
|
||||||
|
return Path(command[0]).name
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Render an SVG icon to a square PNG.")
|
||||||
|
parser.add_argument("svg", nargs="?", type=Path, default=DEFAULT_SVG,
|
||||||
|
help="input SVG (default: images/icon.svg)")
|
||||||
|
parser.add_argument("-r", "--resolution", type=int, default=DEFAULT_RESOLUTION,
|
||||||
|
help=f"width and height in pixels (default: {DEFAULT_RESOLUTION})")
|
||||||
|
parser.add_argument("-o", "--output", type=Path,
|
||||||
|
help="output PNG (default: the input path with a .png suffix)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.resolution <= 0:
|
||||||
|
fail("--resolution must be a positive integer")
|
||||||
|
if not args.svg.is_file():
|
||||||
|
fail(f"{args.svg} not found")
|
||||||
|
png = args.output or args.svg.with_suffix(".png")
|
||||||
|
if png.suffix.lower() != ".png":
|
||||||
|
fail(f"output must be a .png file, got {png}")
|
||||||
|
png.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
renderer = render(args.svg, png, args.resolution)
|
||||||
|
print(f"{png} ({args.resolution}x{args.resolution}, via {renderer})")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user