calkit-python 0.49.5__py3-none-any.whl → 0.50.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.
- calkit/cli/check.py +3 -1
- calkit/cli/latex.py +133 -7
- calkit/cli/main/core.py +4 -1
- calkit/cli/overleaf.py +114 -43
- calkit/cli/update.py +4 -1
- calkit/config.py +62 -1
- calkit/docx.py +288 -58
- calkit/latex.py +830 -29
- calkit/models/pipeline.py +166 -6
- calkit/overleaf.py +183 -14
- calkit/pipeline.py +27 -3
- calkit/resources/README.md +1 -0
- calkit/resources/latex/calkit.sty +208 -0
- calkit/tests/cli/test_latex.py +212 -0
- calkit/tests/cli/test_overleaf.py +85 -2
- calkit/tests/test_config.py +66 -0
- calkit/tests/test_docx.py +170 -0
- calkit/tests/test_latex.py +493 -0
- calkit/tests/test_overleaf.py +143 -4
- calkit/tests/test_pipeline.py +248 -0
- {calkit_python-0.49.5.dist-info → calkit_python-0.50.0.dist-info}/METADATA +2 -1
- {calkit_python-0.49.5.dist-info → calkit_python-0.50.0.dist-info}/RECORD +38 -37
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.49.5.dist-info → calkit_python-0.50.0.dist-info}/WHEEL +0 -0
- {calkit_python-0.49.5.dist-info → calkit_python-0.50.0.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.49.5.dist-info → calkit_python-0.50.0.dist-info}/licenses/LICENSE +0 -0
calkit/cli/check.py
CHANGED
|
@@ -2466,7 +2466,9 @@ def check_storage(
|
|
|
2466
2466
|
stale: set[str] | None = set()
|
|
2467
2467
|
if any(p["stage"] for p in to_record):
|
|
2468
2468
|
status = calkit.pipeline.get_status(
|
|
2469
|
-
check_environments=False,
|
|
2469
|
+
check_environments=False,
|
|
2470
|
+
clean_notebooks=False,
|
|
2471
|
+
preprocess_stages=False,
|
|
2470
2472
|
)
|
|
2471
2473
|
if not status.errors:
|
|
2472
2474
|
stale = {n.split("@")[0] for n in status.stale_stage_names}
|
calkit/cli/latex.py
CHANGED
|
@@ -561,6 +561,17 @@ def _run_latexmk(
|
|
|
561
561
|
os.remove(fdb_path)
|
|
562
562
|
|
|
563
563
|
|
|
564
|
+
@latex_app.command(name="map-components")
|
|
565
|
+
def map_components(
|
|
566
|
+
tex_file: Annotated[
|
|
567
|
+
str, typer.Argument(help="The .tex file whose components to map.")
|
|
568
|
+
],
|
|
569
|
+
) -> None:
|
|
570
|
+
"""Map in components a LaTeX document includes with `\\ck*` commands."""
|
|
571
|
+
for problem in calkit.latex.map_ck_components(tex_file):
|
|
572
|
+
warn(f"{problem}; it will print as ??")
|
|
573
|
+
|
|
574
|
+
|
|
564
575
|
@latex_app.command(name="build")
|
|
565
576
|
def build(
|
|
566
577
|
tex_file: Annotated[str, typer.Argument(help="The .tex file to compile.")],
|
|
@@ -657,6 +668,10 @@ def build(
|
|
|
657
668
|
tex_dir = os.path.dirname(tex_file) or "."
|
|
658
669
|
stem = Path(tex_file).stem
|
|
659
670
|
pdf_dir = output_dir if output_dir is not None else tex_dir
|
|
671
|
+
# Components the document includes with calkit.sty's commands, e.g.,
|
|
672
|
+
# \ckfigure, have to be beside it before TeX can read them
|
|
673
|
+
for problem in calkit.latex.map_ck_components(tex_file):
|
|
674
|
+
warn(f"{problem}; it will print as ??")
|
|
660
675
|
if not os.path.isfile(os.path.join(pdf_dir, stem + ".pdf")):
|
|
661
676
|
fdb_dir = aux_dir if aux_dir is not None else tex_dir
|
|
662
677
|
fdb_fpath = os.path.join(fdb_dir, stem + ".fdb_latexmk")
|
|
@@ -766,8 +781,9 @@ _IFDEFINED_MARKUP_RE = re.compile(
|
|
|
766
781
|
)
|
|
767
782
|
# A one-argument macro's definition, up to its body's opening brace
|
|
768
783
|
_ONE_ARG_MACRO_DEF_RE = re.compile(
|
|
769
|
-
r"\\(?:(?:re
|
|
770
|
-
r"
|
|
784
|
+
r"\\(?:(?:(?:re)?newcommand|providecommand)\*?\s*"
|
|
785
|
+
r"(?:\{\s*\\([A-Za-z@]+)\s*\}|\\([A-Za-z@]+))\s*\[1\]"
|
|
786
|
+
r"|def\s*\\([A-Za-z@]+)#1)\s*(?=\{)"
|
|
771
787
|
)
|
|
772
788
|
get_diff_path = calkit.latex.get_diff_path
|
|
773
789
|
_default_base_ref = calkit.latex.default_base_ref
|
|
@@ -969,6 +985,7 @@ def diff(
|
|
|
969
985
|
generates, are fetched as they were at that revision, so each side of
|
|
970
986
|
the comparison shows its own.
|
|
971
987
|
"""
|
|
988
|
+
import calkit.resources
|
|
972
989
|
|
|
973
990
|
def stage_path(stage: dict, path: str) -> str:
|
|
974
991
|
"""A stage's path in the project's frame rather than its wdir's."""
|
|
@@ -1335,7 +1352,7 @@ def diff(
|
|
|
1335
1352
|
which can't say what's inside, so those directories beside the
|
|
1336
1353
|
document are included whole. Neither finds another stage's output
|
|
1337
1354
|
DVC doesn't cache, so the pipeline's inputs for the document are
|
|
1338
|
-
added too.
|
|
1355
|
+
added too, as are the components the document includes.
|
|
1339
1356
|
"""
|
|
1340
1357
|
doc_dir = Path(root, os.path.dirname(tex_file))
|
|
1341
1358
|
pointed = [
|
|
@@ -1344,7 +1361,17 @@ def diff(
|
|
|
1344
1361
|
if p.is_file()
|
|
1345
1362
|
]
|
|
1346
1363
|
detected = calkit.latex.detect_inputs(tex_file, wdir=root)
|
|
1347
|
-
|
|
1364
|
+
# What the revision's own calkit.sty commands include, which the
|
|
1365
|
+
# stage may not depend on now, at each path TeX would try
|
|
1366
|
+
_, components = calkit.latex.read_ck_usage(
|
|
1367
|
+
os.path.join(root, tex_file)
|
|
1368
|
+
)
|
|
1369
|
+
included = [
|
|
1370
|
+
path
|
|
1371
|
+
for c in components
|
|
1372
|
+
for path in calkit.latex.component_candidates(c)[0]
|
|
1373
|
+
]
|
|
1374
|
+
return detected + pointed + included + pipeline_inputs
|
|
1348
1375
|
|
|
1349
1376
|
def point_changed_figures_at_base(base_root: str, head_root: str) -> None:
|
|
1350
1377
|
"""Make the older side's changed figures refer to its own copies.
|
|
@@ -1531,6 +1558,22 @@ def diff(
|
|
|
1531
1558
|
for root in prepared:
|
|
1532
1559
|
break_verbatim_params(root)
|
|
1533
1560
|
expand_wrapper_macros(root)
|
|
1561
|
+
# Each side's own values, figures, and tables, read from its
|
|
1562
|
+
# revision, or the working tree for its copy of the sources
|
|
1563
|
+
calkit.latex.expand_ck_components(
|
|
1564
|
+
root, "." if root == working_copy else root, tex_file
|
|
1565
|
+
)
|
|
1566
|
+
# \cknum in the marked-up document needs calkit.sty beside it
|
|
1567
|
+
build_style = os.path.join(
|
|
1568
|
+
head_root, os.path.dirname(tex_file), calkit.latex.STYLE_FNAME
|
|
1569
|
+
)
|
|
1570
|
+
if not os.path.isfile(build_style):
|
|
1571
|
+
with open(build_style, "w", encoding="utf-8") as f:
|
|
1572
|
+
f.write(
|
|
1573
|
+
calkit.resources.read_text(
|
|
1574
|
+
"latex", calkit.latex.STYLE_FNAME
|
|
1575
|
+
)
|
|
1576
|
+
)
|
|
1534
1577
|
point_changed_figures_at_base(checkouts["base"], head_root)
|
|
1535
1578
|
_build_diff(
|
|
1536
1579
|
base_tex_fpath=sides["base"],
|
|
@@ -1711,6 +1754,9 @@ def _build_diff(
|
|
|
1711
1754
|
# chose otherwise
|
|
1712
1755
|
if not any(a.startswith("--graphics-markup") for a in latexdiff_args):
|
|
1713
1756
|
latexdiff_cmd.append("--graphics-markup=both")
|
|
1757
|
+
# A value from calkit.sty is marked like text, so a changed one shows
|
|
1758
|
+
# struck out beside its new value, rather than only as it is now
|
|
1759
|
+
latexdiff_cmd.append("--append-safecmd=cknum")
|
|
1714
1760
|
# User pass-through args come last so they can override Calkit's
|
|
1715
1761
|
# defaults
|
|
1716
1762
|
latexdiff_cmd += latexdiff_args
|
|
@@ -2013,7 +2059,10 @@ def to_docx(
|
|
|
2013
2059
|
try:
|
|
2014
2060
|
calkit.docx.pdf_to_docx(pdf_path, output)
|
|
2015
2061
|
except RuntimeError as e:
|
|
2016
|
-
raise_error(
|
|
2062
|
+
raise_error(
|
|
2063
|
+
f"{e}\nTo convert from the source instead, use "
|
|
2064
|
+
"--engine libreoffice"
|
|
2065
|
+
)
|
|
2017
2066
|
elif engine == "libreoffice":
|
|
2018
2067
|
import shlex
|
|
2019
2068
|
|
|
@@ -2032,6 +2081,8 @@ def to_docx(
|
|
|
2032
2081
|
job = Path(source).stem + "-calkit-docx"
|
|
2033
2082
|
build = os.path.join(calkit.latex.DOCX_BUILD_DIR, Path(source).stem)
|
|
2034
2083
|
shutil.rmtree(build, ignore_errors=True)
|
|
2084
|
+
# Under .calkit/local, which keeps itself out of Git
|
|
2085
|
+
calkit.ensure_local_dir()
|
|
2035
2086
|
os.makedirs(build)
|
|
2036
2087
|
# The PDF build's bibliography, since TeX4ht doesn't run BibTeX
|
|
2037
2088
|
bbl = Path(source).with_suffix(".bbl")
|
|
@@ -2169,6 +2220,32 @@ def to_docx(
|
|
|
2169
2220
|
blks = calkit.latex.blocks(lines)
|
|
2170
2221
|
paras = doc.paragraphs
|
|
2171
2222
|
matched = calkit.latex.align([p.text for p in paras], blks)
|
|
2223
|
+
# Word's PDF import drops a hyphen ending a line even when the word has
|
|
2224
|
+
# it, e.g., "two-dimensional" as "twodimensional", so it goes back where
|
|
2225
|
+
# the paragraph's source spells the word with it and never without
|
|
2226
|
+
if engine == "word":
|
|
2227
|
+
for para, blk in zip(paras, matched):
|
|
2228
|
+
if blk is None or para.element is None:
|
|
2229
|
+
continue
|
|
2230
|
+
words = re.findall(
|
|
2231
|
+
r"(?<![\w-])[^\W\d_]\w*(?:-\w+)+(?![\w-])", blk.text
|
|
2232
|
+
)
|
|
2233
|
+
for word in dict.fromkeys(words):
|
|
2234
|
+
pattern = re.compile(
|
|
2235
|
+
r"(?<![\w-])"
|
|
2236
|
+
+ "(-?)".join(re.escape(w) for w in word.split("-"))
|
|
2237
|
+
+ r"(?![\w-])",
|
|
2238
|
+
re.IGNORECASE,
|
|
2239
|
+
)
|
|
2240
|
+
if any("" in m.groups() for m in pattern.finditer(blk.text)):
|
|
2241
|
+
continue
|
|
2242
|
+
text = doc.linear_text(para.element)
|
|
2243
|
+
# From the end, so earlier offsets still hold
|
|
2244
|
+
for m in reversed(list(pattern.finditer(text))):
|
|
2245
|
+
for k in reversed(range(1, len(m.groups()) + 1)):
|
|
2246
|
+
if not m.group(k):
|
|
2247
|
+
doc.insert_text(para.element, m.start(k), "-")
|
|
2248
|
+
paras = doc.paragraphs
|
|
2172
2249
|
# Bookmark each anchored paragraph; a block rendering as several
|
|
2173
2250
|
# paragraphs gets numbered suffixes
|
|
2174
2251
|
original: dict[str, str] = {}
|
|
@@ -2251,6 +2328,26 @@ def to_docx(
|
|
|
2251
2328
|
# Word lays some displays out in tables of its own, which go too
|
|
2252
2329
|
# if all else they hold is the prose on either side, moved out.
|
|
2253
2330
|
emu_limit = int(1.5 * 914400)
|
|
2331
|
+
# A figure without a caption, in a float or not, could be any tall
|
|
2332
|
+
# picture, so then none go
|
|
2333
|
+
captionless, depth, captioned = False, 0, False
|
|
2334
|
+
for ln in lines:
|
|
2335
|
+
code = calkit.latex._strip_comments(ln.text)
|
|
2336
|
+
for m in re.finditer(
|
|
2337
|
+
r"\\(begin|end)\{(figure|wrapfigure|sidewaysfigure)\*?\}"
|
|
2338
|
+
r"|\\(caption|includegraphics)\b",
|
|
2339
|
+
code,
|
|
2340
|
+
):
|
|
2341
|
+
if m.group(1) == "begin":
|
|
2342
|
+
depth, captioned = depth + 1, captioned and depth > 0
|
|
2343
|
+
elif m.group(1) == "end":
|
|
2344
|
+
depth = max(0, depth - 1)
|
|
2345
|
+
if depth == 0 and not captioned:
|
|
2346
|
+
captionless = True
|
|
2347
|
+
elif m.group(3) == "caption":
|
|
2348
|
+
captioned = True
|
|
2349
|
+
elif depth == 0:
|
|
2350
|
+
captionless = True
|
|
2254
2351
|
w_body = doc.doc.find(calkit.docx._tag(calkit.docx.W, "body"))
|
|
2255
2352
|
assert w_body is not None
|
|
2256
2353
|
w_p = calkit.docx._tag(calkit.docx.W, "p")
|
|
@@ -2263,6 +2360,11 @@ def to_docx(
|
|
|
2263
2360
|
)
|
|
2264
2361
|
|
|
2265
2362
|
anchored = {p.element for p, b in zip(paras, matched) if b is not None}
|
|
2363
|
+
captions = {
|
|
2364
|
+
p.element
|
|
2365
|
+
for p, b in zip(paras, matched)
|
|
2366
|
+
if b is not None and b.floating
|
|
2367
|
+
}
|
|
2266
2368
|
|
|
2267
2369
|
def fragment(el: Any, context: str) -> bool:
|
|
2268
2370
|
# Never prose: anything anchored to the source, e.g., a caption
|
|
@@ -2276,7 +2378,7 @@ def to_docx(
|
|
|
2276
2378
|
prose_like = (
|
|
2277
2379
|
len(words) >= 2
|
|
2278
2380
|
and calkit.latex.similarity(text, context) >= 0.5
|
|
2279
|
-
and not
|
|
2381
|
+
and not calkit.latex.equation_fragment(text)
|
|
2280
2382
|
)
|
|
2281
2383
|
if el in anchored or len(words) > 3 or prose_like:
|
|
2282
2384
|
return False
|
|
@@ -2287,7 +2389,29 @@ def to_docx(
|
|
|
2287
2389
|
"wordprocessingDrawing}extent"
|
|
2288
2390
|
)
|
|
2289
2391
|
)
|
|
2290
|
-
|
|
2392
|
+
if not tall:
|
|
2393
|
+
return True
|
|
2394
|
+
if captionless:
|
|
2395
|
+
return False
|
|
2396
|
+
# A tall picture is a figure where there's a caption beside it,
|
|
2397
|
+
# past any other pictures, else a display drawn as one
|
|
2398
|
+
children = list(w_body)
|
|
2399
|
+
i = children.index(unit_of(el))
|
|
2400
|
+
for step in (-1, 1):
|
|
2401
|
+
k = i + step
|
|
2402
|
+
while (
|
|
2403
|
+
0 <= k < len(children) and not text_of(children[k]).strip()
|
|
2404
|
+
):
|
|
2405
|
+
k += step
|
|
2406
|
+
if not 0 <= k < len(children):
|
|
2407
|
+
continue
|
|
2408
|
+
near = children[k]
|
|
2409
|
+
if any(q in captions for q in near.iter(w_p)) or re.match(
|
|
2410
|
+
r"\s*(Figure|Fig\.|Table|Scheme|Chart)\s+[A-Z]?\.?\d",
|
|
2411
|
+
text_of(near),
|
|
2412
|
+
):
|
|
2413
|
+
return False
|
|
2414
|
+
return True
|
|
2291
2415
|
|
|
2292
2416
|
def unit_of(el: Any) -> Any:
|
|
2293
2417
|
while doc._parents.get(el) is not w_body:
|
|
@@ -2690,6 +2814,7 @@ def to_docx(
|
|
|
2690
2814
|
.isoformat(timespec="seconds")
|
|
2691
2815
|
.replace(":", "-")
|
|
2692
2816
|
)
|
|
2817
|
+
calkit.ensure_local_dir()
|
|
2693
2818
|
dirs = [calkit.latex.LOCAL_DOCX_EXPORTS_DIR]
|
|
2694
2819
|
if log:
|
|
2695
2820
|
dirs.append(calkit.latex.DOCX_EXPORTS_DIR)
|
|
@@ -3084,6 +3209,7 @@ def merge_docx(
|
|
|
3084
3209
|
.isoformat(timespec="seconds")
|
|
3085
3210
|
.replace(":", "-")
|
|
3086
3211
|
)
|
|
3212
|
+
calkit.ensure_local_dir()
|
|
3087
3213
|
dirs = [calkit.latex.LOCAL_DOCX_MERGES_DIR]
|
|
3088
3214
|
if log:
|
|
3089
3215
|
dirs.append(calkit.latex.DOCX_MERGES_DIR)
|
calkit/cli/main/core.py
CHANGED
|
@@ -3343,7 +3343,10 @@ def run(
|
|
|
3343
3343
|
step("Compiling DVC pipeline", "🔀")
|
|
3344
3344
|
try:
|
|
3345
3345
|
dvc_stages = calkit.pipeline.to_dvc(
|
|
3346
|
-
ck_info=ck_info,
|
|
3346
|
+
ck_info=ck_info,
|
|
3347
|
+
write=True,
|
|
3348
|
+
for_run=True,
|
|
3349
|
+
preprocess_stages=True,
|
|
3347
3350
|
)
|
|
3348
3351
|
except Exception as e:
|
|
3349
3352
|
os.environ.pop("CALKIT_PIPELINE_RUNNING", None)
|
calkit/cli/overleaf.py
CHANGED
|
@@ -365,6 +365,8 @@ def import_publication(
|
|
|
365
365
|
os.makedirs(dest_dir, exist_ok=True)
|
|
366
366
|
ck_info = calkit.load_calkit_info()
|
|
367
367
|
pubs = ck_info.get("publications", [])
|
|
368
|
+
if dest_dir in (ck_info.get("overleaf_sync") or {}):
|
|
369
|
+
raise_error(f"'{dest_dir}' is already synced with Overleaf")
|
|
368
370
|
# TODO: Don't allow the same Overleaf project ID in multiple publications
|
|
369
371
|
repo = calkit.git.get_repo()
|
|
370
372
|
# Clone the Overleaf project into .calkit/overleaf if it doesn't exist
|
|
@@ -400,34 +402,36 @@ def import_publication(
|
|
|
400
402
|
"please specify with --target"
|
|
401
403
|
)
|
|
402
404
|
return
|
|
405
|
+
# Determine the PDF output path
|
|
406
|
+
pdf_path = target_path.removesuffix(".tex") + ".pdf"
|
|
407
|
+
typer.echo(f"Using PDF path: {pdf_path}")
|
|
408
|
+
tex_path = pdf_path.removesuffix(".pdf") + ".tex"
|
|
409
|
+
target_tex_path = Path(dest_dir, tex_path).as_posix()
|
|
410
|
+
pub_path = Path(dest_dir, pdf_path).as_posix()
|
|
411
|
+
# A publication already in the project is linked as it is, unless asked
|
|
412
|
+
# to replace it
|
|
413
|
+
existing_pub = next((p for p in pubs if p.get("path") == pub_path), None)
|
|
414
|
+
if existing_pub is not None and overwrite:
|
|
415
|
+
# Note: This publication will go to the end of the list
|
|
416
|
+
pubs = [p for p in pubs if p.get("path") != pub_path]
|
|
417
|
+
existing_pub = None
|
|
418
|
+
if existing_pub is not None:
|
|
419
|
+
typer.echo(f"Using the publication already at {pub_path}")
|
|
403
420
|
# Try to extract title from the target LaTeX file if not provided
|
|
404
|
-
if not title:
|
|
405
|
-
|
|
406
|
-
|
|
421
|
+
if existing_pub is None and not title:
|
|
422
|
+
extracted_title = _extract_title_from_tex(
|
|
423
|
+
os.path.join(
|
|
424
|
+
dest_dir if push_only else overleaf_project_dir, target_path
|
|
425
|
+
)
|
|
407
426
|
)
|
|
408
|
-
extracted_title = _extract_title_from_tex(target_tex_path)
|
|
409
427
|
if extracted_title:
|
|
410
428
|
typer.echo(f"Detected title: {extracted_title}")
|
|
411
429
|
title = extracted_title
|
|
412
|
-
if not title:
|
|
430
|
+
if existing_pub is None and not title:
|
|
413
431
|
raise_error(
|
|
414
432
|
"Title could not be detected from the LaTeX file; "
|
|
415
433
|
"please specify with --title"
|
|
416
434
|
)
|
|
417
|
-
# Determine the PDF output path
|
|
418
|
-
pdf_path = target_path.removesuffix(".tex") + ".pdf" # type: ignore
|
|
419
|
-
typer.echo(f"Using PDF path: {pdf_path}")
|
|
420
|
-
tex_path = pdf_path.removesuffix(".pdf") + ".tex"
|
|
421
|
-
target_tex_path = Path(dest_dir, tex_path).as_posix()
|
|
422
|
-
pub_path = Path(dest_dir, pdf_path).as_posix()
|
|
423
|
-
pub_paths = [pub.get("path") for pub in pubs]
|
|
424
|
-
if not overwrite and pub_path in pub_paths:
|
|
425
|
-
raise_error(
|
|
426
|
-
f"A publication already exists in this project at {pub_path}"
|
|
427
|
-
)
|
|
428
|
-
elif overwrite and pub_path in pub_paths:
|
|
429
|
-
# Note: This publication will go to the end of the list
|
|
430
|
-
pubs = [p for p in pubs if p.get("path") != pub_path]
|
|
431
435
|
# Check that we have a LaTeX environment
|
|
432
436
|
typer.echo("Checking that this project has a LaTeX environment")
|
|
433
437
|
envs = ck_info.get("environments", {})
|
|
@@ -457,14 +461,18 @@ def import_publication(
|
|
|
457
461
|
pipeline = ck_info.get("pipeline", {})
|
|
458
462
|
stages = pipeline.get("stages", {})
|
|
459
463
|
stage_name = None
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
):
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
464
|
+
if existing_pub is not None and existing_pub.get("stage") in stages:
|
|
465
|
+
stage_name = existing_pub["stage"]
|
|
466
|
+
typer.echo(f"Using the publication's build stage '{stage_name}'")
|
|
467
|
+
else:
|
|
468
|
+
for stage_name_i, stage in stages.items():
|
|
469
|
+
if (
|
|
470
|
+
stage.get("kind") == "latex"
|
|
471
|
+
and stage.get("target_path") == target_tex_path
|
|
472
|
+
):
|
|
473
|
+
stage_name = stage_name_i
|
|
474
|
+
typer.echo(f"Found build stage '{stage_name}' in pipeline")
|
|
475
|
+
break
|
|
468
476
|
if stage_name is None:
|
|
469
477
|
# Create a new stage
|
|
470
478
|
stage_name = calkit.to_kebab_case("build-" + dest_dir)
|
|
@@ -482,21 +490,24 @@ def import_publication(
|
|
|
482
490
|
no_commit=True,
|
|
483
491
|
)
|
|
484
492
|
repo.git.add("calkit.yaml")
|
|
485
|
-
# Add to publications in calkit.yaml
|
|
486
|
-
typer.echo("Adding publication to calkit.yaml")
|
|
487
493
|
ck_info = calkit.load_calkit_info()
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
494
|
+
if existing_pub is None:
|
|
495
|
+
typer.echo("Adding publication to calkit.yaml")
|
|
496
|
+
pubs.append(
|
|
497
|
+
dict(
|
|
498
|
+
path=pub_path,
|
|
499
|
+
title=title,
|
|
500
|
+
description=description,
|
|
501
|
+
kind=kind,
|
|
502
|
+
stage=stage_name,
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
ck_info["publications"] = pubs
|
|
506
|
+
else:
|
|
507
|
+
for pub in ck_info.get("publications") or []:
|
|
508
|
+
if pub.get("path") == pub_path and not pub.get("stage"):
|
|
509
|
+
pub["stage"] = stage_name
|
|
497
510
|
ol_sync = ck_info.get("overleaf_sync", {})
|
|
498
|
-
if dest_dir in ol_sync:
|
|
499
|
-
raise_error(f"'{dest_dir}' is already synced with Overleaf")
|
|
500
511
|
ol_sync[dest_dir] = dict(url=src_url)
|
|
501
512
|
if push_paths:
|
|
502
513
|
ol_sync[dest_dir]["push_paths"] = push_paths
|
|
@@ -526,6 +537,65 @@ def import_publication(
|
|
|
526
537
|
)
|
|
527
538
|
|
|
528
539
|
|
|
540
|
+
@overleaf_app.command(name="link")
|
|
541
|
+
def link_publication(
|
|
542
|
+
folder: Annotated[
|
|
543
|
+
str,
|
|
544
|
+
typer.Argument(help="Folder in the project, e.g., 'paper'."),
|
|
545
|
+
],
|
|
546
|
+
project_url: Annotated[
|
|
547
|
+
str,
|
|
548
|
+
typer.Argument(
|
|
549
|
+
help=(
|
|
550
|
+
"Overleaf project URL, e.g., "
|
|
551
|
+
"https://www.overleaf.com/project/6800005973cb2e35."
|
|
552
|
+
)
|
|
553
|
+
),
|
|
554
|
+
],
|
|
555
|
+
title: Annotated[
|
|
556
|
+
str | None,
|
|
557
|
+
typer.Option(
|
|
558
|
+
"--title",
|
|
559
|
+
"-t",
|
|
560
|
+
help="Title, if the publication isn't in calkit.yaml yet.",
|
|
561
|
+
),
|
|
562
|
+
] = None,
|
|
563
|
+
target_path: Annotated[
|
|
564
|
+
str | None,
|
|
565
|
+
typer.Option("--target", "-T", help="Target TeX file in the folder."),
|
|
566
|
+
] = None,
|
|
567
|
+
push_paths: Annotated[
|
|
568
|
+
list[str],
|
|
569
|
+
typer.Option(
|
|
570
|
+
"--push-path",
|
|
571
|
+
"-p",
|
|
572
|
+
help="Paths in the folder to only push to Overleaf, e.g., 'figures'.",
|
|
573
|
+
),
|
|
574
|
+
] = [],
|
|
575
|
+
no_commit: Annotated[
|
|
576
|
+
bool,
|
|
577
|
+
typer.Option("--no-commit", help="Do not commit changes to repo."),
|
|
578
|
+
] = False,
|
|
579
|
+
) -> None:
|
|
580
|
+
"""Link a folder in the project to an Overleaf project, pushing its files
|
|
581
|
+
there.
|
|
582
|
+
|
|
583
|
+
Like 'import --push-only', for a document that starts in the project.
|
|
584
|
+
"""
|
|
585
|
+
import_publication(
|
|
586
|
+
src_url=project_url,
|
|
587
|
+
dest_dir=folder,
|
|
588
|
+
title=title,
|
|
589
|
+
target_path=target_path,
|
|
590
|
+
description=None,
|
|
591
|
+
kind=None,
|
|
592
|
+
push_paths=push_paths,
|
|
593
|
+
no_commit=no_commit,
|
|
594
|
+
overwrite=False,
|
|
595
|
+
push_only=True,
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
|
|
529
599
|
def _get_document_stages(folders: list[str]) -> dict[str, str]:
|
|
530
600
|
"""LaTeX stages that build a document in these folders, keyed to the
|
|
531
601
|
folder.
|
|
@@ -935,6 +1005,9 @@ def sync(
|
|
|
935
1005
|
continue
|
|
936
1006
|
if paths is not None and synced_folder not in paths:
|
|
937
1007
|
continue
|
|
1008
|
+
# Resolving a conflict only finishes the sync that hit it
|
|
1009
|
+
if resolve and synced_folder != resolving_info["wdir"]:
|
|
1010
|
+
continue
|
|
938
1011
|
overleaf_project_id = sync_data.get("project_id")
|
|
939
1012
|
if not overleaf_project_id:
|
|
940
1013
|
raise_error(
|
|
@@ -950,7 +1023,7 @@ def sync(
|
|
|
950
1023
|
f"Syncing {folder} with Overleaf project ID {overleaf_project_id}"
|
|
951
1024
|
)
|
|
952
1025
|
wdir = synced_folder
|
|
953
|
-
if resolve
|
|
1026
|
+
if resolve:
|
|
954
1027
|
repo.git.add(wdir)
|
|
955
1028
|
if repo.git.diff(["--staged", wdir]):
|
|
956
1029
|
repo.git.commit(
|
|
@@ -959,8 +1032,6 @@ def sync(
|
|
|
959
1032
|
if in_am_session:
|
|
960
1033
|
with calkit.overleaf.staged_changes_set_aside(repo):
|
|
961
1034
|
repo.git.am("--skip")
|
|
962
|
-
elif resolve:
|
|
963
|
-
continue
|
|
964
1035
|
# Local edits are saved first so they're what's synced and rebuilt
|
|
965
1036
|
# from. Untracked files aren't sent to Overleaf, so they're left out.
|
|
966
1037
|
if repo.git.diff(wdir) or repo.index.diff("HEAD", wdir):
|
calkit/cli/update.py
CHANGED
|
@@ -1679,7 +1679,10 @@ def update_path_storage(
|
|
|
1679
1679
|
# calkit.yaml has it now must be known to be up to date
|
|
1680
1680
|
if outputs and to == "dvc":
|
|
1681
1681
|
status = calkit.pipeline.get_status(
|
|
1682
|
-
ck_info=ck_info,
|
|
1682
|
+
ck_info=ck_info,
|
|
1683
|
+
check_environments=False,
|
|
1684
|
+
clean_notebooks=False,
|
|
1685
|
+
preprocess_stages=False,
|
|
1683
1686
|
)
|
|
1684
1687
|
if status.errors:
|
|
1685
1688
|
raise_error(
|
calkit/config.py
CHANGED
|
@@ -306,11 +306,67 @@ def set_secret(key: str, value: str) -> None:
|
|
|
306
306
|
"""Sets a secret using keyring, handling byte conversion for Linux."""
|
|
307
307
|
service_name = get_app_name()
|
|
308
308
|
username = _keyring_username(key)
|
|
309
|
+
|
|
310
|
+
def update_macos_item() -> bool:
|
|
311
|
+
# keyring replaces the item, which forgets every app the user chose
|
|
312
|
+
# to always allow, so they'd be asked again after each token refresh
|
|
313
|
+
import ctypes
|
|
314
|
+
import ctypes.util
|
|
315
|
+
|
|
316
|
+
import keyring.backends.macOS
|
|
317
|
+
|
|
318
|
+
if not isinstance(
|
|
319
|
+
keyring.get_keyring(), keyring.backends.macOS.Keyring
|
|
320
|
+
):
|
|
321
|
+
return False
|
|
322
|
+
from keyring.backends.macOS import api
|
|
323
|
+
|
|
324
|
+
# Our own handles, so keyring's private ones are left alone
|
|
325
|
+
found = ctypes.CDLL(ctypes.util.find_library("Foundation"))
|
|
326
|
+
sec = ctypes.CDLL(ctypes.util.find_library("Security"))
|
|
327
|
+
found.CFDataCreate.restype = ctypes.c_void_p
|
|
328
|
+
found.CFDataCreate.argtypes = (
|
|
329
|
+
ctypes.c_void_p,
|
|
330
|
+
ctypes.c_char_p,
|
|
331
|
+
ctypes.c_long,
|
|
332
|
+
)
|
|
333
|
+
found.CFRelease.restype = None
|
|
334
|
+
found.CFRelease.argtypes = (ctypes.c_void_p,)
|
|
335
|
+
sec.SecItemUpdate.restype = ctypes.c_int32
|
|
336
|
+
sec.SecItemUpdate.argtypes = (ctypes.c_void_p, ctypes.c_void_p)
|
|
337
|
+
data = value.encode("utf-8")
|
|
338
|
+
cf_data = found.CFDataCreate(None, data, len(data))
|
|
339
|
+
query = api.create_query(
|
|
340
|
+
kSecClass=api.k_("kSecClassGenericPassword"),
|
|
341
|
+
kSecAttrService=service_name,
|
|
342
|
+
kSecAttrAccount=username,
|
|
343
|
+
)
|
|
344
|
+
attrs = api.create_query(kSecValueData=ctypes.c_void_p(cf_data))
|
|
345
|
+
try:
|
|
346
|
+
status = sec.SecItemUpdate(query, attrs)
|
|
347
|
+
finally:
|
|
348
|
+
for ref in (cf_data, query, attrs):
|
|
349
|
+
if ref:
|
|
350
|
+
found.CFRelease(ref)
|
|
351
|
+
# Only a missing item may be added through keyring, since that
|
|
352
|
+
# replaces any item there, e.g., after the user denied the update
|
|
353
|
+
if status == -25300: # errSecItemNotFound
|
|
354
|
+
return False
|
|
355
|
+
if status == -128: # errSecUserCanceled
|
|
356
|
+
raise keyring.errors.KeyringLocked(
|
|
357
|
+
f"Can't store password on keychain: OSStatus {status}"
|
|
358
|
+
)
|
|
359
|
+
if status != 0:
|
|
360
|
+
raise keyring.errors.PasswordSetError(
|
|
361
|
+
f"Can't store password on keychain: OSStatus {status}"
|
|
362
|
+
)
|
|
363
|
+
return True
|
|
364
|
+
|
|
309
365
|
with _keyring_home():
|
|
310
366
|
if platform.system() == "Linux":
|
|
311
367
|
value_bytes = value.encode("utf-8")
|
|
312
368
|
keyring.set_password(service_name, username, value_bytes) # type: ignore
|
|
313
|
-
|
|
369
|
+
elif platform.system() != "Darwin" or not update_macos_item():
|
|
314
370
|
keyring.set_password(service_name, username, value)
|
|
315
371
|
_secret_cache[(service_name, username)] = value
|
|
316
372
|
|
|
@@ -526,6 +582,11 @@ class Settings(BaseSettings):
|
|
|
526
582
|
KeyringOptionalSecret in get_type_args(value.annotation)
|
|
527
583
|
) and key in cfg:
|
|
528
584
|
secret_val = cfg.pop(key)
|
|
585
|
+
# Only what changed, so a token refresh leaves the
|
|
586
|
+
# others, and who may read them, alone
|
|
587
|
+
stored = (get_app_name(), _keyring_username(key))
|
|
588
|
+
if _secret_cache.get(stored, ...) == secret_val:
|
|
589
|
+
continue
|
|
529
590
|
if secret_val is not None:
|
|
530
591
|
set_secret(key, secret_val)
|
|
531
592
|
else:
|