article-cli 2.0.0__py3-none-any.whl → 2.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.
Files changed (35) hide show
  1. article_cli/__init__.py +1 -1
  2. article_cli/cli.py +1 -0
  3. article_cli/commands/__init__.py +2 -0
  4. article_cli/commands/bundle.py +93 -0
  5. article_cli/commands/version.py +1 -1
  6. article_cli/config.py +20 -14
  7. article_cli/git_hooks.py +2 -1
  8. article_cli/git_manager.py +2 -1
  9. article_cli/paper_version.py +108 -0
  10. article_cli/repository_setup.py +6 -1
  11. article_cli/services/__init__.py +8 -0
  12. article_cli/services/bundle.py +335 -0
  13. article_cli/services/bundle_backends.py +337 -0
  14. article_cli/services/compiler.py +11 -4
  15. article_cli/services/gitinfo.py +45 -5
  16. article_cli/services/release.py +33 -12
  17. article_cli/templates/article/ieee.tex.j2 +2 -2
  18. article_cli/templates/article/lncs.tex.j2 +2 -2
  19. article_cli/templates/article/lncs.typ.j2 +3 -0
  20. article_cli/templates/article/main.tex.j2 +2 -2
  21. article_cli/templates/article/main.typ.j2 +3 -0
  22. article_cli/templates/poster/poster.tex.j2 +2 -2
  23. article_cli/templates/poster/poster.typ.j2 +3 -0
  24. article_cli/templates/presentation/beamer.tex.j2 +2 -2
  25. article_cli/templates/presentation/typst.typ.j2 +3 -0
  26. article_cli/templates/project/README.md.j2 +2 -2
  27. article_cli/templates/project/pyproject.toml.j2 +1 -1
  28. article_cli/typst_compiler.py +9 -0
  29. article_cli/zotero.py +81 -51
  30. {article_cli-2.0.0.dist-info → article_cli-2.1.0.dist-info}/METADATA +113 -9
  31. article_cli-2.1.0.dist-info/RECORD +60 -0
  32. {article_cli-2.0.0.dist-info → article_cli-2.1.0.dist-info}/WHEEL +1 -1
  33. article_cli-2.0.0.dist-info/RECORD +0 -56
  34. {article_cli-2.0.0.dist-info → article_cli-2.1.0.dist-info}/entry_points.txt +0 -0
  35. {article_cli-2.0.0.dist-info → article_cli-2.1.0.dist-info}/licenses/LICENSE +0 -0
article_cli/__init__.py CHANGED
@@ -9,7 +9,7 @@ This package provides tools for:
9
9
  - Git hooks setup
10
10
  """
11
11
 
12
- __version__ = "1.5.0"
12
+ __version__ = "2.1.0"
13
13
  __author__ = "Christophe Prud'homme"
14
14
  __email__ = "prudhomm@cemosis.fr"
15
15
 
article_cli/cli.py CHANGED
@@ -44,6 +44,7 @@ Examples:
44
44
  %(prog)s bib update --check # Check checked-in bibliography freshness
45
45
  %(prog)s bib update # Update references from Zotero
46
46
  %(prog)s compile # Compile configured main document
47
+ %(prog)s bundle --verify # Prepare a submission source ZIP
47
48
  %(prog)s version # Refresh and report git metadata
48
49
  %(prog)s version --compile --check-pdf # Refresh, compile, and inspect PDF text
49
50
  %(prog)s release v1 --dry-run # Preview release tag creation
@@ -4,6 +4,7 @@ Command modules for article-cli.
4
4
 
5
5
  from . import (
6
6
  bibtex,
7
+ bundle,
7
8
  clean,
8
9
  compile,
9
10
  config,
@@ -25,6 +26,7 @@ COMMAND_MODULES = [
25
26
  version,
26
27
  release,
27
28
  bibtex,
29
+ bundle,
28
30
  config,
29
31
  fonts,
30
32
  themes,
@@ -0,0 +1,93 @@
1
+ """Source archive command for HAL, arXiv, and other submission systems."""
2
+
3
+ import argparse
4
+ import subprocess
5
+
6
+ from ..config import Config
7
+ from ..reporting import print_error, print_info, print_success
8
+ from ..services.bundle import BundleOptions, BundleService
9
+
10
+
11
+ def add_parser(subparsers):
12
+ parser = subparsers.add_parser("bundle", help="Create a LaTeX or Typst source ZIP")
13
+ parser.add_argument(
14
+ "document", nargs="?", help="Main .tex or .typ file (or configured document)"
15
+ )
16
+ parser.add_argument(
17
+ "-o", "--output", help="ZIP path, relative to document directory"
18
+ )
19
+ parser.add_argument(
20
+ "--build-dir", help="Build directory containing compiler dependency metadata"
21
+ )
22
+ parser.add_argument(
23
+ "--include",
24
+ action="append",
25
+ default=[],
26
+ help="Add document-relative glob (repeatable)",
27
+ )
28
+ parser.add_argument(
29
+ "--exclude",
30
+ action="append",
31
+ default=[],
32
+ help="Exclude document-relative glob (repeatable)",
33
+ )
34
+ for key, help_text in [
35
+ ("verify", "Recompile selected files in a clean directory"),
36
+ ("include-pdf", "Include the generated main PDF in the ZIP"),
37
+ ]:
38
+ group = parser.add_mutually_exclusive_group()
39
+ group.add_argument(
40
+ "--" + key,
41
+ dest=key.replace("-", "_"),
42
+ action="store_true",
43
+ default=None,
44
+ help=help_text,
45
+ )
46
+ group.add_argument(
47
+ "--no-" + key, dest=key.replace("-", "_"), action="store_false"
48
+ )
49
+ parser.add_argument(
50
+ "--no-build",
51
+ action="store_true",
52
+ help="Use existing compiler dependency metadata and PDF",
53
+ )
54
+ parser.add_argument(
55
+ "--dry-run",
56
+ action="store_true",
57
+ help="List files from existing build data; write nothing",
58
+ )
59
+ parser.add_argument(
60
+ "--force", action="store_true", help="Replace an existing ZIP and manifest"
61
+ )
62
+ parser.set_defaults(handler=run)
63
+
64
+
65
+ def run(args: argparse.Namespace, config: Config) -> int:
66
+ try:
67
+ service = BundleService(config)
68
+ plan = service.plan(
69
+ BundleOptions(
70
+ document=args.document,
71
+ output=args.output,
72
+ build_dir=args.build_dir,
73
+ include=tuple(args.include),
74
+ exclude=tuple(args.exclude),
75
+ include_pdf=args.include_pdf,
76
+ verify=args.verify,
77
+ no_build=args.no_build,
78
+ dry_run=args.dry_run,
79
+ )
80
+ )
81
+ print_info(f"Bundle files ({len(plan.files)}):")
82
+ for name in sorted(plan.files):
83
+ print(f" {name}")
84
+ if args.dry_run:
85
+ print_info(f"Would write: {plan.output}")
86
+ else:
87
+ output = service.write(plan, force=args.force)
88
+ print_success(f"Created source bundle: {output}")
89
+ print_info(f'Manifest: {output.with_suffix(".manifest.json")}')
90
+ return 0
91
+ except (ValueError, OSError, subprocess.TimeoutExpired) as error:
92
+ print_error(str(error))
93
+ return 1
@@ -17,7 +17,7 @@ def add_parser(subparsers: Any) -> None:
17
17
  "version",
18
18
  help="Refresh and report git version metadata",
19
19
  description=(
20
- "Refresh gitinfo2 metadata and report the current git version state "
20
+ "Refresh LaTeX gitinfo2 or Typst JSON metadata and report Git state "
21
21
  "without creating a tag."
22
22
  ),
23
23
  )
article_cli/config.py CHANGED
@@ -9,13 +9,7 @@ from pathlib import Path
9
9
  from typing import Dict, Any, Optional, Union
10
10
  import argparse
11
11
 
12
- try:
13
- import tomllib # Python 3.11+
14
- except ImportError:
15
- try:
16
- import tomli as tomllib # fallback for older Python versions
17
- except ImportError:
18
- tomllib = None
12
+ import tomllib
19
13
 
20
14
 
21
15
  class Config:
@@ -67,7 +61,7 @@ class Config:
67
61
  config_path = self._find_config_file()
68
62
  self.loaded_config_file = config_path.resolve() if config_path else None
69
63
 
70
- if config_path and tomllib:
64
+ if config_path:
71
65
  try:
72
66
  with open(config_path, "rb") as f:
73
67
  full_config = tomllib.load(f)
@@ -104,12 +98,6 @@ class Config:
104
98
  if not self.quiet:
105
99
  print(f"Warning: Could not load config file {config_path}: {e}")
106
100
  self._config_data = {}
107
- elif config_path and not tomllib:
108
- if not self.quiet:
109
- print(
110
- "Warning: TOML support not available. Install dependencies with: uv sync"
111
- )
112
- self._config_data = {}
113
101
  else:
114
102
  self._config_data = {}
115
103
 
@@ -205,6 +193,16 @@ class Config:
205
193
  "timeout": self.get("latex", "timeout", 300),
206
194
  }
207
195
 
196
+ def get_bundle_config(self) -> Dict[str, Any]:
197
+ """Get source archive policy; patterns are document-directory relative."""
198
+ return {
199
+ "output": self.get("bundle", "output", ""),
200
+ "include": self.get("bundle", "include", []),
201
+ "exclude": self.get("bundle", "exclude", []),
202
+ "include_pdf": self.get("bundle", "include_pdf", False),
203
+ "verify": self.get("bundle", "verify", False),
204
+ }
205
+
208
206
  def get_project_config(self) -> Dict[str, Any]:
209
207
  """Get project-level configuration"""
210
208
  return {
@@ -426,6 +424,14 @@ shell_escape = false
426
424
  # Compilation timeout in seconds
427
425
  timeout = 300
428
426
 
427
+ [bundle]
428
+ # Submission ZIP; paths and globs are relative to the main document directory.
429
+ # output = "dist/paper-sources.zip"
430
+ include = []
431
+ exclude = ["local-only/**", "reviews/**"]
432
+ include_pdf = false
433
+ verify = false
434
+
429
435
  [project]
430
436
  # Project type: "article", "typst-article", "presentation", "typst-presentation",
431
437
  # "poster", or "typst-poster"
article_cli/git_hooks.py CHANGED
@@ -10,6 +10,7 @@ import subprocess
10
10
  from pathlib import Path
11
11
  from typing import List, Optional
12
12
 
13
+ from .paper_version import METADATA_EXCLUDES
13
14
  from .reporting import print_info, print_success, print_warning
14
15
 
15
16
  MANAGED_HOOK_START = "# >>> article-cli gitinfo2 hook >>>"
@@ -279,7 +280,7 @@ def _is_dirty_for_gitinfo2(repo_path: Path) -> bool:
279
280
  "--untracked-files=no",
280
281
  "--",
281
282
  ".",
282
- ":(exclude)gitHeadLocal.gin",
283
+ *METADATA_EXCLUDES,
283
284
  ],
284
285
  )
285
286
  return bool(result and result.stdout.strip())
@@ -16,6 +16,7 @@ from .git_hooks import (
16
16
  install_managed_gitinfo2_hook,
17
17
  refresh_gitinfo2_metadata,
18
18
  )
19
+ from .paper_version import METADATA_EXCLUDES
19
20
  from .reporting import Colors, print_error, print_info, print_success, print_warning
20
21
 
21
22
 
@@ -294,7 +295,7 @@ class GitManager:
294
295
  """Return dirty tracked/untracked files, optionally ignoring gitHeadLocal.gin."""
295
296
  pathspec = ["--", "."]
296
297
  if ignore_gitinfo:
297
- pathspec.append(":(exclude)gitHeadLocal.gin")
298
+ pathspec.extend(METADATA_EXCLUDES)
298
299
  result = self.git(["status", "--porcelain", *pathspec])
299
300
  if result.returncode != 0:
300
301
  return ["<could not inspect git status>"]
@@ -0,0 +1,108 @@
1
+ """Portable paper provenance: read Git once and export a Typst JSON snapshot.
2
+
3
+ Git access is outside the document compiler. Archived documents retain the frozen
4
+ snapshot, while working repositories refresh it through article-cli. LaTeX keeps
5
+ its established gitinfo2 adapter in git_hooks.
6
+ """
7
+
8
+ import json
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from .command_runner import DEFAULT_RUNNER, CommandRunner
14
+
15
+ TYPST_METADATA_FILE = "gitHeadLocal.json"
16
+ # Generated provenance must not make the next snapshot dirty by itself.
17
+ METADATA_EXCLUDES = (
18
+ ":(exclude)gitHeadLocal.gin",
19
+ ":(glob,exclude)**/gitHeadLocal.gin",
20
+ ":(exclude)gitHeadLocal.json",
21
+ ":(glob,exclude)**/gitHeadLocal.json",
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class GitSnapshot:
27
+ """Format-neutral, deterministic paper version state (no build timestamp)."""
28
+
29
+ commit: str = ""
30
+ short_commit: str = ""
31
+ branch: str = "unversioned"
32
+ description: str = "unversioned"
33
+ release: str = ""
34
+ commit_date: str = ""
35
+ dirty: bool = False
36
+ schema_version: int = 1
37
+
38
+
39
+ def read_git_snapshot(
40
+ start_path: Path, runner: CommandRunner = DEFAULT_RUNNER
41
+ ) -> Optional[GitSnapshot]:
42
+ """Read an existing commit; tracked source edits define the dirty flag."""
43
+
44
+ def git(*args):
45
+ try:
46
+ result = runner.run(["git", *args], cwd=start_path)
47
+ return result.stdout.strip() if result.returncode == 0 else None
48
+ except (FileNotFoundError, OSError):
49
+ return None
50
+
51
+ fields = git("log", "-1", "--format=%H%n%h%n%cI", "HEAD")
52
+ if not fields:
53
+ return None
54
+ values = fields.splitlines()
55
+ if len(values) != 3:
56
+ return None
57
+ # Query at the Git root so changes in sibling documents are visible too.
58
+ root = git("rev-parse", "--show-toplevel")
59
+ if not root:
60
+ return None
61
+ result = runner.run(
62
+ [
63
+ "git",
64
+ "status",
65
+ "--porcelain",
66
+ "--untracked-files=no",
67
+ "--",
68
+ ".",
69
+ *METADATA_EXCLUDES,
70
+ ],
71
+ cwd=Path(root),
72
+ )
73
+ if result.returncode:
74
+ return None
75
+ dirty = bool(result.stdout.strip())
76
+ description = git("describe", "--tags", "--long", "--always") or values[1]
77
+ release = git("describe", "--tags", "--abbrev=0") or ""
78
+ return GitSnapshot(
79
+ commit=values[0],
80
+ short_commit=values[1],
81
+ commit_date=values[2],
82
+ branch=git("symbolic-ref", "--quiet", "--short", "HEAD") or "detached",
83
+ description=description + ("-*" if dirty else ""),
84
+ release=release,
85
+ dirty=dirty,
86
+ )
87
+
88
+
89
+ def write_typst_snapshot(snapshot: GitSnapshot, directory: Path) -> Path:
90
+ """Write a stable, safely escaped snapshot beside the main document."""
91
+ path = directory / TYPST_METADATA_FILE
92
+ content = (
93
+ json.dumps(asdict(snapshot), indent=2, sort_keys=True, ensure_ascii=False)
94
+ + "\n"
95
+ )
96
+ if not path.is_file() or path.read_text() != content:
97
+ path.write_text(content)
98
+ return path
99
+
100
+
101
+ def refresh_typst_metadata(
102
+ directory: Path, dry_run: bool = False
103
+ ) -> Optional[GitSnapshot]:
104
+ """Refresh when Git is available; preserve an archive's existing snapshot."""
105
+ snapshot = read_git_snapshot(directory)
106
+ if snapshot is not None and not dry_run:
107
+ write_typst_snapshot(snapshot, directory)
108
+ return snapshot
@@ -14,10 +14,11 @@ from pathlib import Path
14
14
  from typing import List, Optional, Sequence, Union
15
15
 
16
16
  from .git_hooks import ensure_gitinfo2_hook_source
17
+ from .paper_version import GitSnapshot, read_git_snapshot, write_typst_snapshot
17
18
  from .template_renderer import TEMPLATE_VERSION, TemplateRenderer
18
19
  from .reporting import print_error, print_info, print_success
19
20
 
20
- ARTICLE_CLI_MIN_VERSION = "1.5.0"
21
+ ARTICLE_CLI_MIN_VERSION = "2.1.0"
21
22
  ARTICLE_TEMPLATES = {
22
23
  ("article", "default"): "article/main.tex.j2",
23
24
  ("article", "lncs"): "article/lncs.tex.j2",
@@ -422,6 +423,10 @@ class RepositorySetup:
422
423
  )
423
424
 
424
425
  doc_path.write_text(doc_content, encoding="utf-8")
426
+ if is_typst and not (doc_path.parent / "gitHeadLocal.json").exists():
427
+ write_typst_snapshot(
428
+ read_git_snapshot(self.repo_path) or GitSnapshot(), doc_path.parent
429
+ )
425
430
  print_success(f"Created: {doc_path.relative_to(self.repo_path)}")
426
431
  return True
427
432
 
@@ -3,6 +3,8 @@ Service layer for article-cli command handlers.
3
3
  """
4
4
 
5
5
  from .bibliography import BibliographyService, BibliographyUpdateOptions
6
+ from .bundle import BundleOptions, BundlePlan, BundleService
7
+ from .bundle_backends import BundleBackend, BundleBuild, BundleDependency
6
8
  from .compiler import CompileOptions, CompilerService
7
9
  from .git import GitService
8
10
  from .gitinfo import GitInfoService
@@ -12,6 +14,12 @@ from .workflow import WorkflowService
12
14
  __all__ = [
13
15
  "BibliographyService",
14
16
  "BibliographyUpdateOptions",
17
+ "BundleOptions",
18
+ "BundlePlan",
19
+ "BundleService",
20
+ "BundleBackend",
21
+ "BundleBuild",
22
+ "BundleDependency",
15
23
  "CompileOptions",
16
24
  "CompilerService",
17
25
  "GitInfoService",