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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typst-pkg-sync
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Sync tagged Typst packages from GitHub, GitLab, and Codeberg
5
5
  Author: Jan-Philipp Reßler
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "typst-pkg-sync"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  requires-python = ">=3.14"
9
9
  description = "Sync tagged Typst packages from GitHub, GitLab, and Codeberg"
10
10
  readme = "README.md"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "typst-pkg-sync"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  requires-python = ">=3.14"
9
9
  description = "Sync tagged Typst packages from GitHub, GitLab, and Codeberg"
10
10
  readme = "README.md"
@@ -1,5 +1,5 @@
1
1
  """
2
- typst-pkg-sync — Local Package Manager for Typst
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 — Local Package Manager for Typst
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 log_warn(msg: str) -> None:
118
- print(f"\033[33m[WARN]\033[0m {msg}")
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 log_error(msg: str) -> None:
122
- print(f"\033[31m[ERROR]\033[0m {msg}")
123
-
124
-
125
- def log_debug(msg: str) -> None:
126
- print(f"\033[34m[DEBUG]\033[0m {msg}")
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
- log_error(f'[{repo}] tags must be an array of versions or the string "all", got: "{tags}"')
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
- log_error(f"[{repo}] tags array is empty")
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
- log_error(
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
- log_error(f'[{repo}] tags must be an array of versions or "all"')
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
- log_warn(f"[{owner}/{repo}] skipping non-version tag: {raw!r}")
212
+ _LOGGER.warning(f"[{owner}/{repo}] skipping non-version tag: {raw!r}")
200
213
  continue
201
214
  if version in seen:
202
- log_warn(
215
+ _LOGGER.warning(
203
216
  f"[{owner}/{repo}] tag {raw!r} maps to version {version!r}, "
204
- f"which was already seen — skipping duplicate"
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
- log_info(f"Created default config at {config_file}")
222
- log_info("Edit it to add your repositories, then re-run.")
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
- log_error(f"Invalid TOML in {config_file}: {e}")
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
- log_error(f"Invalid token_env for repository '{repo_cfg.get('repo', '')}'")
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
- log_error("The 'base_url' option is only supported for GitLab repositories")
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
- log_error(f"Invalid GitLab base_url: {base_url!r}")
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
- log_error(f"Invalid GitLab base_url: {base_url!r}")
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
- log_error(f"Invalid GitLab base_url: {base_url!r}")
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, KeyError, TypeError) as e:
306
- log_warn(f"Could not fetch tags for {owner}/{repo}: {e}")
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
- log_warn(f"Could not parse typst.toml: {e}")
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) — it is used both
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
- log_info(f"⬇ Downloading {owner}/{repo}@{candidate} from {platform}...")
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
- log_debug(f"Tag '{candidate}' not found, trying next candidate...")
423
+ _LOGGER.debug(f"Tag '{candidate}' not found, trying next candidate...")
411
424
  continue
412
- log_error(f"HTTP {e.code} for {url}")
425
+ _LOGGER.error(f"HTTP {e.code} for {url}")
413
426
  return False
414
- except (http.client.HTTPException, OSError, ValueError) as e:
415
- log_error(f"Download failed for {owner}/{repo}@{candidate}: {e}")
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
- log_error(f"Could not download {owner}/{repo}@{tag}")
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
- tarfile.TarError,
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
- log_error(
449
+ _LOGGER.error(
442
450
  f"No valid typst.toml with [package] name found in "
443
- f"{owner}/{repo}@{used_tag} — cannot determine package name"
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
- log_debug(f"Package name '{package_name}' differs from repo '{repo}'")
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
- log_warn(f"⊘ Already exists: {dest}")
463
+ _LOGGER.warning(f"Already exists: {dest}")
456
464
  return True
457
- log_warn(f"Overwriting existing: {dest}")
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
- log_error(f"Install failed: {e}")
474
+ _LOGGER.error(f"Install failed: {e}")
467
475
  shutil.rmtree(dest, ignore_errors=True)
468
476
  return False
469
477
 
470
- log_info(f"✅ Installed {package_name}@{tag} → {dest}")
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
- log_error(f"Unknown platform '{platform}' for repo '{repo}'")
494
+ _LOGGER.error(f"Unknown platform '{platform}' for repo '{repo}'")
487
495
  return False
488
496
  if not owner or not repo:
489
- log_error(f"Missing 'owner' or 'repo' in config entry: {repo_cfg}")
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
- log_error(f"No tags found for {owner}/{repo}")
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
- log_error(f"No tags matching the version pattern found for {owner}/{repo}")
514
+ _LOGGER.error(f"No tags matching the version pattern found for {owner}/{repo}")
507
515
  return False
508
- log_info(f'[{owner}/{repo}] tags = "all" → {len(tags)} version(s): {tags}')
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
- log_info(f"── {owner}/{repo}: {ok}/{len(tags)} version(s) synced")
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
- log_warn("No [[repositories]] defined in config.")
540
- log_info(f"Edit {config_file} to add some.")
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
- log_info("📦 typst-pkg-sync — Typst Package Sync")
547
- log_info(f"📁 Base: {base_dir}")
548
- log_info(f"🏷 Namespace: {namespace}")
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
- log_error(f"{failures} repository(ies) had errors")
572
+ _LOGGER.error(f"{failures} repository(ies) had errors")
559
573
  sys.exit(1)
560
- log_info("🎉 All done!")
574
+ _LOGGER.info("All done!")
561
575
 
562
576
 
563
577
  if __name__ == "__main__":
File without changes
File without changes