calkit-python 0.46.0__py3-none-any.whl → 0.47.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. calkit/__init__.py +34 -24
  2. calkit/cli/check.py +3 -1
  3. calkit/cli/latex.py +427 -0
  4. calkit/cli/new.py +48 -33
  5. calkit/docx.py +692 -0
  6. calkit/dvc/core.py +17 -0
  7. calkit/environments.py +2 -1
  8. calkit/latex.py +451 -1
  9. calkit/models/core.py +29 -18
  10. calkit/models/docx.py +79 -0
  11. calkit/models/pipeline.py +12 -4
  12. calkit/pipeline.py +25 -5
  13. calkit/tests/cli/test_new.py +43 -19
  14. calkit/tests/models/test_core.py +24 -0
  15. calkit/tests/models/test_pipeline.py +1 -1
  16. calkit/tests/test_docx.py +397 -0
  17. {calkit_python-0.46.0.dist-info → calkit_python-0.47.0.dist-info}/METADATA +1 -1
  18. {calkit_python-0.46.0.dist-info → calkit_python-0.47.0.dist-info}/RECORD +35 -32
  19. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  20. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  21. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  22. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  23. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
  24. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
  25. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
  26. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/909.6d8285ce7c45878ac508.js +0 -0
  27. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
  28. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
  29. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
  30. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.d7a43c7948f690d37d19.js +0 -0
  31. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  32. {calkit_python-0.46.0.data → calkit_python-0.47.0.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  33. {calkit_python-0.46.0.dist-info → calkit_python-0.47.0.dist-info}/WHEEL +0 -0
  34. {calkit_python-0.46.0.dist-info → calkit_python-0.47.0.dist-info}/entry_points.txt +0 -0
  35. {calkit_python-0.46.0.dist-info → calkit_python-0.47.0.dist-info}/licenses/LICENSE +0 -0
calkit/__init__.py CHANGED
@@ -16,6 +16,7 @@ if TYPE_CHECKING:
16
16
  dependencies,
17
17
  detect,
18
18
  docker,
19
+ docx,
19
20
  dvc,
20
21
  environments,
21
22
  fs,
@@ -27,8 +28,10 @@ if TYPE_CHECKING:
27
28
  invenio,
28
29
  julia,
29
30
  jupyter,
31
+ latex,
30
32
  licenses,
31
33
  magics,
34
+ markdown,
32
35
  matlab,
33
36
  models,
34
37
  notebooks,
@@ -38,53 +41,60 @@ if TYPE_CHECKING:
38
41
  pipeline,
39
42
  procedures,
40
43
  provenance,
44
+ questions,
41
45
  releases,
42
46
  reproducibility,
43
47
  resources,
44
48
  schema,
45
49
  server,
46
50
  templates,
51
+ workspace,
47
52
  )
48
53
 
49
54
  from .notebooks import declare_notebook # noqa: F401
50
55
 
51
56
  # Lazy-load submodules to speed up imports
52
57
  _SUBMODULES = {
53
- "git",
54
- "dvc",
55
- "hub",
56
- "fs",
57
- "jupyter",
58
- "config",
59
- "models",
60
- "office",
61
- "templates",
62
- "conda",
63
58
  "calc",
64
- "procedures",
65
- "provenance",
66
- "reproducibility",
67
- "github",
68
- "invenio",
69
- "releases",
70
- "resources",
71
- "licenses",
72
- "overleaf",
73
- "julia",
74
- "notebooks",
75
- "environments",
76
- "pipeline",
77
- "matlab",
59
+ "conda",
60
+ "config",
78
61
  "datasets",
79
62
  "dependencies",
80
63
  "detect",
81
64
  "docker",
65
+ "docx",
66
+ "dvc",
67
+ "environments",
68
+ "fs",
69
+ "git",
70
+ "github",
82
71
  "gui",
72
+ "hub",
83
73
  "install",
74
+ "invenio",
75
+ "julia",
76
+ "jupyter",
77
+ "latex",
78
+ "licenses",
84
79
  "magics",
80
+ "markdown",
81
+ "matlab",
82
+ "models",
83
+ "notebooks",
84
+ "office",
85
85
  "ops",
86
+ "overleaf",
87
+ "pipeline",
88
+ "procedures",
89
+ "provenance",
90
+ "questions",
91
+ "releases",
92
+ "reproducibility",
93
+ "resources",
86
94
  "schema",
87
95
  "server",
96
+ "templates",
97
+ "workspace",
88
98
  }
89
99
 
90
100
 
calkit/cli/check.py CHANGED
@@ -1517,7 +1517,9 @@ def check_docker_env(
1517
1517
  deps_md5s=deps_md5s,
1518
1518
  run_config=run_config,
1519
1519
  )
1520
- with open(arch_lock_fpath, "w") as f:
1520
+ # newline="\n" so the file is byte-identical on every platform,
1521
+ # rather than a Windows checkout flipping it to CRLF.
1522
+ with open(arch_lock_fpath, "w", newline="\n") as f:
1521
1523
  json.dump(arch_lock, f, indent=4)
1522
1524
 
1523
1525
  current_arch = get_docker_arch()
calkit/cli/latex.py CHANGED
@@ -613,3 +613,430 @@ def _build_diff(
613
613
  finally:
614
614
  if not keep_tex and os.path.isfile(diff_tex):
615
615
  os.remove(diff_tex)
616
+
617
+
618
+ @latex_app.command(name="to-docx")
619
+ def to_docx(
620
+ pdf_path: Annotated[str, typer.Argument(help="Compiled PDF to export.")],
621
+ source: Annotated[
622
+ str | None,
623
+ typer.Option(
624
+ "--source",
625
+ help=(
626
+ "Main .tex file. Defaults to the pipeline stage's target, "
627
+ "else the .tex next to the PDF."
628
+ ),
629
+ ),
630
+ ] = None,
631
+ output: Annotated[
632
+ str | None,
633
+ typer.Option(
634
+ "--output",
635
+ "-o",
636
+ help="Where to write the .docx. Defaults to <pdf>-for-review.docx.",
637
+ ),
638
+ ] = None,
639
+ comment_only: Annotated[
640
+ bool,
641
+ typer.Option("--comment-only", help="Lock the document to comments."),
642
+ ] = False,
643
+ force: Annotated[
644
+ bool,
645
+ typer.Option("--force", "-f", help="Overwrite an existing export."),
646
+ ] = False,
647
+ ) -> None:
648
+ """Export a Word copy of a LaTeX document for review.
649
+
650
+ Uses Word's own PDF import, so the copy looks like the PDF, then records
651
+ inside the file which source line each paragraph came from and the text
652
+ as sent, so ``merge-docx`` can bring edits and comments back.
653
+ """
654
+ import datetime
655
+ import sys
656
+ import uuid
657
+ from typing import Any
658
+
659
+ import calkit.docx
660
+ import calkit.git
661
+ import calkit.pipeline
662
+ from calkit.models.docx import LatexDocxExport
663
+ from calkit.models.pipeline import LatexStage
664
+
665
+ ck_info = calkit.load_calkit_info()
666
+ stages = ck_info.get("pipeline", {}).get("stages", {})
667
+ stage_name = calkit.pipeline.get_stage_for_output(pdf_path, ck_info)
668
+ # A latex stage's PDF is implied by its target, not listed as an output
669
+ if stage_name is None:
670
+ for name, stage in stages.items():
671
+ if isinstance(stage, dict) and stage.get("kind") == "latex":
672
+ pdf = LatexStage.model_validate(stage).pdf_path
673
+ if pdf == Path(pdf_path).as_posix():
674
+ stage_name = name
675
+ break
676
+ if source is None and stage_name is not None:
677
+ source = stages[stage_name].get("target_path")
678
+ if source is None:
679
+ source = Path(pdf_path).with_suffix(".tex").as_posix()
680
+ source = Path(source).as_posix()
681
+ if not os.path.isfile(source):
682
+ raise_error(f"Source {source} does not exist; pass --source")
683
+ if stage_name is not None:
684
+ typer.echo(f"Running stage {stage_name} to make sure the PDF is fresh")
685
+ subprocess.run(
686
+ [sys.executable, "-m", "calkit", "run", stage_name], check=True
687
+ )
688
+ if not os.path.isfile(pdf_path):
689
+ raise_error(f"{pdf_path} does not exist")
690
+ if output is None:
691
+ output = (
692
+ Path(pdf_path)
693
+ .with_name(Path(pdf_path).stem + "-for-review.docx")
694
+ .as_posix()
695
+ )
696
+ if os.path.exists(output) and not force:
697
+ raise_error(f"{output} already exists; use --force to overwrite it")
698
+ typer.echo("Converting the PDF with Word")
699
+ try:
700
+ calkit.docx.pdf_to_docx(pdf_path, output)
701
+ except RuntimeError as e:
702
+ raise_error(str(e))
703
+ doc = calkit.docx.Document(output)
704
+ lines = calkit.latex.flatten(source)
705
+ blks = calkit.latex.blocks(lines)
706
+ paras = doc.paragraphs
707
+ matched = calkit.latex.align([p.text for p in paras], blks)
708
+ # Bookmark each anchored paragraph; a block rendering as several
709
+ # paragraphs gets numbered suffixes
710
+ original: dict[str, str] = {}
711
+ para_for_block: dict[int, Any] = {}
712
+ seen: dict[str, int] = {}
713
+ for i, (para, blk) in enumerate(zip(paras, matched)):
714
+ if blk is None or para.element is None:
715
+ continue
716
+ name = calkit.latex.make_bookmark_name(blk.path, blk.lineno)
717
+ seen[name] = seen.get(name, 0) + 1
718
+ if seen[name] > 1:
719
+ name = f"{name}_{seen[name]}"
720
+ doc.add_bookmark(para.element, name, 9000 + i)
721
+ original[name] = para.text
722
+ para_for_block.setdefault(id(blk), para.element)
723
+ # Existing comment blocks in the source go out as Word comments
724
+ threads, anchors, highlights, resolved = [], [], [], []
725
+ for path in sorted({ln.path for ln in lines}):
726
+ file_lines = Path(path).read_text(encoding="utf-8").split("\n")
727
+ for tc in calkit.latex.parse_comments(file_lines):
728
+ after = tc.lineno + tc.nlines
729
+ blk = next(
730
+ (b for b in blks if b.path == path and b.lineno >= after), None
731
+ )
732
+ if blk is not None and id(blk) in para_for_block:
733
+ threads.append(tc.messages())
734
+ anchors.append(para_for_block[id(blk)])
735
+ highlights.append(
736
+ (tc.highlight, tc.highlight_occ) if tc.highlight else None
737
+ )
738
+ resolved.append(tc.resolved)
739
+ doc.add_comments(threads, anchors, highlights, resolved)
740
+ if comment_only:
741
+ doc.protect("comments")
742
+ else:
743
+ doc.track_changes()
744
+ doc.show_all_markup()
745
+ rev, dirty = None, False
746
+ try:
747
+ repo = calkit.git.get_repo()
748
+ rev = repo.head.commit.hexsha
749
+ dirty = any(repo.is_dirty(path=p) for p in {ln.path for ln in lines})
750
+ except Exception:
751
+ pass
752
+ export_id = str(uuid.uuid4())
753
+ doc.write_original(
754
+ calkit.docx.Original(
755
+ export_id, rev, source, original, doc.media_hashes
756
+ )
757
+ )
758
+ doc.set_identifier(f"calkit-latex-export:{export_id}:{rev or ''}:{source}")
759
+ doc.save()
760
+ record = LatexDocxExport(
761
+ id=export_id,
762
+ created=datetime.datetime.now(datetime.timezone.utc),
763
+ source=source,
764
+ pdf=Path(pdf_path).as_posix(),
765
+ docx=Path(output).as_posix(),
766
+ rev=rev,
767
+ dirty=dirty,
768
+ permission="comment" if comment_only else "suggest",
769
+ paragraphs=len(original),
770
+ unanchored=sum(
771
+ 1 for p, m in zip(paras, matched) if m is None and p.text
772
+ ),
773
+ comments_exported=len(threads),
774
+ files={
775
+ p: "md5:" + calkit.get_md5(p)
776
+ for p in sorted(
777
+ {ln.path for ln in lines}
778
+ | {Path(pdf_path).as_posix(), Path(output).as_posix()}
779
+ )
780
+ },
781
+ )
782
+ os.makedirs(calkit.latex.DOCX_EXPORTS_DIR, exist_ok=True)
783
+ with open(
784
+ os.path.join(calkit.latex.DOCX_EXPORTS_DIR, f"{export_id}.json"),
785
+ "w",
786
+ encoding="utf-8",
787
+ newline="\n",
788
+ ) as f:
789
+ f.write(record.model_dump_json(indent=2))
790
+ typer.echo(
791
+ f"Wrote {output} ({record.paragraphs} paragraphs anchored, "
792
+ f"{record.unanchored} not, {len(threads)} comments)"
793
+ )
794
+
795
+
796
+ @latex_app.command(name="merge-docx")
797
+ def merge_docx(
798
+ docx_path: Annotated[str, typer.Argument(help="Reviewed .docx to merge.")],
799
+ no_comments: Annotated[
800
+ bool,
801
+ typer.Option(
802
+ "--no-comments", help="Don't write comments to the .tex."
803
+ ),
804
+ ] = False,
805
+ ) -> None:
806
+ """Merge a reviewed Word document back into the LaTeX source.
807
+
808
+ Accepted changes are applied. Tracked changes not yet accepted or
809
+ rejected, and edits that no longer fit the source, are left alone with
810
+ a warning: deal with them in Word and merge again. Comments become
811
+ comment blocks above the paragraph; threads resolved in Word are
812
+ marked resolved.
813
+ """
814
+ import datetime
815
+
816
+ import calkit.docx
817
+ import calkit.git
818
+ from calkit.cli.core import warn
819
+ from calkit.models.docx import LatexDocxMerge, LatexDocxMergeChange
820
+
821
+ doc = calkit.docx.Document(docx_path)
822
+ original = doc.read_original()
823
+ if original is None:
824
+ raise_error(
825
+ f"{docx_path} was not exported by Calkit, or its metadata was "
826
+ "stripped by another application"
827
+ )
828
+ if not os.path.isfile(original.source):
829
+ raise_error(f"Source {original.source} does not exist")
830
+ # Figures come from the pipeline, so a picture swapped or edited in
831
+ # Word can't be merged
832
+ media = doc.media_hashes
833
+ changed = sorted(
834
+ name
835
+ for name in set(media) | set(original.media)
836
+ if media.get(name) != original.media.get(name)
837
+ )
838
+ if changed:
839
+ warn(
840
+ "Figures were changed in Word and can't be merged; edit the "
841
+ "pipeline instead: " + ", ".join(changed)
842
+ )
843
+ lines = calkit.latex.flatten(original.source)
844
+ blks = calkit.latex.blocks(lines)
845
+ path_for_hash = {
846
+ calkit.latex.make_bookmark_name(p, 0).split("_")[1]: p
847
+ for p in {ln.path for ln in lines}
848
+ }
849
+ edits: dict[str, list[tuple[int, int, list[str]]]] = {}
850
+ changes: list[LatexDocxMergeChange] = []
851
+ for para in doc.paragraphs:
852
+ if para.bookmark is None or para.bookmark not in original.paragraphs:
853
+ continue
854
+ sent = original.paragraphs[para.bookmark]
855
+ if para.text == sent:
856
+ continue
857
+ parts = para.bookmark.split("_")
858
+ path, lineno = path_for_hash.get(parts[1], ""), int(parts[2])
859
+ blk = calkit.latex.find_block(
860
+ blks, path, lineno, sent
861
+ ) or calkit.latex.find_block(blks, path, lineno, para.text)
862
+ if blk is None:
863
+ warn(f"Can't place an edit from {path}:{lineno}: {para.text[:60]}")
864
+ changes.append(
865
+ LatexDocxMergeChange(
866
+ path=path, lineno=lineno, status="unplaced"
867
+ )
868
+ )
869
+ continue
870
+ loc = f"{blk.path}:{blk.lineno}"
871
+ if para.pending:
872
+ warn(f"Tracked change at {loc} not yet accepted or rejected")
873
+ changes.append(
874
+ LatexDocxMergeChange(
875
+ path=blk.path,
876
+ lineno=blk.lineno,
877
+ status="pending",
878
+ author=", ".join(para.authors) or None,
879
+ )
880
+ )
881
+ continue
882
+ sent_tex = calkit.latex.from_word_text(sent)
883
+ new_tex = calkit.latex.from_word_text(para.text)
884
+ if calkit.latex.already_applied(blk, sent_tex, new_tex):
885
+ changes.append(
886
+ LatexDocxMergeChange(
887
+ path=blk.path, lineno=blk.lineno, status="already-applied"
888
+ )
889
+ )
890
+ continue
891
+ new_lines = calkit.latex.apply_edit(blk, sent_tex, new_tex)
892
+ if new_lines is None:
893
+ warn(
894
+ f"Edit at {loc} touches markup; apply it by hand: {para.text[:60]}"
895
+ )
896
+ changes.append(
897
+ LatexDocxMergeChange(
898
+ path=blk.path, lineno=blk.lineno, status="unplaced"
899
+ )
900
+ )
901
+ continue
902
+ edits.setdefault(blk.path, []).append(
903
+ (blk.lineno, len(blk.lines), new_lines)
904
+ )
905
+ changes.append(
906
+ LatexDocxMergeChange(
907
+ path=blk.path, lineno=blk.lineno, status="applied"
908
+ )
909
+ )
910
+ typer.echo(f"Applied edit at {loc}")
911
+ files = {
912
+ p: Path(p).read_text(encoding="utf-8").split("\n")
913
+ for p in {ln.path for ln in lines}
914
+ }
915
+ for path, updates in edits.items():
916
+ for lineno, count, new_lines in sorted(updates, reverse=True):
917
+ files[path][lineno - 1 : lineno - 1 + count] = new_lines
918
+ added = updated = 0
919
+ if not no_comments:
920
+ # Threads keyed by root, anchored through the root's bookmark
921
+ comments = doc.comments
922
+ by_id = {c.para_id: c for c in comments}
923
+ roots = [c for c in comments if not c.parent_id]
924
+ # Resolve every thread to a block first, then edit each file from
925
+ # the bottom up so earlier line numbers stay valid
926
+ placed: list[tuple[calkit.latex.Block, calkit.latex.TexComment]] = []
927
+ for root in roots:
928
+ thread = [root] + [
929
+ c
930
+ for c in comments
931
+ if c.parent_id and by_id.get(c.parent_id) is root
932
+ ]
933
+ tc = calkit.latex.TexComment(
934
+ [
935
+ calkit.latex.Entry(
936
+ c.author, c.text, date=calkit.latex.word_date(c.date)
937
+ )
938
+ for c in thread
939
+ ],
940
+ highlight=root.highlight,
941
+ highlight_occ=root.highlight_occ,
942
+ resolved=root.done,
943
+ )
944
+ if root.bookmark is None:
945
+ warn(
946
+ f"Comment by {root.author} has no anchor: {root.text[:60]}"
947
+ )
948
+ continue
949
+ parts = root.bookmark.split("_")
950
+ path, lineno = path_for_hash.get(parts[1], ""), int(parts[2])
951
+ blk = calkit.latex.find_block(
952
+ blks, path, lineno, original.paragraphs.get(root.bookmark, "")
953
+ )
954
+ if blk is None:
955
+ warn(
956
+ f"Can't place a comment by {root.author}: {root.text[:60]}"
957
+ )
958
+ continue
959
+ placed.append((blk, tc))
960
+ for blk, tc in sorted(
961
+ placed, key=lambda x: (x[0].path, x[0].lineno), reverse=True
962
+ ):
963
+ content = files[blk.path]
964
+ at = blk.lineno
965
+ existing = next(
966
+ (
967
+ e
968
+ for e in calkit.latex.parse_comments(content)
969
+ if e.author == tc.author and e.text == tc.text
970
+ ),
971
+ None,
972
+ )
973
+ if existing is not None:
974
+ if (
975
+ existing.messages() == tc.messages()
976
+ and existing.resolved == tc.resolved
977
+ ):
978
+ continue
979
+ # Word knows neither emails nor what the source already
980
+ # recorded, so carry those over for unchanged messages
981
+ known = {(e.author, e.text): e for e in existing.entries}
982
+ for e in tc.entries:
983
+ old_e = known.get((e.author, e.text))
984
+ if old_e is not None:
985
+ e.email = old_e.email
986
+ e.date = old_e.date or e.date
987
+ del content[
988
+ existing.lineno - 1 : existing.lineno - 1 + existing.nlines
989
+ ]
990
+ if existing.lineno < at:
991
+ at -= existing.nlines
992
+ content[at - 1 : at - 1] = tc.render()
993
+ added += existing is None
994
+ updated += existing is not None
995
+ for path, content in files.items():
996
+ new = "\n".join(content)
997
+ if new != Path(path).read_text(encoding="utf-8"):
998
+ Path(path).write_text(new, encoding="utf-8")
999
+ rev = None
1000
+ try:
1001
+ rev = calkit.git.get_repo().head.commit.hexsha
1002
+ except Exception:
1003
+ pass
1004
+ seen = {a for p in doc.paragraphs for a in p.authors}
1005
+ seen |= {c.author for c in doc.comments}
1006
+ record = LatexDocxMerge(
1007
+ export_id=original.id,
1008
+ created=datetime.datetime.now(datetime.timezone.utc),
1009
+ docx=Path(docx_path).as_posix(),
1010
+ rev=rev,
1011
+ authors=sorted(seen),
1012
+ last_modified_by=doc.last_modified_by,
1013
+ changes=changes,
1014
+ comments_added=added,
1015
+ comments_updated=updated,
1016
+ files={
1017
+ p: "md5:" + calkit.get_md5(p)
1018
+ for p in sorted(
1019
+ {ln.path for ln in lines} | {Path(docx_path).as_posix()}
1020
+ )
1021
+ },
1022
+ )
1023
+ os.makedirs(calkit.latex.DOCX_MERGES_DIR, exist_ok=True)
1024
+ stamp = record.created.strftime("%Y%m%dT%H%M%S.%fZ")
1025
+ with open(
1026
+ os.path.join(
1027
+ calkit.latex.DOCX_MERGES_DIR, f"{original.id}-{stamp}.json"
1028
+ ),
1029
+ "w",
1030
+ encoding="utf-8",
1031
+ newline="\n",
1032
+ ) as f:
1033
+ f.write(record.model_dump_json(indent=2))
1034
+ counts = {
1035
+ s: sum(1 for c in changes if c.status == s)
1036
+ for s in ("applied", "already-applied", "pending", "unplaced")
1037
+ }
1038
+ typer.echo(
1039
+ f"Applied {counts['applied']} edits ({counts['already-applied']} "
1040
+ f"already there, {counts['pending']} pending, {counts['unplaced']} "
1041
+ f"unplaced); {added} comments added, {updated} updated"
1042
+ )
calkit/cli/new.py CHANGED
@@ -65,22 +65,37 @@ def _person_from_options(
65
65
  return person.model_dump(exclude_none=True)
66
66
 
67
67
 
68
- def _split_template_subdir(
69
- template: str, git_url: str
70
- ) -> tuple[str, str | None]:
71
- """Split a directory out of an 'owner/repo/path' template.
68
+ def _parse_template(
69
+ template: str, hub_url: str
70
+ ) -> tuple[str, str | None, str | None]:
71
+ """Resolve a template to its name, Git URL, and directory in the repo.
72
72
 
73
- One repo can hold several self-contained example projects, so a
74
- template may name a directory within it. Only the shorthand form is
75
- split; a full URL is left alone, since its path is the repo's.
73
+ 'owner/project[/dir]', or that under the hub URL, names a project on
74
+ the hub, whose Git URL is looked up later, so it comes back as None.
75
+ An HTTPS or SSH URL works on any host, e.g.,
76
+ 'https://github.com/owner/repo/dir'. One repo can hold several
77
+ self-contained example projects, so a template may name a directory
78
+ within it; the first two path segments are the repo. Other URLs,
79
+ e.g., 'file://', are used as is.
76
80
  """
77
- if "://" in template or "github.com" in template:
78
- return git_url, None
79
- parts = template.strip("/").split("/")
80
- if len(parts) <= 2:
81
- return git_url, None
82
- subdir = "/".join(parts[2:])
83
- return git_url.rsplit("/" + subdir, 1)[0], subdir
81
+ import re
82
+
83
+ hub_url = hub_url.rstrip("/")
84
+ for prefix in (hub_url + "/", hub_url.split("://", 1)[-1] + "/"):
85
+ if template.startswith(prefix):
86
+ template = template.removeprefix(prefix)
87
+ break
88
+ m = re.match(r"^(https?://[^/]+/|[\w.-]+@[\w.-]+:)(.+)$", template)
89
+ if m is None and "://" in template:
90
+ return template, template, None
91
+ base, path = m.groups() if m else (None, template)
92
+ parts = path.strip("/").split("/")
93
+ if len(parts) < 2:
94
+ raise_error(f"Template '{template}' should be 'owner/project'")
95
+ parts[1] = parts[1].removesuffix(".git")
96
+ subdir = "/".join(parts[2:]) or None
97
+ url = base + "/".join(parts[:2]) if base else None
98
+ return "/".join(parts), url, subdir
84
99
 
85
100
 
86
101
  @new_app.command(name="project", cls=_NewProjectCommand)
@@ -137,10 +152,12 @@ def new_project(
137
152
  str | None,
138
153
  typer.Option(
139
154
  "--template",
155
+ "--from",
140
156
  "-t",
141
157
  help=(
142
- "Template from which to derive the project, e.g., "
143
- "'calkit/example-basic'."
158
+ "Template from which to derive the project: a hub project "
159
+ "as 'owner/project' or its hub URL, or a Git URL on any "
160
+ "host, e.g., 'https://github.com/owner/repo/dir'."
144
161
  ),
145
162
  ),
146
163
  ] = None,
@@ -412,24 +429,22 @@ def new_project(
412
429
  return
413
430
  # If using a template, clone it first
414
431
  if template:
415
- # TODO: If the template is not a Git repo URL, make a request to the
416
- # the hub to get it?
417
- # For now, assume consistency between hub projects and
418
- # GitHub repo URLs
419
- if "github.com" in template:
420
- template_git_url = template
421
- template_name = template.split("github.com")[-1][1:].removesuffix(
422
- ".git"
423
- )
424
- else:
425
- template_name = template
426
- template_git_url = f"https://github.com/{template}"
427
- # A template can name a directory inside a repo, e.g.
428
- # 'calkit/calkit/examples/markdown', so one repo can hold several
429
- # self-contained examples.
430
- template_git_url, template_subdir = _split_template_subdir(
431
- template, template_git_url
432
+ template_name, template_git_url, template_subdir = _parse_template(
433
+ template, calkit.hub.get_hub_url()
432
434
  )
435
+ if template_git_url is None:
436
+ project = "/".join(template_name.split("/")[:2])
437
+ typer.echo(f"Fetching Git repo URL for {project} from the hub")
438
+ try:
439
+ template_git_url = calkit.hub.get(f"/projects/{project}")[
440
+ "git_repo_url"
441
+ ]
442
+ except Exception as e:
443
+ raise_error(
444
+ f"Could not fetch project {project} from the hub ({e}); "
445
+ "for a repo not on the hub, pass its URL, e.g., "
446
+ f"https://github.com/{template_name}"
447
+ )
433
448
  if template_subdir is None:
434
449
  # Now clone it
435
450
  subprocess.run(["git", "clone", template_git_url, abs_path])