typst-pkg-sync 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {typst_pkg_sync-0.2.0 → typst_pkg_sync-0.2.1}/PKG-INFO +1 -1
- {typst_pkg_sync-0.2.0 → typst_pkg_sync-0.2.1}/pyproject.toml +1 -1
- {typst_pkg_sync-0.2.0 → typst_pkg_sync-0.2.1}/pyproject.toml.orig +1 -1
- {typst_pkg_sync-0.2.0 → typst_pkg_sync-0.2.1}/src/typst_pkg_sync/cli.py +80 -66
- {typst_pkg_sync-0.2.0 → typst_pkg_sync-0.2.1}/LICENSE +0 -0
- {typst_pkg_sync-0.2.0 → typst_pkg_sync-0.2.1}/README.md +0 -0
- {typst_pkg_sync-0.2.0 → typst_pkg_sync-0.2.1}/src/typst_pkg_sync/__init__.py +0 -0
- {typst_pkg_sync-0.2.0 → typst_pkg_sync-0.2.1}/src/typst_pkg_sync/__main__.py +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
typst-pkg-sync
|
|
2
|
+
typst-pkg-sync - Local Package Manager for Typst
|
|
3
3
|
|
|
4
4
|
Downloads tagged source files from GitHub, GitLab, and Codeberg
|
|
5
5
|
and installs them into the local Typst package directory structure:
|
|
@@ -17,6 +17,7 @@ No third-party dependencies.
|
|
|
17
17
|
import argparse
|
|
18
18
|
import http.client
|
|
19
19
|
import json
|
|
20
|
+
import logging
|
|
20
21
|
import os
|
|
21
22
|
import re
|
|
22
23
|
import shutil
|
|
@@ -29,6 +30,7 @@ import urllib.parse
|
|
|
29
30
|
import urllib.request
|
|
30
31
|
import zipfile
|
|
31
32
|
from pathlib import Path
|
|
33
|
+
from typing import ClassVar
|
|
32
34
|
|
|
33
35
|
# ---------------------------------------------------------------------------
|
|
34
36
|
# Constants
|
|
@@ -71,7 +73,7 @@ PLATFORMS = {
|
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
DEFAULT_CONFIG = """\
|
|
74
|
-
# typst-pkg-sync
|
|
76
|
+
# typst-pkg-sync - Local Package Manager for Typst
|
|
75
77
|
#
|
|
76
78
|
# Packages are installed to:
|
|
77
79
|
# <base_dir>/<namespace>/<package-name>/<version>/
|
|
@@ -109,21 +111,30 @@ tags = "all"
|
|
|
109
111
|
# Logging
|
|
110
112
|
# ---------------------------------------------------------------------------
|
|
111
113
|
|
|
114
|
+
_LOGGER = logging.getLogger(__name__)
|
|
112
115
|
|
|
113
|
-
def log_info(msg: str) -> None:
|
|
114
|
-
print(f"\033[32m[INFO]\033[0m {msg}")
|
|
115
116
|
|
|
117
|
+
class _ColorFormatter(logging.Formatter):
|
|
118
|
+
FORMATS: ClassVar[dict[int, tuple[str, str, str]]] = {
|
|
119
|
+
logging.DEBUG: ("\033[34m", "DEBUG", " "),
|
|
120
|
+
logging.INFO: ("\033[32m", "INFO", " "),
|
|
121
|
+
logging.WARNING: ("\033[33m", "WARN", " "),
|
|
122
|
+
logging.ERROR: ("\033[31m", "ERROR", " "),
|
|
123
|
+
}
|
|
116
124
|
|
|
117
|
-
def
|
|
118
|
-
|
|
125
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
126
|
+
color, level, padding = self.FORMATS.get(record.levelno, ("", record.levelname, " "))
|
|
127
|
+
reset = "\033[0m" if color else ""
|
|
128
|
+
return f"{color}[{level}]{reset}{padding}{record.getMessage()}"
|
|
119
129
|
|
|
120
130
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
def configure_logging(verbose: bool = True) -> None:
|
|
132
|
+
"""Configure colored console logging at the requested verbosity."""
|
|
133
|
+
_LOGGER.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
134
|
+
handler = logging.StreamHandler(sys.stdout)
|
|
135
|
+
handler.setFormatter(_ColorFormatter())
|
|
136
|
+
_LOGGER.addHandler(handler)
|
|
137
|
+
_LOGGER.propagate = False
|
|
127
138
|
|
|
128
139
|
|
|
129
140
|
# ---------------------------------------------------------------------------
|
|
@@ -158,18 +169,20 @@ def validate_tags(tags: object, repo: str) -> list[str] | str:
|
|
|
158
169
|
if isinstance(tags, str):
|
|
159
170
|
if tags == "all":
|
|
160
171
|
return "all"
|
|
161
|
-
|
|
172
|
+
_LOGGER.error(
|
|
173
|
+
f'[{repo}] tags must be an array of versions or the string "all", got: "{tags}"'
|
|
174
|
+
)
|
|
162
175
|
sys.exit(1)
|
|
163
176
|
|
|
164
177
|
if isinstance(tags, list):
|
|
165
178
|
if not tags:
|
|
166
|
-
|
|
179
|
+
_LOGGER.error(f"[{repo}] tags array is empty")
|
|
167
180
|
sys.exit(1)
|
|
168
181
|
normalized = []
|
|
169
182
|
for t in tags:
|
|
170
183
|
version = normalize_tag(t) if isinstance(t, str) else None
|
|
171
184
|
if version is None:
|
|
172
|
-
|
|
185
|
+
_LOGGER.error(
|
|
173
186
|
f"[{repo}] invalid tag {t!r}: tags must consist of an "
|
|
174
187
|
f"optional 'v' prefix followed by digits and dots "
|
|
175
188
|
f'(e.g. "0.2.0" or "v1.0.0")'
|
|
@@ -178,7 +191,7 @@ def validate_tags(tags: object, repo: str) -> list[str] | str:
|
|
|
178
191
|
normalized.append(version)
|
|
179
192
|
return normalized
|
|
180
193
|
|
|
181
|
-
|
|
194
|
+
_LOGGER.error(f'[{repo}] tags must be an array of versions or "all"')
|
|
182
195
|
sys.exit(1)
|
|
183
196
|
|
|
184
197
|
|
|
@@ -196,12 +209,12 @@ def filter_remote_tags(raw_tags: list[str], owner: str, repo: str) -> list[str]:
|
|
|
196
209
|
for raw in raw_tags:
|
|
197
210
|
version = normalize_tag(raw)
|
|
198
211
|
if version is None:
|
|
199
|
-
|
|
212
|
+
_LOGGER.warning(f"[{owner}/{repo}] skipping non-version tag: {raw!r}")
|
|
200
213
|
continue
|
|
201
214
|
if version in seen:
|
|
202
|
-
|
|
215
|
+
_LOGGER.warning(
|
|
203
216
|
f"[{owner}/{repo}] tag {raw!r} maps to version {version!r}, "
|
|
204
|
-
f"which was already seen
|
|
217
|
+
f"which was already seen - skipping duplicate"
|
|
205
218
|
)
|
|
206
219
|
continue
|
|
207
220
|
seen.add(version)
|
|
@@ -218,8 +231,8 @@ def filter_remote_tags(raw_tags: list[str], owner: str, repo: str) -> list[str]:
|
|
|
218
231
|
def create_default_config(config_file: Path) -> None:
|
|
219
232
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
220
233
|
config_file.write_text(DEFAULT_CONFIG)
|
|
221
|
-
|
|
222
|
-
|
|
234
|
+
_LOGGER.info(f"Created default config at {config_file}")
|
|
235
|
+
_LOGGER.info("Edit it to add your repositories, then re-run.")
|
|
223
236
|
sys.exit(0)
|
|
224
237
|
|
|
225
238
|
|
|
@@ -230,7 +243,7 @@ def load_config(config_file: Path = CONFIG_FILE) -> dict:
|
|
|
230
243
|
with open(config_file, "rb") as f:
|
|
231
244
|
return tomllib.load(f)
|
|
232
245
|
except tomllib.TOMLDecodeError as e:
|
|
233
|
-
|
|
246
|
+
_LOGGER.error(f"Invalid TOML in {config_file}: {e}")
|
|
234
247
|
sys.exit(1)
|
|
235
248
|
|
|
236
249
|
|
|
@@ -241,23 +254,23 @@ def resolve_platform_config(platform: str, repo_cfg: dict) -> dict | None:
|
|
|
241
254
|
|
|
242
255
|
token_env = repo_cfg.get("token_env", plat["auth_env"])
|
|
243
256
|
if not isinstance(token_env, str) or not token_env:
|
|
244
|
-
|
|
257
|
+
_LOGGER.error(f"Invalid token_env for repository '{repo_cfg.get('repo', '')}'")
|
|
245
258
|
return None
|
|
246
259
|
plat["auth_env"] = token_env
|
|
247
260
|
|
|
248
261
|
if "base_url" in repo_cfg:
|
|
249
262
|
if platform != "gitlab":
|
|
250
|
-
|
|
263
|
+
_LOGGER.error("The 'base_url' option is only supported for GitLab repositories")
|
|
251
264
|
return None
|
|
252
265
|
base_url = repo_cfg["base_url"]
|
|
253
266
|
if not isinstance(base_url, str) or not base_url or base_url != base_url.strip():
|
|
254
|
-
|
|
267
|
+
_LOGGER.error(f"Invalid GitLab base_url: {base_url!r}")
|
|
255
268
|
return None
|
|
256
269
|
try:
|
|
257
270
|
parsed = urllib.parse.urlsplit(base_url)
|
|
258
271
|
_ = parsed.port
|
|
259
272
|
except ValueError:
|
|
260
|
-
|
|
273
|
+
_LOGGER.error(f"Invalid GitLab base_url: {base_url!r}")
|
|
261
274
|
return None
|
|
262
275
|
if (
|
|
263
276
|
parsed.scheme not in {"http", "https"}
|
|
@@ -267,7 +280,7 @@ def resolve_platform_config(platform: str, repo_cfg: dict) -> dict | None:
|
|
|
267
280
|
or parsed.query
|
|
268
281
|
or parsed.fragment
|
|
269
282
|
):
|
|
270
|
-
|
|
283
|
+
_LOGGER.error(f"Invalid GitLab base_url: {base_url!r}")
|
|
271
284
|
return None
|
|
272
285
|
plat["base_url"] = base_url.rstrip("/")
|
|
273
286
|
|
|
@@ -302,8 +315,8 @@ def fetch_tags(plat: dict, owner: str, repo: str) -> list[str]:
|
|
|
302
315
|
try:
|
|
303
316
|
data = json.loads(make_request(url, plat))
|
|
304
317
|
return [tag["name"] for tag in data]
|
|
305
|
-
except (http.client.HTTPException, OSError, ValueError,
|
|
306
|
-
|
|
318
|
+
except (http.client.HTTPException, OSError, ValueError, TypeError) as e:
|
|
319
|
+
_LOGGER.warning(f"Could not fetch tags for {owner}/{repo}: {e}")
|
|
307
320
|
return []
|
|
308
321
|
|
|
309
322
|
|
|
@@ -352,7 +365,7 @@ def read_package_name(package_dir: Path) -> str | None:
|
|
|
352
365
|
name = data.get("package", {}).get("name")
|
|
353
366
|
return name if isinstance(name, str) and name else None
|
|
354
367
|
except (tomllib.TOMLDecodeError, OSError) as e:
|
|
355
|
-
|
|
368
|
+
_LOGGER.warning(f"Could not parse typst.toml: {e}")
|
|
356
369
|
return None
|
|
357
370
|
|
|
358
371
|
|
|
@@ -373,7 +386,7 @@ def download_and_install(
|
|
|
373
386
|
"""
|
|
374
387
|
Download one tag of one repository and install it.
|
|
375
388
|
|
|
376
|
-
'tag' must already be normalized (no 'v' prefix)
|
|
389
|
+
'tag' must already be normalized (no 'v' prefix) - it is used both
|
|
377
390
|
to build the archive URL (with prefix re-added if the remote uses
|
|
378
391
|
one) and as the version directory name.
|
|
379
392
|
|
|
@@ -401,22 +414,22 @@ def download_and_install(
|
|
|
401
414
|
tag=urllib.parse.quote(candidate, safe=""),
|
|
402
415
|
)
|
|
403
416
|
try:
|
|
404
|
-
|
|
417
|
+
_LOGGER.info(f"Downloading {owner}/{repo}@{candidate} from {platform}...")
|
|
405
418
|
archive_bytes = make_request(url, plat)
|
|
406
419
|
used_tag = candidate
|
|
407
420
|
break
|
|
408
421
|
except urllib.error.HTTPError as e:
|
|
409
422
|
if e.code == 404 and candidate != candidates[-1]:
|
|
410
|
-
|
|
423
|
+
_LOGGER.debug(f"Tag '{candidate}' not found, trying next candidate...")
|
|
411
424
|
continue
|
|
412
|
-
|
|
425
|
+
_LOGGER.error(f"HTTP {e.code} for {url}")
|
|
413
426
|
return False
|
|
414
|
-
except (http.client.HTTPException, OSError
|
|
415
|
-
|
|
427
|
+
except (http.client.HTTPException, OSError) as e:
|
|
428
|
+
_LOGGER.error(f"Download failed for {owner}/{repo}@{candidate}: {e}")
|
|
416
429
|
return False
|
|
417
430
|
|
|
418
431
|
if archive_bytes is None or used_tag is None:
|
|
419
|
-
|
|
432
|
+
_LOGGER.error(f"Could not download {owner}/{repo}@{tag}")
|
|
420
433
|
return False
|
|
421
434
|
|
|
422
435
|
# Extract to a staging dir first so we can read typst.toml
|
|
@@ -426,35 +439,30 @@ def download_and_install(
|
|
|
426
439
|
|
|
427
440
|
try:
|
|
428
441
|
extract_archive(archive_bytes, staging, plat["archive_format"])
|
|
429
|
-
except (
|
|
430
|
-
|
|
431
|
-
zipfile.BadZipFile,
|
|
432
|
-
OSError,
|
|
433
|
-
EOFError,
|
|
434
|
-
) as e:
|
|
435
|
-
log_error(f"Extraction failed for {owner}/{repo}@{used_tag}: {e}")
|
|
442
|
+
except (tarfile.TarError, zipfile.BadZipFile, EOFError) as e:
|
|
443
|
+
_LOGGER.error(f"Extraction failed for {owner}/{repo}@{used_tag}: {e}")
|
|
436
444
|
return False
|
|
437
445
|
|
|
438
446
|
# Read the package name from typst.toml
|
|
439
447
|
package_name = read_package_name(staging)
|
|
440
448
|
if package_name is None:
|
|
441
|
-
|
|
449
|
+
_LOGGER.error(
|
|
442
450
|
f"No valid typst.toml with [package] name found in "
|
|
443
|
-
f"{owner}/{repo}@{used_tag}
|
|
451
|
+
f"{owner}/{repo}@{used_tag} - cannot determine package name"
|
|
444
452
|
)
|
|
445
453
|
return False
|
|
446
454
|
|
|
447
455
|
if package_name != repo:
|
|
448
|
-
|
|
456
|
+
_LOGGER.debug(f"Package name '{package_name}' differs from repo '{repo}'")
|
|
449
457
|
|
|
450
458
|
# Final destination: normalized tag (no 'v' prefix) as version dir
|
|
451
459
|
dest = base_dir / namespace / package_name / tag
|
|
452
460
|
|
|
453
461
|
if dest.exists():
|
|
454
462
|
if skip_existing:
|
|
455
|
-
|
|
463
|
+
_LOGGER.warning(f"Already exists: {dest}")
|
|
456
464
|
return True
|
|
457
|
-
|
|
465
|
+
_LOGGER.warning(f"Overwriting existing: {dest}")
|
|
458
466
|
shutil.rmtree(dest)
|
|
459
467
|
|
|
460
468
|
dest.mkdir(parents=True, exist_ok=True)
|
|
@@ -463,11 +471,11 @@ def download_and_install(
|
|
|
463
471
|
for item in staging.iterdir():
|
|
464
472
|
shutil.move(str(item), str(dest / item.name))
|
|
465
473
|
except OSError as e:
|
|
466
|
-
|
|
474
|
+
_LOGGER.error(f"Install failed: {e}")
|
|
467
475
|
shutil.rmtree(dest, ignore_errors=True)
|
|
468
476
|
return False
|
|
469
477
|
|
|
470
|
-
|
|
478
|
+
_LOGGER.info(f"Installed {package_name}@{tag} -> {dest}")
|
|
471
479
|
return True
|
|
472
480
|
|
|
473
481
|
|
|
@@ -483,10 +491,10 @@ def sync_repository(repo_cfg: dict, settings: dict) -> bool:
|
|
|
483
491
|
raw_tags = repo_cfg.get("tags")
|
|
484
492
|
|
|
485
493
|
if platform not in PLATFORMS:
|
|
486
|
-
|
|
494
|
+
_LOGGER.error(f"Unknown platform '{platform}' for repo '{repo}'")
|
|
487
495
|
return False
|
|
488
496
|
if not owner or not repo:
|
|
489
|
-
|
|
497
|
+
_LOGGER.error(f"Missing 'owner' or 'repo' in config entry: {repo_cfg}")
|
|
490
498
|
return False
|
|
491
499
|
|
|
492
500
|
plat = resolve_platform_config(platform, repo_cfg)
|
|
@@ -499,13 +507,13 @@ def sync_repository(repo_cfg: dict, settings: dict) -> bool:
|
|
|
499
507
|
if tags == "all":
|
|
500
508
|
raw_remote = fetch_tags(plat, owner, repo)
|
|
501
509
|
if not raw_remote:
|
|
502
|
-
|
|
510
|
+
_LOGGER.error(f"No tags found for {owner}/{repo}")
|
|
503
511
|
return False
|
|
504
512
|
tags = filter_remote_tags(raw_remote, owner, repo)
|
|
505
513
|
if not tags:
|
|
506
|
-
|
|
514
|
+
_LOGGER.error(f"No tags matching the version pattern found for {owner}/{repo}")
|
|
507
515
|
return False
|
|
508
|
-
|
|
516
|
+
_LOGGER.info(f'[{owner}/{repo}] tags = "all" -> {len(tags)} version(s): {tags}')
|
|
509
517
|
|
|
510
518
|
base_dir = Path(settings.get("base_dir", str(DEFAULT_BASE_DIR))).expanduser()
|
|
511
519
|
namespace = settings.get("namespace", "preview")
|
|
@@ -516,11 +524,13 @@ def sync_repository(repo_cfg: dict, settings: dict) -> bool:
|
|
|
516
524
|
if download_and_install(plat, owner, repo, tag, base_dir, namespace, skip_existing):
|
|
517
525
|
ok += 1
|
|
518
526
|
|
|
519
|
-
|
|
527
|
+
_LOGGER.info(f"-- {owner}/{repo}: {ok}/{len(tags)} version(s) synced")
|
|
520
528
|
return ok == len(tags)
|
|
521
529
|
|
|
522
530
|
|
|
523
531
|
def main(argv: list[str] | None = None) -> None:
|
|
532
|
+
configure_logging()
|
|
533
|
+
|
|
524
534
|
parser = argparse.ArgumentParser(description="Sync Typst packages from Git repositories")
|
|
525
535
|
parser.add_argument(
|
|
526
536
|
"--config",
|
|
@@ -533,31 +543,35 @@ def main(argv: list[str] | None = None) -> None:
|
|
|
533
543
|
|
|
534
544
|
config = load_config(config_file)
|
|
535
545
|
settings = config.get("settings", {})
|
|
546
|
+
verbose = settings.get("verbose", True)
|
|
547
|
+
if not isinstance(verbose, bool):
|
|
548
|
+
_LOGGER.error("Invalid 'verbose' setting: expected a boolean")
|
|
549
|
+
sys.exit(1)
|
|
550
|
+
configure_logging(verbose)
|
|
551
|
+
|
|
536
552
|
repos = config.get("repositories", [])
|
|
537
553
|
|
|
538
554
|
if not repos:
|
|
539
|
-
|
|
540
|
-
|
|
555
|
+
_LOGGER.warning("No [[repositories]] defined in config.")
|
|
556
|
+
_LOGGER.info(f"Edit {config_file} to add some.")
|
|
541
557
|
sys.exit(0)
|
|
542
558
|
|
|
543
559
|
namespace = settings.get("namespace", "preview")
|
|
544
560
|
base_dir = Path(settings.get("base_dir", str(DEFAULT_BASE_DIR))).expanduser()
|
|
545
561
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
print()
|
|
562
|
+
_LOGGER.info("typst-pkg-sync - Typst Package Sync")
|
|
563
|
+
_LOGGER.info(f"Base: {base_dir}")
|
|
564
|
+
_LOGGER.info(f"Namespace: {namespace}")
|
|
550
565
|
|
|
551
566
|
failures = 0
|
|
552
567
|
for repo_cfg in repos:
|
|
553
568
|
if not sync_repository(repo_cfg, settings):
|
|
554
569
|
failures += 1
|
|
555
570
|
|
|
556
|
-
print()
|
|
557
571
|
if failures:
|
|
558
|
-
|
|
572
|
+
_LOGGER.error(f"{failures} repository(ies) had errors")
|
|
559
573
|
sys.exit(1)
|
|
560
|
-
|
|
574
|
+
_LOGGER.info("All done!")
|
|
561
575
|
|
|
562
576
|
|
|
563
577
|
if __name__ == "__main__":
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|