penpot-local-stack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ Metadata-Version: 2.5
2
+ Name: penpot-local-stack
3
+ Version: 0.1.0
4
+ Summary: A disposable local Penpot stack: import a template, draw, export it back
5
+ Project-URL: Repository, https://github.com/oberon-systems/penpot-local-stack
6
+ Project-URL: Issues, https://github.com/oberon-systems/penpot-local-stack/issues
7
+ Author-email: zombig <me@zombig.name>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Operating System :: POSIX
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Multimedia :: Graphics
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: httpx>=0.27
20
+ Requires-Dist: pydantic-settings>=2.4
21
+ Requires-Dist: pydantic>=2
22
+ Requires-Dist: pyyaml>=6
23
+ Requires-Dist: questionary>=2
24
+ Description-Content-Type: text/markdown
25
+
26
+ # penpot-local-stack
27
+
28
+ A disposable [Penpot](https://penpot.app) stack for drawing UI mockups, built
29
+ for AI-driven UI/UX work. Penpot itself is open source; its code is at
30
+ [penpot/penpot](https://github.com/penpot/penpot).
31
+
32
+ The stack exists so a design session leaves nothing behind but the drawing.
33
+ It runs from a Compose file with no persistent state: the database lives in
34
+ tmpfs and goes away with the containers, the profile is created on every
35
+ start, and the only thing that survives is `templates/` in your directory.
36
+ Mockups are stored there as unpacked Penpot exports, JSON plus SVG, so git
37
+ shows readable diffs and an agent can read a mockup directly instead of
38
+ looking at a picture of it.
39
+
40
+ - [Install](#install)
41
+ - [Settings](#settings)
42
+ - [Design loop](#design-loop)
43
+ - [Commands](#commands)
44
+ - [Templates](#templates)
45
+ - [Connect Penpot MCP](#connect-penpot-mcp)
46
+
47
+ ## Install
48
+
49
+ Docker with the Compose plugin and Python 3.12 or newer have to be there
50
+ already, and that `python3` needs `httpx`, `questionary`, `pydantic-settings`
51
+ and `PyYAML`. The installer checks and tells you what is missing; it never
52
+ installs anything itself.
53
+
54
+ Run it in the directory that will hold the mockups:
55
+
56
+ ```bash
57
+ curl -fsSL https://raw.githubusercontent.com/oberon-systems/penpot-local-stack/main/install/install.sh | bash
58
+ ```
59
+
60
+ It downloads the stack and lays it out:
61
+
62
+ ```text
63
+ bin/ libs/ config/ the stack, run by the system python3
64
+ .penpot.yaml settings, read from the directory you run in
65
+ Makefile up, down, import, export, extract, convert
66
+ templates/ unpacked Penpot exports, empty at first
67
+ ```
68
+
69
+ Re-running it replaces `bin/`, `libs/` and `config/`, which is the upgrade,
70
+ and keeps `.penpot.yaml` and `templates/`. `--ref` unfolds a branch or a tag
71
+ instead of `main`. See [install/README.md](install/README.md) for the details.
72
+
73
+ ## Settings
74
+
75
+ The installer copies `.penpot.yaml.example` from the repository to
76
+ `.penpot.yaml` next to the `Makefile`, and every command reads that file from
77
+ the directory it runs in:
78
+
79
+ ```yaml
80
+ templates: templates
81
+ host: 127.0.0.1
82
+ port: 9001
83
+ project: penpot-local
84
+ version: 2.17.2
85
+ ```
86
+
87
+ | Key | Default | What it sets |
88
+ | ----------- | ---------------------- | ------------------------------- |
89
+ | `templates` | `templates` | Where the unpacked exports live |
90
+ | `host` | `127.0.0.1` | The address Penpot binds to |
91
+ | `port` | `9001` | The port Penpot binds to |
92
+ | `project` | `penpot-local` | The Compose project name |
93
+ | `version` | `2.17.2` | The Penpot image tag |
94
+ | `email` | `designer@example.com` | The throwaway profile |
95
+ | `password` | `penpot-local` | Its password |
96
+
97
+ Drop a key to fall back to its default. Every key also answers to an
98
+ environment variable with a `PENPOT_` prefix, and the variable wins over the
99
+ file:
100
+
101
+ ```bash
102
+ PENPOT_PORT=9100 make up
103
+ ```
104
+
105
+ ## Design loop
106
+
107
+ Start the stack and pick a template or `(empty)`:
108
+
109
+ ```bash
110
+ make up
111
+ ```
112
+
113
+ `up` creates a throwaway profile and opens the browser already logged in
114
+ through the printed `/autologin` link. If that session is lost, log in as
115
+ `designer@example.com` with the password `penpot-local`.
116
+
117
+ Draw in the browser at `http://localhost:9001`, or wherever `port` points. When you are done, pick the
118
+ file to export and the template to write it to, then let the stack go down:
119
+
120
+ ```bash
121
+ make down
122
+ ```
123
+
124
+ `down` writes the export into `templates/<name>/` and runs
125
+ `docker compose down -v`, which drops the database and the assets. Pick
126
+ `(skip export)` to throw the work away; the wipe is confirmed first whenever
127
+ the stack still holds a file.
128
+
129
+ ## Commands
130
+
131
+ | Target | What it does |
132
+ | -------------- | --------------------------------------------------------- |
133
+ | `make up` | Start the stack, import a template, open the browser |
134
+ | `make down` | Offer the export, confirm the wipe, stop the stack |
135
+ | `make import` | Import a template into the running stack, after a confirm |
136
+ | `make export` | Export a file into `templates/`, after a confirm |
137
+ | `make extract` | Unpack a `.penpot` file into `templates/` |
138
+ | `make convert` | Pack a template into a `.penpot` file |
139
+
140
+ `import` and `export` work against a running stack, so a template can be
141
+ swapped in or a file saved off without ending the session. Both ask before
142
+ they touch anything: `import` names the template it is about to load, `export`
143
+ says whether it writes a new template or overwrites one that is already there.
144
+
145
+ `extract` and `convert` are the offline pair and need no stack at all.
146
+ `extract` lists the `.penpot` files next to you, asks which template to write
147
+ and unpacks the archive under the same rules as `export`: frame thumbnails are
148
+ dropped, JSON is reformatted, and an archive holding raster images is refused
149
+ with the offending files listed. `convert` goes the other way and packs
150
+ `templates/<name>/` into `<name>.penpot`, which is what you hand to a Penpot
151
+ that is not this one. Both confirm before writing, and say whether they are
152
+ creating something or overwriting it.
153
+
154
+ ## Templates
155
+
156
+ - One directory per template: `manifest.json`, `files/` and `objects/`.
157
+ - Templates are read from `templates/` in the current directory. Set
158
+ `PENPOT_TEMPLATES` to read them from somewhere else.
159
+ - Images and icons must be SVG. An export that holds raster images is refused
160
+ and the offending files are listed, so you can replace them and export
161
+ again.
162
+ - Frame thumbnails are raster renders Penpot rebuilds on its own, so the
163
+ export leaves them out.
164
+ - Import keeps the Penpot file id, so a round trip changes only what you drew.
165
+
166
+ ## Connect Penpot MCP
167
+
168
+ `up` enables MCP for the throwaway profile, issues its key and serves it
169
+ behind the fixed `/mcp/claude` URL, so an agent needs the server added only
170
+ once. Add it, then restart the agent:
171
+
172
+ ```bash
173
+ claude mcp add --transport http penpot http://localhost:9001/mcp/claude
174
+ ```
175
+
176
+ After later `up` runs, reconnect `penpot` from `/mcp` instead of restarting.
177
+ While the stack is down, the server just shows as failed.
178
+
179
+ The MCP plugin runs inside the Penpot browser tab. Keep a file open in the
180
+ workspace while the agent works; a hidden or unloaded tab stops MCP.
@@ -0,0 +1,14 @@
1
+ penpot_stack/config/autologin.conf,sha256=fchV08GGfPkhbXLIX4opQWYnrkGNZTg1hi0f0bfPP0I,154
2
+ penpot_stack/config/compose.yaml,sha256=gvOV6yJmkmvsW-WXhRrSrm9_1YyiZqcZbVbLwW9VE_I,2358
3
+ penpot_stack/__init__.py,sha256=mvRPyADrJna3E_OHJ8kUrIIgt1jcXgn_ZVS2sn0nzh0,87
4
+ penpot_stack/cli.py,sha256=RUUO1wv1zMNZl2Tri0XD0VDHYTvvVxdeOnTAkUyFQ6A,4070
5
+ penpot_stack/compose.py,sha256=oqG1fMgp2Qury_El0XYLaI7VUAjJpQt4GsZPkpDtkN0,1510
6
+ penpot_stack/penpot.py,sha256=eDURPbFXciJz8WFwhN6PJfnktWPt2VueZnUR959brcI,2869
7
+ penpot_stack/prompts.py,sha256=TG1buuhVaZjlySse8Kq3WbufhBuKvpnY2jzr2Snp3Ww,935
8
+ penpot_stack/settings.py,sha256=2r1zD2SZC5CxGwvEbVPzRNTfeYjpXr0Zjs6msqfAeNQ,1456
9
+ penpot_stack/templates.py,sha256=fMSRFINaCqJ8jhgYGvEfxeKaJ54mrcHJpj5eba9hKs4,3079
10
+ penpot_local_stack-0.1.0.dist-info/METADATA,sha256=nyW3ujYqw97TlvDmXyZmvz04D0M_Jmi_q34YjCeOZZ8,7322
11
+ penpot_local_stack-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
12
+ penpot_local_stack-0.1.0.dist-info/entry_points.txt,sha256=V41xUQgSPhMykV3mKO1DHMga7qDBRCuYYIpj0gee36Q,55
13
+ penpot_local_stack-0.1.0.dist-info/licenses/LICENSE,sha256=UH3T6qSdLRq_gdfrPiuNx22uO5I8kRBp8NIKGwzLKuQ,1071
14
+ penpot_local_stack-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ penpot-stack = penpot_stack.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 oberon-systems
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ """Disposable Penpot stack for UI mockups: import a template, draw, export it back."""
penpot_stack/cli.py ADDED
@@ -0,0 +1,135 @@
1
+ """The four commands the stack exposes: up, down, import and export."""
2
+
3
+ import sys
4
+ import webbrowser
5
+ import zipfile
6
+ from pathlib import Path
7
+
8
+ import httpx
9
+ import questionary
10
+
11
+ from . import prompts
12
+ from .compose import compose, publish
13
+ from .penpot import connect, drafts, files, profile, register, rpc, session
14
+ from .settings import settings
15
+ from .templates import archives, load, names, pack, root, save, unpack
16
+
17
+
18
+ def choices(client: httpx.Client) -> list[questionary.Choice]:
19
+ return [questionary.Choice(label, value=value) for label, value in files(client)]
20
+
21
+
22
+ def write(client: httpx.Client, file_id: str) -> bool:
23
+ name = prompts.target(names())
24
+ if name is None:
25
+ return False
26
+ target = root() / name
27
+ known = "Overwrite" if target.exists() else "Export to"
28
+ if not prompts.confirm(f"{known} templates/{name}?"):
29
+ return False
30
+ save(client, file_id, target)
31
+ print(f"exported to {target}")
32
+ return True
33
+
34
+
35
+ def up() -> None:
36
+ template = prompts.source(names(), extra=[prompts.EMPTY])
37
+ if template is None:
38
+ return
39
+ compose("up", "-d", "--wait")
40
+ url = settings().url
41
+ client = connect(wait=300)
42
+ if client is None:
43
+ sys.exit(f"penpot is not answering on {url}")
44
+ publish(register(client))
45
+ if template != prompts.EMPTY:
46
+ load(client, drafts(client), root() / template)
47
+ login = f"{url}/autologin?token={client.cookies['auth-token']}"
48
+ webbrowser.open(login)
49
+ print(f"\nPenpot: {login}")
50
+ print(f"MCP: {url}/mcp/claude")
51
+
52
+
53
+ def down() -> None:
54
+ client = connect(wait=0)
55
+ if client is not None:
56
+ rpc(client, "login-with-password", **profile())
57
+ stored = choices(client)
58
+ if stored:
59
+ file_id = prompts.select("Export file", [prompts.SKIP, *stored])
60
+ if file_id is None:
61
+ return
62
+ if file_id != prompts.SKIP and not write(client, file_id):
63
+ return
64
+ if not prompts.confirm("Drop the stack and everything left in it?"):
65
+ return
66
+ compose("down", "-v")
67
+
68
+
69
+ def export() -> None:
70
+ client = session()
71
+ stored = choices(client)
72
+ if not stored:
73
+ sys.exit("the stack holds no files to export")
74
+ file_id = prompts.select("Export file", stored)
75
+ if file_id is not None:
76
+ write(client, file_id)
77
+
78
+
79
+ def restore() -> None:
80
+ available = names()
81
+ if not available:
82
+ sys.exit(f"no templates in {root()} to import")
83
+ name = prompts.source(available)
84
+ if name is None or not prompts.confirm(f"Import {name} into the stack?"):
85
+ return
86
+ client = session()
87
+ load(client, drafts(client), root() / name)
88
+ print(f"imported {name}")
89
+
90
+
91
+ def extract() -> None:
92
+ found = archives()
93
+ if not found:
94
+ sys.exit("no .penpot files in the current directory")
95
+ source = prompts.select("Penpot file", [questionary.Choice(p.name, value=p) for p in found])
96
+ if source is None:
97
+ return
98
+ name = prompts.target(names())
99
+ if name is None:
100
+ return
101
+ target = root() / name
102
+ known = "Overwrite" if target.exists() else "Extract into"
103
+ if not prompts.confirm(f"{known} templates/{name}?"):
104
+ return
105
+ unpack(zipfile.ZipFile(source), target)
106
+ print(f"extracted to {target}")
107
+
108
+
109
+ def convert() -> None:
110
+ available = names()
111
+ if not available:
112
+ sys.exit(f"no templates in {root()} to convert")
113
+ name = prompts.source(available)
114
+ if name is None:
115
+ return
116
+ target = Path.cwd() / f"{name}.penpot"
117
+ known = "Overwrite" if target.exists() else "Write"
118
+ if not prompts.confirm(f"{known} {target.name}?"):
119
+ return
120
+ target.write_bytes(pack(root() / name))
121
+ print(f"packed into {target}")
122
+
123
+
124
+ def main() -> None:
125
+ commands = {
126
+ "up": up,
127
+ "down": down,
128
+ "import": restore,
129
+ "export": export,
130
+ "extract": extract,
131
+ "convert": convert,
132
+ }
133
+ if len(sys.argv) != 2 or sys.argv[1] not in commands:
134
+ sys.exit(f"usage: {Path(sys.argv[0]).name} {{{'|'.join(commands)}}}")
135
+ commands[sys.argv[1]]()
@@ -0,0 +1,45 @@
1
+ """The shipped Compose definition and the nginx route that publishes MCP."""
2
+
3
+ import os
4
+ import secrets
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from .settings import Settings, settings
9
+
10
+
11
+ def directory() -> Path:
12
+ here = Path(__file__).resolve().parent
13
+ # Installed, the stack files sit inside the package; in a checkout they are next to libs/.
14
+ roots = (here / "config", here.parent / "config")
15
+ return next(p for p in roots if (p / "compose.yaml").is_file())
16
+
17
+
18
+ def environment(config: Settings) -> dict[str, str]:
19
+ return {
20
+ "PENPOT_PROJECT": config.project,
21
+ "PENPOT_HOST": config.host,
22
+ "PENPOT_PORT": str(config.port),
23
+ "PENPOT_VERSION": config.version,
24
+ "PENPOT_URI": config.url,
25
+ "PENPOT_SECRET_KEY": secrets.token_urlsafe(48),
26
+ }
27
+
28
+
29
+ def compose(*args: str) -> None:
30
+ env = {**environment(settings()), **os.environ}
31
+ command = ["docker", "compose", "-f", str(directory() / "compose.yaml"), *args]
32
+ subprocess.run(command, check=True, env=env) # noqa: S603, S607
33
+
34
+
35
+ def publish(key: str) -> None:
36
+ location = (
37
+ "location = /mcp/claude {\n"
38
+ f" rewrite ^ /mcp?userToken={key} break;\n"
39
+ " proxy_pass http://penpot-mcp:4401;\n"
40
+ " proxy_http_version 1.1;\n"
41
+ " proxy_buffering off;\n"
42
+ "}\n"
43
+ )
44
+ script = 'printf "%s" "$1" > /etc/nginx/overrides/server.d/claude-mcp.conf && nginx -s reload'
45
+ compose("exec", "-T", "penpot-frontend", "sh", "-c", script, "sh", location)
@@ -0,0 +1,5 @@
1
+ location = /autologin {
2
+ absolute_redirect off;
3
+ add_header Set-Cookie "auth-token=$arg_token; Path=/; HttpOnly; SameSite=Lax";
4
+ return 302 /;
5
+ }
@@ -0,0 +1,86 @@
1
+ name: ${PENPOT_PROJECT:-penpot-local}
2
+
3
+ x-flags: &flags
4
+ PENPOT_FLAGS: >-
5
+ disable-email-verification disable-secure-session-cookies disable-onboarding
6
+ enable-access-tokens enable-mcp
7
+ x-uri: &uri
8
+ PENPOT_PUBLIC_URI: ${PENPOT_URI:-http://localhost:9001}
9
+ x-secret-key: &secret-key
10
+ PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY}
11
+
12
+ volumes:
13
+ assets:
14
+
15
+ services:
16
+ penpot-frontend:
17
+ image: penpotapp/frontend:${PENPOT_VERSION:-2.17.2}
18
+ ports:
19
+ - "${PENPOT_HOST:-127.0.0.1}:${PENPOT_PORT:-9001}:8080"
20
+ volumes:
21
+ - assets:/opt/data/assets
22
+ - ./autologin.conf:/etc/nginx/overrides/server.d/autologin.conf:ro
23
+ depends_on:
24
+ - penpot-backend
25
+ - penpot-exporter
26
+ - penpot-mcp
27
+ environment:
28
+ <<: [*flags, *uri]
29
+
30
+ penpot-backend:
31
+ image: penpotapp/backend:${PENPOT_VERSION:-2.17.2}
32
+ volumes:
33
+ - assets:/opt/data/assets
34
+ depends_on:
35
+ penpot-postgres:
36
+ condition: service_healthy
37
+ penpot-valkey:
38
+ condition: service_healthy
39
+ environment:
40
+ <<: [*flags, *uri, *secret-key]
41
+ PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot
42
+ PENPOT_DATABASE_USERNAME: penpot
43
+ PENPOT_DATABASE_PASSWORD: penpot
44
+ PENPOT_REDIS_URI: redis://penpot-valkey/0
45
+ PENPOT_OBJECTS_STORAGE_BACKEND: fs
46
+ PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
47
+ PENPOT_TELEMETRY_ENABLED: "false"
48
+
49
+ penpot-mcp:
50
+ image: penpotapp/mcp:${PENPOT_VERSION:-2.17.2}
51
+
52
+ penpot-exporter:
53
+ image: penpotapp/exporter:${PENPOT_VERSION:-2.17.2}
54
+ depends_on:
55
+ penpot-valkey:
56
+ condition: service_healthy
57
+ environment:
58
+ <<: [*uri, *secret-key]
59
+ PENPOT_INTERNAL_URI: http://penpot-frontend:8080
60
+ PENPOT_REDIS_URI: redis://penpot-valkey/0
61
+
62
+ penpot-postgres:
63
+ image: postgres:15
64
+ stop_signal: SIGINT
65
+ tmpfs:
66
+ - /var/lib/postgresql/data
67
+ healthcheck:
68
+ test: [CMD-SHELL, pg_isready -U penpot]
69
+ interval: 2s
70
+ timeout: 10s
71
+ retries: 5
72
+ start_period: 2s
73
+ environment:
74
+ POSTGRES_DB: penpot
75
+ POSTGRES_USER: penpot
76
+ POSTGRES_PASSWORD: penpot
77
+
78
+ penpot-valkey:
79
+ image: valkey/valkey:8.1
80
+ command: [valkey-server, --save, "", --appendonly, "no"]
81
+ healthcheck:
82
+ test: [CMD-SHELL, valkey-cli ping | grep PONG]
83
+ interval: 1s
84
+ timeout: 3s
85
+ retries: 5
86
+ start_period: 3s
penpot_stack/penpot.py ADDED
@@ -0,0 +1,83 @@
1
+ """The Penpot RPC API: the throwaway profile, the session and the calls the commands need."""
2
+
3
+ import sys
4
+ import time
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from .settings import settings
10
+
11
+
12
+ def profile() -> dict[str, str]:
13
+ config = settings()
14
+ return {"email": config.email, "password": config.password}
15
+
16
+
17
+ def rpc(client: httpx.Client, command: str, **params: Any) -> Any:
18
+ response = client.post(f"/api/rpc/command/{command}", json=params)
19
+ if response.is_error:
20
+ sys.exit(f"{command}: {response.status_code} {response.text}")
21
+ return response.json() if response.content else None
22
+
23
+
24
+ def stream(client: httpx.Client, command: str, **kwargs: Any) -> str:
25
+ event = ""
26
+ with client.stream("POST", f"/api/rpc/command/{command}", **kwargs) as response:
27
+ if response.is_error:
28
+ sys.exit(f"{command}: {response.status_code} {response.read().decode()}")
29
+ for line in response.iter_lines():
30
+ if line.startswith("event:"):
31
+ event = line.removeprefix("event:").strip()
32
+ elif line.startswith("data:") and event in ("end", "error"):
33
+ if event == "error":
34
+ sys.exit(f"{command}: {line}")
35
+ return line.removeprefix("data:")
36
+ sys.exit(f"{command}: stream closed without a result")
37
+
38
+
39
+ def connect(wait: float) -> httpx.Client | None:
40
+ client = httpx.Client(
41
+ base_url=settings().url,
42
+ headers={"Accept": "application/json"},
43
+ timeout=60,
44
+ follow_redirects=True,
45
+ )
46
+ deadline = time.monotonic() + wait
47
+ while True:
48
+ try:
49
+ if client.post("/api/rpc/command/get-profile", json={}).is_success:
50
+ return client
51
+ except httpx.TransportError:
52
+ pass
53
+ if time.monotonic() > deadline:
54
+ return None
55
+ time.sleep(2)
56
+
57
+
58
+ def session() -> httpx.Client:
59
+ client = connect(wait=0)
60
+ if client is None:
61
+ sys.exit(f"penpot is not answering on {settings().url}, run `up` first")
62
+ rpc(client, "login-with-password", **profile())
63
+ return client
64
+
65
+
66
+ def register(client: httpx.Client) -> str:
67
+ token = rpc(client, "prepare-register-profile", fullname="Designer", **profile())["token"]
68
+ rpc(client, "register-profile", token=token)
69
+ rpc(client, "update-profile-props", props={"mcpEnabled": True})
70
+ return str(rpc(client, "create-access-token", name="mcp", type="mcp")["token"])
71
+
72
+
73
+ def drafts(client: httpx.Client) -> str:
74
+ projects = rpc(client, "get-all-projects")
75
+ return str(next(p["id"] for p in projects if p["isDefault"] and p["isDefaultTeam"]))
76
+
77
+
78
+ def files(client: httpx.Client) -> list[tuple[str, str]]:
79
+ return [
80
+ (f"{project['name']} / {file['name']}", file["id"])
81
+ for project in rpc(client, "get-all-projects")
82
+ for file in rpc(client, "get-project-files", projectId=project["id"])
83
+ ]
@@ -0,0 +1,34 @@
1
+ """Every question the commands ask, so cancelling one always means the same no."""
2
+
3
+ import re
4
+ from collections.abc import Sequence
5
+ from typing import Any
6
+
7
+ import questionary
8
+
9
+ EMPTY = "(empty)"
10
+ NEW = "(new template)"
11
+ SKIP = "(skip export)"
12
+
13
+
14
+ def valid(name: str) -> bool:
15
+ return bool(re.fullmatch(r"[a-z0-9][a-z0-9-]*", name))
16
+
17
+
18
+ def select(question: str, choices: Sequence[Any]) -> Any | None:
19
+ return questionary.select(question, choices=list(choices)).ask()
20
+
21
+
22
+ def confirm(question: str) -> bool:
23
+ return bool(questionary.confirm(question, default=False).ask())
24
+
25
+
26
+ def source(existing: Sequence[str], extra: Sequence[str] = ()) -> str | None:
27
+ return select("Template", [*extra, *existing])
28
+
29
+
30
+ def target(existing: Sequence[str]) -> str | None:
31
+ choice = select("Template", [NEW, *existing])
32
+ if choice is None or choice != NEW:
33
+ return choice
34
+ return questionary.text("Template name", validate=valid).ask()
@@ -0,0 +1,46 @@
1
+ """Settings: defaults, .penpot.yaml in the working directory, then PENPOT_* variables."""
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic_settings import (
6
+ BaseSettings,
7
+ PydanticBaseSettingsSource,
8
+ SettingsConfigDict,
9
+ YamlConfigSettingsSource,
10
+ )
11
+
12
+ FILE = ".penpot.yaml"
13
+ WILDCARD = ("0.0.0.0", "::") # noqa: S104
14
+
15
+
16
+ class Settings(BaseSettings):
17
+ model_config = SettingsConfigDict(env_prefix="PENPOT_", yaml_file=FILE, extra="forbid")
18
+
19
+ templates: Path = Path("templates")
20
+ host: str = "127.0.0.1"
21
+ port: int = 9001
22
+ project: str = "penpot-local"
23
+ version: str = "2.17.2"
24
+ email: str = "designer@example.com"
25
+ password: str = "penpot-local" # noqa: S105
26
+
27
+ @property
28
+ def url(self) -> str:
29
+ # A wildcard bind is still reached as localhost by the browser opened on this machine.
30
+ host = "localhost" if self.host in (*WILDCARD, "127.0.0.1") else self.host
31
+ return f"http://{host}:{self.port}"
32
+
33
+ @classmethod
34
+ def settings_customise_sources(
35
+ cls,
36
+ settings_cls: type[BaseSettings],
37
+ init_settings: PydanticBaseSettingsSource,
38
+ env_settings: PydanticBaseSettingsSource,
39
+ dotenv_settings: PydanticBaseSettingsSource,
40
+ file_secret_settings: PydanticBaseSettingsSource,
41
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
42
+ return (init_settings, env_settings, YamlConfigSettingsSource(settings_cls))
43
+
44
+
45
+ def settings() -> Settings:
46
+ return Settings()
@@ -0,0 +1,90 @@
1
+ """Templates on disk: unpacked Penpot exports the stack imports from and exports back to."""
2
+
3
+ import io
4
+ import json
5
+ import re
6
+ import shutil
7
+ import sys
8
+ import zipfile
9
+ from pathlib import Path
10
+
11
+ import httpx
12
+
13
+ from .penpot import rpc, stream
14
+ from .settings import settings
15
+
16
+
17
+ def root() -> Path:
18
+ return settings().templates.resolve()
19
+
20
+
21
+ def names() -> list[str]:
22
+ return sorted(p.parent.name for p in root().glob("*/manifest.json"))
23
+
24
+
25
+ def archives() -> list[Path]:
26
+ return sorted(Path.cwd().glob("*.penpot"))
27
+
28
+
29
+ def pack(source: Path) -> bytes:
30
+ buffer = io.BytesIO()
31
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
32
+ for path in sorted(p for p in source.rglob("*") if p.is_file()):
33
+ archive.write(path, path.relative_to(source).as_posix())
34
+ return buffer.getvalue()
35
+
36
+
37
+ def load(client: httpx.Client, project: str, source: Path) -> None:
38
+ file_id = json.loads((source / "manifest.json").read_text())["files"][0]["id"]
39
+ rpc(client, "create-file", id=file_id, name=source.name, projectId=project)
40
+ stream(
41
+ client,
42
+ "import-binfile",
43
+ data={"name": source.name, "project-id": project, "file-id": file_id},
44
+ files={"file": (f"{source.name}.penpot", pack(source), "application/zip")},
45
+ )
46
+
47
+
48
+ def download(client: httpx.Client, file_id: str) -> zipfile.ZipFile:
49
+ result = stream(
50
+ client,
51
+ "export-binfile",
52
+ json={"fileId": file_id, "includeLibraries": False, "embedAssets": True},
53
+ )
54
+ # Transit encodes the link either as a tagged string or as a tagged map, depending on Penpot.
55
+ url = re.search(r'"~r([^"]+)"|"~#uri"\s*:\s*"([^"]+)"', result)
56
+ if url is None:
57
+ sys.exit(f"export-binfile: no download link in {result}")
58
+ response = client.get(url.group(1) or url.group(2))
59
+ response.raise_for_status()
60
+ return zipfile.ZipFile(io.BytesIO(response.content))
61
+
62
+
63
+ def entries(archive: zipfile.ZipFile) -> list[str]:
64
+ # Frame thumbnails are raster renders Penpot regenerates, so they never reach the template.
65
+ thumbnails = [n for n in archive.namelist() if "/thumbnails/" in n]
66
+ objects = {f"objects/{json.loads(archive.read(n))['mediaId']}." for n in thumbnails}
67
+ return [
68
+ n
69
+ for n in archive.namelist()
70
+ if n not in thumbnails and not n.startswith(tuple(objects)) and not n.endswith("/")
71
+ ]
72
+
73
+
74
+ def unpack(archive: zipfile.ZipFile, target: Path) -> None:
75
+ kept = entries(archive)
76
+ raster = [n for n in kept if not n.endswith((".json", ".svg"))]
77
+ if raster:
78
+ sys.exit("only SVG images are allowed, replace these:\n " + "\n ".join(raster))
79
+ shutil.rmtree(target, ignore_errors=True)
80
+ for name in kept:
81
+ path = target / name
82
+ path.parent.mkdir(parents=True, exist_ok=True)
83
+ content = archive.read(name)
84
+ if name.endswith(".json"):
85
+ content = (json.dumps(json.loads(content), indent=2) + "\n").encode()
86
+ path.write_bytes(content)
87
+
88
+
89
+ def save(client: httpx.Client, file_id: str, target: Path) -> None:
90
+ unpack(download(client, file_id), target)