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.
Files changed (38) hide show
  1. calkit/cli/check.py +3 -1
  2. calkit/cli/latex.py +133 -7
  3. calkit/cli/main/core.py +4 -1
  4. calkit/cli/overleaf.py +114 -43
  5. calkit/cli/update.py +4 -1
  6. calkit/config.py +62 -1
  7. calkit/docx.py +288 -58
  8. calkit/latex.py +830 -29
  9. calkit/models/pipeline.py +166 -6
  10. calkit/overleaf.py +183 -14
  11. calkit/pipeline.py +27 -3
  12. calkit/resources/README.md +1 -0
  13. calkit/resources/latex/calkit.sty +208 -0
  14. calkit/tests/cli/test_latex.py +212 -0
  15. calkit/tests/cli/test_overleaf.py +85 -2
  16. calkit/tests/test_config.py +66 -0
  17. calkit/tests/test_docx.py +170 -0
  18. calkit/tests/test_latex.py +493 -0
  19. calkit/tests/test_overleaf.py +143 -4
  20. calkit/tests/test_pipeline.py +248 -0
  21. {calkit_python-0.49.5.dist-info → calkit_python-0.50.0.dist-info}/METADATA +2 -1
  22. {calkit_python-0.49.5.dist-info → calkit_python-0.50.0.dist-info}/RECORD +38 -37
  23. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  24. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  25. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  26. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  27. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  28. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  29. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  30. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  31. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  32. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  33. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  34. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  35. {calkit_python-0.49.5.data → calkit_python-0.50.0.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  36. {calkit_python-0.49.5.dist-info → calkit_python-0.50.0.dist-info}/WHEEL +0 -0
  37. {calkit_python-0.49.5.dist-info → calkit_python-0.50.0.dist-info}/entry_points.txt +0 -0
  38. {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, clean_notebooks=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|provide)?newcommand\*?\s*(?:\{\s*\\([A-Za-z@]+)\s*\}"
770
- r"|\\([A-Za-z@]+))\s*\[1\]|def\s*\\([A-Za-z@]+)#1)\s*(?=\{)"
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
- return detected + pointed + pipeline_inputs
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(str(e))
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 re.search(eq_num + r"\s*$", text)
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
- return not tall
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, write=True, for_run=True
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
- target_tex_path = os.path.join(
406
- dest_dir if push_only else overleaf_project_dir, target_path
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
- for stage_name_i, stage in stages.items():
461
- if (
462
- stage.get("kind") == "latex"
463
- and stage.get("target_path") == target_tex_path
464
- ):
465
- stage_name = stage_name_i
466
- typer.echo(f"Found build stage '{stage_name}' in pipeline")
467
- break
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
- new_pub = dict(
489
- path=pub_path,
490
- title=title,
491
- description=description,
492
- kind=kind,
493
- stage=stage_name,
494
- )
495
- pubs.append(new_pub)
496
- ck_info["publications"] = pubs
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 and wdir == resolving_info["wdir"]:
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, check_environments=False, clean_notebooks=False
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
- else:
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: