pasteup 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.
pasteup/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Pasteup: turn a folder of clip art into listing images."""
2
+
3
+ __version__ = "0.1.0"
pasteup/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from pasteup.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
pasteup/cli.py ADDED
@@ -0,0 +1,171 @@
1
+ """The `pasteup` command: start the app and open it in the browser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shutil
9
+ import socket
10
+ import sys
11
+ import threading
12
+ import time
13
+ import urllib.request
14
+ import webbrowser
15
+
16
+ from pasteup import __version__
17
+
18
+ LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1")
19
+ WILDCARD_HOSTS = {"0.0.0.0": "127.0.0.1", "::": "::1"}
20
+ DEFAULT_PORT = 8000
21
+ # Ports tried after the default when another program holds it.
22
+ FALLBACK_PORTS = 10
23
+ BROWSER_WAIT_SECONDS = 20
24
+
25
+ # Asking the local server must not go through a proxy set in the environment.
26
+ _local_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> None:
30
+ parser = argparse.ArgumentParser(
31
+ prog="pasteup",
32
+ description="Turn a folder of clip art into listing images. Starts the app and opens it in your browser.",
33
+ )
34
+ parser.add_argument("--host", default="127.0.0.1", help="address to listen on (default: 127.0.0.1)")
35
+ parser.add_argument(
36
+ "--port", type=_port_number, help=f"port to listen on (default: {DEFAULT_PORT}, or the next free one)"
37
+ )
38
+ parser.add_argument("--no-browser", action="store_true", help="don't open the app in a browser")
39
+ parser.add_argument(
40
+ "--shortcut", action="store_true", help="put a Pasteup launcher on your desktop, then exit"
41
+ )
42
+ parser.add_argument("--reload", action="store_true", help="restart on code changes, for development")
43
+ parser.add_argument("--version", action="version", version=f"pasteup {__version__}")
44
+ args = parser.parse_args(argv)
45
+
46
+ if args.shortcut:
47
+ from pasteup.shortcut import create_shortcut
48
+
49
+ print(create_shortcut())
50
+ return
51
+
52
+ if args.host not in LOCAL_HOSTS:
53
+ print(
54
+ f"Warning: listening on {args.host} lets other machines reach an app that reads and "
55
+ "writes your files, with no login. See SECURITY.md before doing this.",
56
+ file=sys.stderr,
57
+ )
58
+
59
+ visit_host = WILDCARD_HOSTS.get(args.host, args.host)
60
+ open_browser = not args.no_browser and _can_open_browser(visit_host)
61
+ port, state = _choose_port(visit_host, args.port)
62
+ url = _base_url(visit_host, port)
63
+
64
+ if state == "pasteup":
65
+ print(f"Pasteup is already running at {url}")
66
+ if open_browser:
67
+ webbrowser.open(url)
68
+ return
69
+
70
+ if port != (args.port or DEFAULT_PORT):
71
+ print(f"Port {DEFAULT_PORT} is taken by another program, so Pasteup is using {port}.")
72
+ print(f"Pasteup is running at {url}")
73
+ if open_browser:
74
+ print("Opening it in your browser...")
75
+ _open_when_ready(url)
76
+ else:
77
+ print("Open that address in your browser.")
78
+ print("Keep this window open while you use Pasteup; press Ctrl+C to stop it.")
79
+ if shutil.which("ffmpeg") is None:
80
+ print(
81
+ "Note: preview videos need ffmpeg (https://ffmpeg.org), which isn't installed. "
82
+ "Everything else works."
83
+ )
84
+ sys.stdout.flush()
85
+
86
+ # Imported here so `pasteup --help` and `--version` stay instant.
87
+ import uvicorn
88
+
89
+ uvicorn.run(
90
+ "pasteup.main:app",
91
+ host=args.host,
92
+ port=port,
93
+ reload=args.reload,
94
+ # Errors only, unless developing: request logs mean nothing to a user.
95
+ log_level="info" if args.reload else "warning",
96
+ )
97
+ print("Pasteup has stopped.")
98
+
99
+
100
+ def _port_number(value: str) -> int:
101
+ port = int(value)
102
+ if not 1 <= port <= 65535:
103
+ raise argparse.ArgumentTypeError("must be between 1 and 65535")
104
+ return port
105
+
106
+
107
+ def _choose_port(host: str, requested_port: int | None) -> tuple[int, str]:
108
+ """Return the port to use, and "pasteup" when Pasteup already runs there."""
109
+ if requested_port is not None:
110
+ state = _probe(host, requested_port)
111
+ if state == "busy":
112
+ sys.exit(f"Port {requested_port} is in use by another program. Choose a different one with --port.")
113
+ return requested_port, state
114
+
115
+ for port in range(DEFAULT_PORT, DEFAULT_PORT + FALLBACK_PORTS + 1):
116
+ state = _probe(host, port)
117
+ if state != "busy":
118
+ return port, state
119
+ sys.exit(
120
+ f"Ports {DEFAULT_PORT} to {DEFAULT_PORT + FALLBACK_PORTS} are all in use by other programs. "
121
+ "Choose a free one with --port."
122
+ )
123
+
124
+
125
+ def _probe(host: str, port: int) -> str:
126
+ """Say whether a port is "free", has Pasteup on it ("pasteup") or another program ("busy")."""
127
+ try:
128
+ with socket.create_connection((host, port), timeout=0.5):
129
+ pass
130
+ except OSError:
131
+ return "free"
132
+ return "pasteup" if _answers_as_pasteup(_base_url(host, port)) else "busy"
133
+
134
+
135
+ def _answers_as_pasteup(url: str) -> bool:
136
+ try:
137
+ with _local_opener.open(f"{url}api/jobs/health", timeout=2) as response:
138
+ return json.load(response).get("app") == "pasteup"
139
+ except (OSError, ValueError, AttributeError):
140
+ return False
141
+
142
+
143
+ def _open_when_ready(url: str) -> None:
144
+ """Open the browser once the server answers, without holding up its start."""
145
+ threading.Thread(target=_wait_then_open, args=(url,), daemon=True).start()
146
+
147
+
148
+ def _wait_then_open(url: str, timeout: float = BROWSER_WAIT_SECONDS) -> None:
149
+ deadline = time.monotonic() + timeout
150
+ while time.monotonic() < deadline:
151
+ if _answers_as_pasteup(url):
152
+ webbrowser.open(url)
153
+ return
154
+ time.sleep(0.25)
155
+
156
+
157
+ def _can_open_browser(host: str) -> bool:
158
+ # A browser on this machine can reach only a server listening on it.
159
+ if host not in LOCAL_HOSTS:
160
+ return False
161
+ if os.environ.get("BROWSER"):
162
+ return True
163
+ # Without a display, Python falls back to text browsers such as lynx, which
164
+ # would take over this terminal.
165
+ if sys.platform.startswith(("linux", "freebsd", "openbsd", "netbsd")):
166
+ return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
167
+ return True
168
+
169
+
170
+ def _base_url(host: str, port: int) -> str:
171
+ return f"http://[{host}]:{port}/" if ":" in host else f"http://{host}:{port}/"
pasteup/main.py ADDED
@@ -0,0 +1,34 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from fastapi import FastAPI
5
+ from fastapi.middleware.trustedhost import TrustedHostMiddleware
6
+ from fastapi.staticfiles import StaticFiles
7
+
8
+ from pasteup import __version__
9
+ from pasteup.routes.jobs import router as jobs_router
10
+ from pasteup.routes.ui import router as ui_router
11
+
12
+ APP_DIR = Path(__file__).resolve().parent
13
+
14
+ # The app reads and writes local paths and has no authentication, so it answers
15
+ # only requests addressed to this machine. Checking Host stops a web page whose
16
+ # domain has been re-pointed at 127.0.0.1 (DNS rebinding) from driving the app
17
+ # through the user's browser. Extra names, comma-separated, are for serving the
18
+ # app deliberately under another host.
19
+ ALLOWED_HOSTS_ENV_VAR = "PASTEUP_ALLOWED_HOSTS"
20
+ DEFAULT_ALLOWED_HOSTS = ("localhost", "127.0.0.1", "[::1]")
21
+
22
+
23
+ def allowed_hosts() -> list[str]:
24
+ extra_hosts = os.environ.get(ALLOWED_HOSTS_ENV_VAR, "").split(",")
25
+ return [*DEFAULT_ALLOWED_HOSTS, *(host.strip() for host in extra_hosts if host.strip())]
26
+
27
+
28
+ app = FastAPI(title="Pasteup", version=__version__)
29
+
30
+ app.add_middleware(TrustedHostMiddleware, allowed_hosts=allowed_hosts())
31
+ app.mount("/static", StaticFiles(directory=APP_DIR / "static"), name="static")
32
+
33
+ app.include_router(ui_router)
34
+ app.include_router(jobs_router, prefix="/api")
File without changes
@@ -0,0 +1,410 @@
1
+ from pathlib import Path
2
+ from typing import Literal
3
+
4
+ from mullion import DEFAULT_TOLERANCE, BackgroundCleanMode
5
+ from pasteup.services.cover_collage import CoverCollageFittingMode, CoverCollageTemplate
6
+ from mullion import DEFAULT_DPI
7
+
8
+ from pydantic import AliasChoices, BaseModel, Field, field_validator
9
+
10
+ # The preview is encoded at a real frame rate, so its length sets the encoding
11
+ # work. Two minutes is well past any listing site's limit (Etsy's is 15 s).
12
+ MAX_VIDEO_DURATION_SECONDS = 120
13
+
14
+
15
+ def _normalize_optional_string_list(value: object, *, field_name: str) -> list[str] | None:
16
+ if value is None:
17
+ return None
18
+ if not isinstance(value, list):
19
+ raise ValueError(f"{field_name} must be a list of strings.")
20
+
21
+ normalized_values: list[str] = []
22
+ seen_values: set[str] = set()
23
+ for item in value:
24
+ if not isinstance(item, str):
25
+ raise ValueError(f"{field_name} must contain only strings.")
26
+ normalized_item = item.strip()
27
+ if not normalized_item or normalized_item in seen_values:
28
+ continue
29
+ seen_values.add(normalized_item)
30
+ normalized_values.append(normalized_item)
31
+
32
+ return normalized_values
33
+
34
+
35
+ def _validate_video_filename(value: str) -> str:
36
+ """Keep the preview video inside the output folder.
37
+
38
+ The name is joined onto the output folder and ffmpeg overwrites whatever is
39
+ already there, so a path in it would let a request replace any file the user
40
+ can write — ``/Users/me/notes.txt`` or ``../../anything.mp4`` alike.
41
+ """
42
+ name = value.strip()
43
+ if name in {"", ".", ".."} or "\\" in name or Path(name).name != name:
44
+ raise ValueError("video_filename must be a plain file name, without folders.")
45
+ if Path(name).suffix.lower() != ".mp4":
46
+ raise ValueError("video_filename must end in .mp4.")
47
+ return name
48
+
49
+
50
+ class OutputQualityOptions(BaseModel):
51
+ """Print-resolution and background-cleaning options.
52
+
53
+ Shared by every request that produces a page, so the four builders cannot
54
+ drift into supporting different subsets of the same feature.
55
+ """
56
+
57
+ dpi: int = Field(
58
+ default=DEFAULT_DPI,
59
+ gt=0,
60
+ le=1200,
61
+ description=(
62
+ "Resolution stamped into exported files. Without it a 3000x4000 page "
63
+ "reads as 41.7x55.6in at the 72 DPI default rather than 10x13.3in."
64
+ ),
65
+ )
66
+ page_unit: Literal["px", "in", "mm"] = Field(
67
+ default="px",
68
+ description="Unit for page_width/page_height. Converted to pixels at dpi.",
69
+ )
70
+ background_clean_mode: BackgroundCleanMode = Field(
71
+ default=BackgroundCleanMode.OFF,
72
+ description=(
73
+ "'flatten' turns an edge-connected near-white background pure white; "
74
+ "'transparent' makes it transparent; 'off' leaves images untouched."
75
+ ),
76
+ )
77
+ background_tolerance: int = Field(
78
+ default=DEFAULT_TOLERANCE,
79
+ ge=0,
80
+ le=255,
81
+ description="Minimum channel value counted as background. Higher is stricter.",
82
+ )
83
+ background_feather: int = Field(
84
+ default=1,
85
+ ge=0,
86
+ le=25,
87
+ description="Mask softening in pixels; removes halos on anti-aliased edges.",
88
+ )
89
+
90
+ @field_validator("background_clean_mode", mode="before")
91
+ @classmethod
92
+ def normalize_background_clean_mode(cls, value: object) -> object:
93
+ if isinstance(value, str):
94
+ return value.strip().lower()
95
+ return value
96
+
97
+
98
+ class JobStatus(BaseModel):
99
+ status: str
100
+
101
+
102
+ class ScanRequest(BaseModel):
103
+ folder_path: str
104
+
105
+
106
+ class ScanSubfolder(BaseModel):
107
+ display_name: str
108
+ full_path: str
109
+
110
+
111
+ class ScanResponse(BaseModel):
112
+ valid: bool
113
+ image_count: int
114
+ sample_files: list[str]
115
+ estimated_pages: int
116
+ available_subfolders: list[ScanSubfolder] = Field(default_factory=list)
117
+ message: str
118
+
119
+
120
+ class SelectedBatchFolderValidationRequest(BaseModel):
121
+ parent_folder_path: str
122
+ selected_subfolders: list[str] = Field(
123
+ validation_alias=AliasChoices("selected_subfolders", "selected_subfolder_paths"),
124
+ )
125
+
126
+ @field_validator("selected_subfolders", mode="before")
127
+ @classmethod
128
+ def normalize_selected_subfolders(cls, value: object) -> list[str]:
129
+ if not isinstance(value, list):
130
+ raise ValueError("selected_subfolders must be a list of paths.")
131
+
132
+ normalized_paths: list[str] = []
133
+ seen_paths: set[str] = set()
134
+ for item in value:
135
+ if not isinstance(item, str):
136
+ raise ValueError("selected_subfolders must contain only strings.")
137
+ normalized_item = item.strip()
138
+ if not normalized_item or normalized_item in seen_paths:
139
+ continue
140
+ seen_paths.add(normalized_item)
141
+ normalized_paths.append(normalized_item)
142
+
143
+ return normalized_paths
144
+
145
+
146
+ class SelectedBatchFolderScanResult(BaseModel):
147
+ display_name: str
148
+ full_path: str
149
+ valid: bool
150
+ image_count: int
151
+ sample_files: list[str]
152
+ estimated_pages: int
153
+ message: str
154
+
155
+
156
+ class SelectedBatchFolderValidationResponse(BaseModel):
157
+ parent_folder_path: str
158
+ selected_folders: int
159
+ valid_selected_folders: int
160
+ invalid_selected_folders: int
161
+ scanned_folders: list[SelectedBatchFolderScanResult]
162
+ skipped_folders: list[SelectedBatchFolderScanResult]
163
+ message: str
164
+
165
+
166
+ class GenerationRequest(OutputQualityOptions):
167
+ folder_path: str
168
+ output_path: str
169
+ page_width: float = Field(gt=0)
170
+ page_height: float = Field(gt=0)
171
+ gap: float = Field(ge=0)
172
+ margin: float = Field(ge=0)
173
+ format: Literal["PNG", "JPG"]
174
+ include_partial_page: bool = Field(
175
+ default=False,
176
+ description=(
177
+ "Render a final page whose cells are not all filled. Off preserves the "
178
+ "existing behaviour of leaving trailing images out; either way the count "
179
+ "is reported rather than dropped silently."
180
+ ),
181
+ )
182
+ create_video: bool = Field(
183
+ default=False,
184
+ validation_alias=AliasChoices("create_video", "create_preview_video"),
185
+ )
186
+ video_duration_seconds: float = Field(
187
+ default=15.0,
188
+ gt=0,
189
+ le=MAX_VIDEO_DURATION_SECONDS,
190
+ validation_alias=AliasChoices("video_duration_seconds", "duration_seconds"),
191
+ )
192
+ video_transition: Literal["none", "crossfade", "flip"] = Field(
193
+ default="none",
194
+ validation_alias=AliasChoices("video_transition", "transition"),
195
+ )
196
+ video_filename: str = "preview.mp4"
197
+
198
+ @field_validator("format", mode="before")
199
+ @classmethod
200
+ def normalize_format(cls, value: str) -> str:
201
+ if isinstance(value, str):
202
+ return value.upper()
203
+ return value
204
+
205
+ @field_validator("video_filename")
206
+ @classmethod
207
+ def validate_video_filename(cls, value: str) -> str:
208
+ return _validate_video_filename(value)
209
+
210
+
211
+ class GenerationResponse(BaseModel):
212
+ success: bool
213
+ total_images: int
214
+ pages_generated: int
215
+ output_files: list[str]
216
+ output_path: str
217
+ video_file: str | None = None
218
+ video_created: bool = False
219
+ images_dropped: int = 0
220
+ images_skipped: int = 0
221
+ backgrounds_cleaned: int = 0
222
+ page_geometry: str | None = None
223
+ message: str
224
+
225
+
226
+ class CoverCollageGenerationRequest(OutputQualityOptions):
227
+ folder_path: str
228
+ output_path: str
229
+ page_width: float = Field(gt=0)
230
+ page_height: float = Field(gt=0)
231
+ format: Literal["PNG", "JPG"]
232
+ collage_template: CoverCollageTemplate = CoverCollageTemplate.RANDOMIZED_GRID
233
+ image_fit: CoverCollageFittingMode = CoverCollageFittingMode.COVER
234
+ selected_cover_images: list[str] | None = None
235
+
236
+ @field_validator("format", mode="before")
237
+ @classmethod
238
+ def normalize_format(cls, value: str) -> str:
239
+ if isinstance(value, str):
240
+ return value.upper()
241
+ return value
242
+
243
+ @field_validator("selected_cover_images", mode="before")
244
+ @classmethod
245
+ def normalize_selected_cover_images(cls, value: object) -> list[str] | None:
246
+ return _normalize_optional_string_list(value, field_name="selected_cover_images")
247
+
248
+
249
+ class CoverCollageResponse(BaseModel):
250
+ success: bool
251
+ total_images: int
252
+ pages_generated: int
253
+ output_files: list[str]
254
+ output_path: str
255
+ page_geometry: str | None = None
256
+ message: str
257
+
258
+
259
+ class BatchCoverCollageGenerationRequest(OutputQualityOptions):
260
+ parent_folder_path: str
261
+ created_output_root_path: str = Field(
262
+ validation_alias=AliasChoices("created_output_root_path", "output_root_path"),
263
+ )
264
+ selected_subfolders: list[str] = Field(
265
+ validation_alias=AliasChoices("selected_subfolders", "selected_subfolder_paths"),
266
+ )
267
+ page_width: float = Field(gt=0)
268
+ page_height: float = Field(gt=0)
269
+ format: Literal["PNG", "JPG"]
270
+ collage_template: CoverCollageTemplate = CoverCollageTemplate.RANDOMIZED_GRID
271
+ image_fit: CoverCollageFittingMode = CoverCollageFittingMode.COVER
272
+
273
+ @field_validator("selected_subfolders", mode="before")
274
+ @classmethod
275
+ def normalize_selected_subfolders(cls, value: object) -> list[str]:
276
+ if not isinstance(value, list):
277
+ raise ValueError("selected_subfolders must be a list of paths.")
278
+
279
+ normalized_paths: list[str] = []
280
+ seen_paths: set[str] = set()
281
+ for item in value:
282
+ if not isinstance(item, str):
283
+ raise ValueError("selected_subfolders must contain only strings.")
284
+ normalized_item = item.strip()
285
+ if not normalized_item or normalized_item in seen_paths:
286
+ continue
287
+ seen_paths.add(normalized_item)
288
+ normalized_paths.append(normalized_item)
289
+
290
+ return normalized_paths
291
+
292
+ @field_validator("format", mode="before")
293
+ @classmethod
294
+ def normalize_format(cls, value: str) -> str:
295
+ if isinstance(value, str):
296
+ return value.upper()
297
+ return value
298
+
299
+
300
+ class BatchCoverCollageResult(BaseModel):
301
+ folder_name: str
302
+ success: bool
303
+ pages_generated: int
304
+ total_images: int
305
+ output_path: str
306
+ output_files: list[str] = Field(default_factory=list)
307
+ message: str
308
+
309
+
310
+ class BatchCoverCollageGenerationResponse(BaseModel):
311
+ success: bool
312
+ processed_folders: int
313
+ generated_folders: int
314
+ total_detected_folders: int
315
+ selected_folders: int
316
+ bundle_results: list[BatchCoverCollageResult]
317
+ created_output_root_path: str
318
+ message: str
319
+
320
+
321
+ class BatchGenerationRequest(OutputQualityOptions):
322
+ parent_folder_path: str
323
+ created_output_root_path: str
324
+ selected_subfolders: list[str] | None = Field(
325
+ default=None,
326
+ validation_alias=AliasChoices("selected_subfolders", "selected_subfolder_paths"),
327
+ )
328
+ page_width: float = Field(gt=0)
329
+ page_height: float = Field(gt=0)
330
+ gap: float = Field(ge=0)
331
+ margin: float = Field(ge=0)
332
+ format: Literal["PNG", "JPG"]
333
+ include_partial_page: bool = Field(
334
+ default=False,
335
+ description=(
336
+ "Render a final page whose cells are not all filled. Off preserves the "
337
+ "existing behaviour of leaving trailing images out; either way the count "
338
+ "is reported rather than dropped silently."
339
+ ),
340
+ )
341
+ create_video: bool = Field(
342
+ default=False,
343
+ validation_alias=AliasChoices("create_video", "create_preview_video"),
344
+ )
345
+ video_duration_seconds: float = Field(
346
+ default=15.0,
347
+ gt=0,
348
+ le=MAX_VIDEO_DURATION_SECONDS,
349
+ validation_alias=AliasChoices("video_duration_seconds", "duration_seconds"),
350
+ )
351
+ video_transition: Literal["none", "crossfade", "flip"] = Field(
352
+ default="none",
353
+ validation_alias=AliasChoices("video_transition", "transition"),
354
+ )
355
+ video_filename: str = "preview.mp4"
356
+
357
+ @field_validator("selected_subfolders", mode="before")
358
+ @classmethod
359
+ def normalize_selected_subfolders(cls, value: object) -> list[str] | None:
360
+ if value is None:
361
+ return None
362
+ if not isinstance(value, list):
363
+ raise ValueError("selected_subfolders must be a list of paths.")
364
+
365
+ normalized_paths: list[str] = []
366
+ seen_paths: set[str] = set()
367
+ for item in value:
368
+ if not isinstance(item, str):
369
+ raise ValueError("selected_subfolders must contain only strings.")
370
+ normalized_item = item.strip()
371
+ if not normalized_item or normalized_item in seen_paths:
372
+ continue
373
+ seen_paths.add(normalized_item)
374
+ normalized_paths.append(normalized_item)
375
+
376
+ return normalized_paths
377
+
378
+ @field_validator("format", mode="before")
379
+ @classmethod
380
+ def normalize_format(cls, value: str) -> str:
381
+ if isinstance(value, str):
382
+ return value.upper()
383
+ return value
384
+
385
+ @field_validator("video_filename")
386
+ @classmethod
387
+ def validate_video_filename(cls, value: str) -> str:
388
+ return _validate_video_filename(value)
389
+
390
+
391
+ class BatchBundleResult(BaseModel):
392
+ folder_name: str
393
+ success: bool
394
+ pages_generated: int
395
+ total_images: int
396
+ output_path: str
397
+ video_file: str | None = None
398
+ video_created: bool = False
399
+ message: str
400
+
401
+
402
+ class BatchGenerationResponse(BaseModel):
403
+ success: bool
404
+ processed_folders: int
405
+ generated_folders: int
406
+ total_detected_folders: int
407
+ selected_folders: int
408
+ bundle_results: list[BatchBundleResult]
409
+ created_output_root_path: str
410
+ message: str
File without changes