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.
- article_cli/__init__.py +1 -1
- article_cli/cli.py +1 -0
- article_cli/commands/__init__.py +2 -0
- article_cli/commands/bundle.py +93 -0
- article_cli/commands/version.py +1 -1
- article_cli/config.py +20 -14
- article_cli/git_hooks.py +2 -1
- article_cli/git_manager.py +2 -1
- article_cli/paper_version.py +108 -0
- article_cli/repository_setup.py +6 -1
- article_cli/services/__init__.py +8 -0
- article_cli/services/bundle.py +335 -0
- article_cli/services/bundle_backends.py +337 -0
- article_cli/services/compiler.py +11 -4
- article_cli/services/gitinfo.py +45 -5
- article_cli/services/release.py +33 -12
- article_cli/templates/article/ieee.tex.j2 +2 -2
- article_cli/templates/article/lncs.tex.j2 +2 -2
- article_cli/templates/article/lncs.typ.j2 +3 -0
- article_cli/templates/article/main.tex.j2 +2 -2
- article_cli/templates/article/main.typ.j2 +3 -0
- article_cli/templates/poster/poster.tex.j2 +2 -2
- article_cli/templates/poster/poster.typ.j2 +3 -0
- article_cli/templates/presentation/beamer.tex.j2 +2 -2
- article_cli/templates/presentation/typst.typ.j2 +3 -0
- article_cli/templates/project/README.md.j2 +2 -2
- article_cli/templates/project/pyproject.toml.j2 +1 -1
- article_cli/typst_compiler.py +9 -0
- article_cli/zotero.py +81 -51
- {article_cli-2.0.0.dist-info → article_cli-2.1.0.dist-info}/METADATA +113 -9
- article_cli-2.1.0.dist-info/RECORD +60 -0
- {article_cli-2.0.0.dist-info → article_cli-2.1.0.dist-info}/WHEEL +1 -1
- article_cli-2.0.0.dist-info/RECORD +0 -56
- {article_cli-2.0.0.dist-info → article_cli-2.1.0.dist-info}/entry_points.txt +0 -0
- {article_cli-2.0.0.dist-info → article_cli-2.1.0.dist-info}/licenses/LICENSE +0 -0
article_cli/__init__.py
CHANGED
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
|
article_cli/commands/__init__.py
CHANGED
|
@@ -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
|
article_cli/commands/version.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
283
|
+
*METADATA_EXCLUDES,
|
|
283
284
|
],
|
|
284
285
|
)
|
|
285
286
|
return bool(result and result.stdout.strip())
|
article_cli/git_manager.py
CHANGED
|
@@ -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.
|
|
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
|
article_cli/repository_setup.py
CHANGED
|
@@ -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.
|
|
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
|
|
article_cli/services/__init__.py
CHANGED
|
@@ -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",
|