calkit-python 0.48.0__py3-none-any.whl → 0.48.2__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 (43) hide show
  1. calkit/cli/latex.py +487 -1
  2. calkit/cli/update.py +36 -13
  3. calkit/core.py +94 -0
  4. calkit/docx.py +68 -9
  5. calkit/dvc/core.py +12 -2
  6. calkit/fs.py +7 -3
  7. calkit/git.py +4 -2
  8. calkit/hub.py +8 -1
  9. calkit/latex.py +260 -10
  10. calkit/markdown.py +156 -5
  11. calkit/models/pipeline.py +126 -3
  12. calkit/pipeline.py +4 -1
  13. calkit/resources/devcontainer/devcontainer.json +1 -1
  14. calkit/resources/vscode/settings.json +1 -1
  15. calkit/tests/cli/main/test_core.py +15 -0
  16. calkit/tests/cli/test_latex.py +239 -0
  17. calkit/tests/cli/test_notebooks.py +6 -0
  18. calkit/tests/cli/test_update.py +51 -5
  19. calkit/tests/dvc/test_core.py +45 -0
  20. calkit/tests/models/test_pipeline.py +59 -0
  21. calkit/tests/test_core.py +50 -0
  22. calkit/tests/test_docx.py +90 -3
  23. calkit/tests/test_git.py +29 -0
  24. calkit/tests/test_hub.py +8 -8
  25. calkit/tests/test_markdown.py +88 -0
  26. {calkit_python-0.48.0.dist-info → calkit_python-0.48.2.dist-info}/METADATA +2 -1
  27. {calkit_python-0.48.0.dist-info → calkit_python-0.48.2.dist-info}/RECORD +43 -43
  28. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  29. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  30. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  31. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  32. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  33. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  34. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  35. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  36. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  37. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  38. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  39. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  40. {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  41. {calkit_python-0.48.0.dist-info → calkit_python-0.48.2.dist-info}/WHEEL +0 -0
  42. {calkit_python-0.48.0.dist-info → calkit_python-0.48.2.dist-info}/entry_points.txt +0 -0
  43. {calkit_python-0.48.0.dist-info → calkit_python-0.48.2.dist-info}/licenses/LICENSE +0 -0
calkit/cli/latex.py CHANGED
@@ -10,6 +10,7 @@ import shutil
10
10
  import string
11
11
  import subprocess
12
12
  import sys
13
+ from collections.abc import Callable
13
14
  from copy import deepcopy
14
15
  from pathlib import Path
15
16
 
@@ -189,6 +190,186 @@ def from_questions(
189
190
  json2latex.dump(command_name, values, f)
190
191
 
191
192
 
193
+ @latex_app.command(name="from-markdown")
194
+ def from_markdown(
195
+ md_path: Annotated[
196
+ str, typer.Argument(help="The Markdown file to build.")
197
+ ],
198
+ outputs: Annotated[
199
+ list[str],
200
+ typer.Option(
201
+ "--output",
202
+ "-o",
203
+ help="Where to write the PDF, or the LaTeX with a .tex "
204
+ "extension; can be given more than once.",
205
+ ),
206
+ ],
207
+ environment: Annotated[
208
+ str | None,
209
+ typer.Option(
210
+ "--environment",
211
+ "-e",
212
+ help="Environment to run pandoc and LaTeX in, e.g., one using "
213
+ "Calkit's LaTeX image, which includes both.",
214
+ ),
215
+ ] = None,
216
+ template_path: Annotated[
217
+ str | None, typer.Option("--template", help="Pandoc template.")
218
+ ] = None,
219
+ filters: Annotated[
220
+ list[str] | None,
221
+ typer.Option(
222
+ "--filter", help="Pandoc Lua filter; can be given more than once."
223
+ ),
224
+ ] = None,
225
+ pandoc_args: Annotated[
226
+ list[str] | None,
227
+ typer.Option(
228
+ "--pandoc-arg",
229
+ help="Another argument for pandoc; can be given more than once.",
230
+ ),
231
+ ] = None,
232
+ no_check: Annotated[
233
+ bool,
234
+ typer.Option("--no-check", help="Don't check the environment first."),
235
+ ] = False,
236
+ verbose: Annotated[
237
+ bool, typer.Option("--verbose", "-v", help="Print commands.")
238
+ ] = False,
239
+ ) -> None:
240
+ """Build a Markdown file into a PDF with pandoc and LaTeX.
241
+
242
+ Value markers are filled from their results files, which keeps the
243
+ Markdown itself current too, and each value links to the question on
244
+ the project's hub whose evidence cites it. The template gets the Calkit
245
+ version and the project's URL as the ``calkit-version``,
246
+ ``project-url``, and ``project`` variables.
247
+ """
248
+ import calkit.docx
249
+ import calkit.markdown
250
+
251
+ def question_url(value: calkit.markdown.MarkdownValue) -> str | None:
252
+ # The question whose evidence cites this value, or failing that,
253
+ # the first to cite its file
254
+ if project_url is None:
255
+ return None
256
+ first = None
257
+ for n, q in enumerate(ck_info.get("questions") or [], start=1):
258
+ evidence = q.get("evidence") if isinstance(q, dict) else None
259
+ for ev in evidence or []:
260
+ if not isinstance(ev, dict) or ev.get("path") != value.path:
261
+ continue
262
+ keys = [ev.get("key"), *(ev.get("values") or {}).values()]
263
+ if value.key in keys:
264
+ return f"{project_url}/questions/{n}"
265
+ first = first or n
266
+ return f"{project_url}/questions/{first}" if first else None
267
+
268
+ def to_latex(tex_path: str) -> None:
269
+ # Images are pointed at from where the LaTeX is, by a filter that
270
+ # runs before the others, so they see the paths LaTeX will
271
+ build_dir = os.path.dirname(tex_path) or "."
272
+ os.makedirs(build_dir, exist_ok=True)
273
+ stem = Path(tex_path).stem
274
+ src_path = f"{build_dir}/{stem}.pandoc.md"
275
+ with open(src_path, "w", encoding="utf-8", newline="\n") as f:
276
+ f.write(
277
+ calkit.markdown.prepare_for_pdf(
278
+ text, md_path, link=question_url
279
+ )
280
+ )
281
+ prefix = Path(os.path.relpath(md_dir, build_dir)).as_posix()
282
+ repoint = f"{build_dir}/{stem}.images.lua"
283
+ with open(repoint, "w", encoding="utf-8", newline="\n") as f:
284
+ f.write(calkit.markdown.image_prefix_filter(prefix))
285
+ args = [src_path, "-o", tex_path, "--standalone"]
286
+ if template_path is not None:
287
+ args += ["--template", template_path]
288
+ for path in [repoint, *(filters or [])]:
289
+ args += ["--lua-filter", path]
290
+ args += [f"--variable=calkit-version:{version}"]
291
+ if project_url is not None:
292
+ args += [
293
+ f"--variable=project-url:{project_url}",
294
+ f"--variable=project:{project_url.split('://', 1)[1]}",
295
+ ]
296
+ args += pandoc_args or []
297
+ if environment is not None:
298
+ cmd = _tex_cmd(
299
+ ["pandoc", *args], environment, no_check, verbose, dep="pandoc"
300
+ )
301
+ else:
302
+ pandoc = calkit.docx.find_pandoc()
303
+ if pandoc is None:
304
+ raise_error(
305
+ "Pandoc is needed; name an environment that has it, "
306
+ "e.g., one using Calkit's LaTeX image, or install it"
307
+ )
308
+ cmd = [pandoc, *args]
309
+ try:
310
+ subprocess.check_call(cmd)
311
+ except subprocess.CalledProcessError:
312
+ raise_error(f"Pandoc failed to convert {md_path}")
313
+
314
+ for output in outputs:
315
+ if not output.endswith((".pdf", ".tex")):
316
+ raise_error("Outputs must be .pdf or .tex files")
317
+ # Paths go into the build directory's, so they have to be the
318
+ # project's own
319
+ rel = os.path.relpath(md_path)
320
+ if rel == ".." or rel.startswith(".." + os.sep) or os.path.isabs(rel):
321
+ raise_error(f"{md_path} isn't in this project")
322
+ md_path = Path(rel).as_posix()
323
+ md_dir = os.path.dirname(md_path) or "."
324
+ ck_info = calkit.load_calkit_info()
325
+ project_url = None
326
+ if ck_info.get("owner") and ck_info.get("name"):
327
+ hub = str(ck_info.get("hub") or "calkit.io").rstrip("/")
328
+ if "://" not in hub:
329
+ hub = "https://" + hub
330
+ project_url = f"{hub}/{ck_info['owner']}/{ck_info['name']}"
331
+ # A local version's commit is the project's own, which isn't Calkit's
332
+ version = calkit.__version__.split("+")[0]
333
+ with open(md_path, encoding="utf-8") as f:
334
+ text = f.read()
335
+ try:
336
+ text, changed = calkit.markdown.set_values(text, md_path)
337
+ except calkit.markdown.MarkdownParseError as e:
338
+ raise_error(str(e))
339
+ if changed:
340
+ with open(md_path, "w", encoding="utf-8", newline="\n") as f:
341
+ f.write(text)
342
+ for output in outputs:
343
+ if output.endswith(".tex"):
344
+ to_latex(output)
345
+ if not any(o.endswith(".pdf") for o in outputs):
346
+ return
347
+ # A PDF is built where Calkit keeps what it derives from the file, and
348
+ # dated by the file's last commit rather than the build directory's,
349
+ # which has none of its own
350
+ build_dir = Path(".calkit", "markdown", md_path, "pdf").as_posix()
351
+ tex_path = f"{build_dir}/{Path(md_path).stem}.tex"
352
+ to_latex(tex_path)
353
+ cmd = [sys.executable, "-m", "calkit", "latex", "build"]
354
+ if environment is not None:
355
+ cmd += ["-e", environment]
356
+ if no_check:
357
+ cmd += ["--no-check"]
358
+ env = os.environ.copy()
359
+ epoch = calkit.latex.get_source_date_epoch(md_path)
360
+ if epoch is not None:
361
+ env["SOURCE_DATE_EPOCH"] = epoch
362
+ try:
363
+ subprocess.check_call(cmd + [tex_path], env=env)
364
+ except subprocess.CalledProcessError:
365
+ raise_error(f"LaTeX failed to build {tex_path}")
366
+ for output in outputs:
367
+ if output.endswith(".pdf"):
368
+ if os.path.dirname(output):
369
+ os.makedirs(os.path.dirname(output), exist_ok=True)
370
+ shutil.copyfile(Path(tex_path).with_suffix(".pdf"), output)
371
+
372
+
192
373
  def _tex_cmd(
193
374
  tex_cmd: list[str],
194
375
  environment: str | None,
@@ -2121,6 +2302,15 @@ def to_docx(
2121
2302
  )
2122
2303
  maths = calkit.docx.latex_to_omml([p[2] for p in pieces], preamble)
2123
2304
  converted = dict(zip([(id(p[0]), p[1]) for p in pieces], maths))
2305
+ # What each link within the document shows, e.g., an equation's
2306
+ # number, by the bookmark it goes to
2307
+ link_text = {
2308
+ h.get(calkit.docx._tag(calkit.docx.W, "anchor"), ""): "".join(
2309
+ t.text or ""
2310
+ for t in h.iter(calkit.docx._tag(calkit.docx.W, "t"))
2311
+ ).strip()
2312
+ for h in doc.doc.iter(calkit.docx._tag(calkit.docx.W, "hyperlink"))
2313
+ }
2124
2314
  bid = 100000
2125
2315
  last_number: str | None = None
2126
2316
  for group, gap, numbers, lead, before_para, after_para in plans:
@@ -2156,7 +2346,30 @@ def to_docx(
2156
2346
  ):
2157
2347
  if para.element is not None and para.element in unit.iter(w_p):
2158
2348
  doc.move_out(para.element, unit, after=end)
2159
- doc.insert_equations(gap[0], rows, bid)
2349
+ # The conversion's own bookmarks on the equations, which
2350
+ # references in the text link to, wholly inside what's replaced,
2351
+ # each on the row whose number the references show
2352
+ w_name = calkit.docx._tag(calkit.docx.W, "name")
2353
+ w_id = calkit.docx._tag(calkit.docx.W, "id")
2354
+ ended = {
2355
+ e.get(w_id)
2356
+ for el in gap
2357
+ for e in el.iter(
2358
+ calkit.docx._tag(calkit.docx.W, "bookmarkEnd")
2359
+ )
2360
+ }
2361
+ row_of = {num: i for i, (_, num, _) in enumerate(rows) if num}
2362
+ keep: dict[int, list[tuple[str, str]]] = {}
2363
+ for el in gap:
2364
+ for b in el.iter(
2365
+ calkit.docx._tag(calkit.docx.W, "bookmarkStart")
2366
+ ):
2367
+ name = b.get(w_name, "")
2368
+ if name in original or b.get(w_id) not in ended:
2369
+ continue
2370
+ row = row_of.get(link_text.get(name, ""), 0)
2371
+ keep.setdefault(row, []).append((name, b.get(w_id, "")))
2372
+ doc.insert_equations(gap[0], rows, bid, keep=keep)
2160
2373
  bid += len(rows)
2161
2374
  for el in gap:
2162
2375
  doc.remove(el)
@@ -2741,6 +2954,12 @@ def merge_docx(
2741
2954
  and existing.resolved == tc.resolved
2742
2955
  ):
2743
2956
  continue
2957
+ # Word can't carry these, so they come from the source
2958
+ tc.id, tc.issue, tc.attrs = (
2959
+ existing.id,
2960
+ existing.issue,
2961
+ existing.attrs,
2962
+ )
2744
2963
  # Word knows neither emails nor what the source already
2745
2964
  # recorded, so carry those over for unchanged messages
2746
2965
  known = {(e.author, e.text): e for e in existing.entries}
@@ -2818,3 +3037,270 @@ def merge_docx(
2818
3037
  f"already there, {counts['pending']} pending, {counts['unplaced']} "
2819
3038
  f"unplaced); {added} comments added, {updated} updated"
2820
3039
  )
3040
+
3041
+
3042
+ comments_app = typer.Typer(no_args_is_help=True)
3043
+ latex_app.add_typer(
3044
+ comments_app,
3045
+ name="comments",
3046
+ help="Work with review comments in LaTeX source.",
3047
+ )
3048
+
3049
+ _ID_OPTION = typer.Option("--id", help="ID of the thread.")
3050
+ _LINE_OPTION = typer.Option(
3051
+ "--line", "-l", help="Number of any line in the thread."
3052
+ )
3053
+
3054
+
3055
+ def _new_entry(
3056
+ text: str, author: str | None, email: str | None
3057
+ ) -> calkit.latex.Entry:
3058
+ import datetime
3059
+
3060
+ import git
3061
+
3062
+ if author is None:
3063
+ try:
3064
+ author = git.Git().config("--get", "user.name").strip()
3065
+ email = email or git.Git().config("--get", "user.email").strip()
3066
+ except git.GitCommandError:
3067
+ raise_error("Set --author, or Git's user.name")
3068
+ date = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
3069
+ return calkit.latex.Entry(author, text, email or None, date)
3070
+
3071
+
3072
+ def _edit_comment(
3073
+ tex_path: str,
3074
+ id: str | None,
3075
+ line: int | None,
3076
+ edit: Callable[[calkit.latex.TexComment], None],
3077
+ ) -> calkit.latex.TexComment:
3078
+ """Apply an edit to one thread and write the file back, giving the
3079
+ thread an ID if it had none."""
3080
+ if (id is None) == (line is None):
3081
+ raise_error("Specify one of --id or --line")
3082
+ if not os.path.isfile(tex_path):
3083
+ raise_error(f"{tex_path} does not exist")
3084
+ lines = Path(tex_path).read_text(encoding="utf-8").split("\n")
3085
+ tc = calkit.latex.find_comment(lines, id=id, lineno=line)
3086
+ if tc is None:
3087
+ raise_error(f"No comment thread in {tex_path} at {id or line}")
3088
+ edit(tc)
3089
+ tc.id = tc.id or calkit.latex.new_comment_id()
3090
+ calkit.latex.write_comment(lines, tc)
3091
+ Path(tex_path).write_text("\n".join(lines), encoding="utf-8")
3092
+ return tc
3093
+
3094
+
3095
+ @comments_app.command(name="list")
3096
+ def list_comments(
3097
+ tex_path: Annotated[
3098
+ str,
3099
+ typer.Argument(
3100
+ help="Document to list comments from, including files it inputs."
3101
+ ),
3102
+ ],
3103
+ unresolved: Annotated[
3104
+ bool,
3105
+ typer.Option("--unresolved", "-u", help="Only list open threads."),
3106
+ ] = False,
3107
+ as_json: Annotated[
3108
+ bool, typer.Option("--json", help="Output result as JSON.")
3109
+ ] = False,
3110
+ ) -> None:
3111
+ """List the comment threads in a LaTeX document."""
3112
+ if not os.path.isfile(tex_path):
3113
+ raise_error(f"{tex_path} does not exist")
3114
+ out = [
3115
+ c
3116
+ for c in calkit.latex.list_comments(tex_path)
3117
+ if not (unresolved and c["resolved"])
3118
+ ]
3119
+ if as_json:
3120
+ typer.echo(json.dumps(out, indent=2))
3121
+ return
3122
+ for c in out:
3123
+ status = " (resolved)" if c["resolved"] else ""
3124
+ typer.echo(f"{c['path']}:{c['line']} [{c['id'] or '-'}]{status}")
3125
+ if c["highlight"]:
3126
+ typer.echo(f' On "{c["highlight"]["text"]}"')
3127
+ for m in c["messages"]:
3128
+ typer.echo(f" {m['author']}: {m['text']}")
3129
+
3130
+
3131
+ @comments_app.command(name="locate")
3132
+ def locate_paragraph(
3133
+ tex_path: Annotated[
3134
+ str,
3135
+ typer.Argument(help="Document to search, including files it inputs."),
3136
+ ],
3137
+ text: Annotated[
3138
+ str | None,
3139
+ typer.Option(
3140
+ "--text", help="Rendered text, e.g., a line copied from the PDF."
3141
+ ),
3142
+ ] = None,
3143
+ path: Annotated[
3144
+ str | None,
3145
+ typer.Option("--path", help="File with the line to look up."),
3146
+ ] = None,
3147
+ line: Annotated[
3148
+ int | None,
3149
+ typer.Option("--line", "-l", help="Line to look up in --path."),
3150
+ ] = None,
3151
+ as_json: Annotated[
3152
+ bool, typer.Option("--json", help="Output result as JSON.")
3153
+ ] = False,
3154
+ ) -> None:
3155
+ """Find the paragraph some rendered text is in, or a source line is in.
3156
+
3157
+ This is how a place in a PDF built without SyncTeX, or by someone else,
3158
+ can be matched to the source.
3159
+ """
3160
+ if (text is None) == (line is None):
3161
+ raise_error("Specify one of --text or --path and --line")
3162
+ if not os.path.isfile(tex_path):
3163
+ raise_error(f"{tex_path} does not exist")
3164
+ found = calkit.latex.locate_block(tex_path, text, path, line)
3165
+ if found is None:
3166
+ raise_error("No matching paragraph found")
3167
+ out = {
3168
+ "path": found.path,
3169
+ "line": found.lineno,
3170
+ "end_line": found.lines[-1].lineno,
3171
+ "text": found.text or calkit.latex.display_text(found),
3172
+ }
3173
+ if as_json:
3174
+ typer.echo(json.dumps(out, indent=2))
3175
+ else:
3176
+ typer.echo(f"{out['path']}:{out['line']}")
3177
+
3178
+
3179
+ @comments_app.command(name="add")
3180
+ def add_comment(
3181
+ tex_path: Annotated[str, typer.Argument(help="File to comment in.")],
3182
+ line: Annotated[
3183
+ int,
3184
+ typer.Option(
3185
+ "--line",
3186
+ "-l",
3187
+ help="A line in, or just above, the paragraph to comment on.",
3188
+ ),
3189
+ ],
3190
+ text: Annotated[str, typer.Option("--text", "-m", help="The comment.")],
3191
+ highlight: Annotated[
3192
+ str | None,
3193
+ typer.Option(
3194
+ "--highlight", help="Rendered text in the paragraph it's about."
3195
+ ),
3196
+ ] = None,
3197
+ occ: Annotated[
3198
+ int,
3199
+ typer.Option(
3200
+ "--occ", help="Which occurrence of the highlight text, from 0."
3201
+ ),
3202
+ ] = 0,
3203
+ author: Annotated[
3204
+ str | None,
3205
+ typer.Option("--author", help="Defaults to Git's user.name."),
3206
+ ] = None,
3207
+ email: Annotated[
3208
+ str | None,
3209
+ typer.Option("--email", help="Defaults to Git's user.email."),
3210
+ ] = None,
3211
+ as_json: Annotated[
3212
+ bool, typer.Option("--json", help="Output the thread as JSON.")
3213
+ ] = False,
3214
+ ) -> None:
3215
+ """Start a comment thread above a paragraph, printing its ID."""
3216
+ if not os.path.isfile(tex_path):
3217
+ raise_error(f"{tex_path} does not exist")
3218
+ lines = Path(tex_path).read_text(encoding="utf-8").split("\n")
3219
+ tc = calkit.latex.TexComment(
3220
+ [_new_entry(text, author, email)],
3221
+ highlight=highlight or None,
3222
+ highlight_occ=occ,
3223
+ id=calkit.latex.new_comment_id(),
3224
+ )
3225
+ try:
3226
+ calkit.latex.add_comment(lines, line, tc)
3227
+ except ValueError as e:
3228
+ raise_error(str(e))
3229
+ Path(tex_path).write_text("\n".join(lines), encoding="utf-8")
3230
+ if as_json:
3231
+ blks = calkit.latex.file_blocks(tex_path, lines)
3232
+ typer.echo(
3233
+ json.dumps(
3234
+ calkit.latex.comment_to_dict(tex_path, tc, blks), indent=2
3235
+ )
3236
+ )
3237
+ else:
3238
+ typer.echo(tc.id)
3239
+
3240
+
3241
+ @comments_app.command(name="reply")
3242
+ def reply_to_comment(
3243
+ tex_path: Annotated[str, typer.Argument(help="File the thread is in.")],
3244
+ text: Annotated[str, typer.Option("--text", "-m", help="The reply.")],
3245
+ id: Annotated[str | None, _ID_OPTION] = None,
3246
+ line: Annotated[int | None, _LINE_OPTION] = None,
3247
+ author: Annotated[
3248
+ str | None,
3249
+ typer.Option("--author", help="Defaults to Git's user.name."),
3250
+ ] = None,
3251
+ email: Annotated[
3252
+ str | None,
3253
+ typer.Option("--email", help="Defaults to Git's user.email."),
3254
+ ] = None,
3255
+ ) -> None:
3256
+ """Reply to a comment thread."""
3257
+ entry = _new_entry(text, author, email)
3258
+ tc = _edit_comment(tex_path, id, line, lambda tc: tc.entries.append(entry))
3259
+ typer.echo(tc.id)
3260
+
3261
+
3262
+ @comments_app.command(name="resolve")
3263
+ def resolve_comment(
3264
+ tex_path: Annotated[str, typer.Argument(help="File the thread is in.")],
3265
+ id: Annotated[str | None, _ID_OPTION] = None,
3266
+ line: Annotated[int | None, _LINE_OPTION] = None,
3267
+ ) -> None:
3268
+ """Mark a comment thread resolved."""
3269
+
3270
+ def edit(tc: calkit.latex.TexComment) -> None:
3271
+ tc.resolved = True
3272
+
3273
+ typer.echo(_edit_comment(tex_path, id, line, edit).id)
3274
+
3275
+
3276
+ @comments_app.command(name="reopen")
3277
+ def reopen_comment(
3278
+ tex_path: Annotated[str, typer.Argument(help="File the thread is in.")],
3279
+ id: Annotated[str | None, _ID_OPTION] = None,
3280
+ line: Annotated[int | None, _LINE_OPTION] = None,
3281
+ ) -> None:
3282
+ """Mark a resolved comment thread open again."""
3283
+
3284
+ def edit(tc: calkit.latex.TexComment) -> None:
3285
+ tc.resolved = False
3286
+
3287
+ typer.echo(_edit_comment(tex_path, id, line, edit).id)
3288
+
3289
+
3290
+ @comments_app.command(name="delete")
3291
+ def delete_comment(
3292
+ tex_path: Annotated[str, typer.Argument(help="File the thread is in.")],
3293
+ id: Annotated[str | None, _ID_OPTION] = None,
3294
+ line: Annotated[int | None, _LINE_OPTION] = None,
3295
+ ) -> None:
3296
+ """Delete a comment thread and its replies."""
3297
+ if (id is None) == (line is None):
3298
+ raise_error("Specify one of --id or --line")
3299
+ if not os.path.isfile(tex_path):
3300
+ raise_error(f"{tex_path} does not exist")
3301
+ lines = Path(tex_path).read_text(encoding="utf-8").split("\n")
3302
+ tc = calkit.latex.find_comment(lines, id=id, lineno=line)
3303
+ if tc is None:
3304
+ raise_error(f"No comment thread in {tex_path} at {id or line}")
3305
+ del lines[tc.lineno - 1 : tc.lineno - 1 + tc.nlines]
3306
+ Path(tex_path).write_text("\n".join(lines), encoding="utf-8")
calkit/cli/update.py CHANGED
@@ -1718,18 +1718,33 @@ def update_hub(
1718
1718
  # A project that already exists on the hub, e.g., one created there
1719
1719
  # for this repo, is connected to rather than created again, which the
1720
1720
  # hub would refuse since a repo belongs to one project
1721
- exists = False
1722
- if owner is not None:
1721
+ project = None
1722
+ if git_repo_url is not None and git_repo_url.startswith(
1723
+ "https://github.com/"
1724
+ ):
1725
+ github_repo = git_repo_url.removeprefix("https://github.com/")
1726
+ try:
1727
+ found = calkit.hub.get(
1728
+ "/projects",
1729
+ params=dict(github_repo=github_repo),
1730
+ max_retries=1,
1731
+ )
1732
+ if found.get("data"):
1733
+ project = found["data"][0]
1734
+ except Exception:
1735
+ pass
1736
+ elif owner is not None:
1723
1737
  try:
1724
- calkit.hub.get(f"/projects/{owner}/{name}", max_retries=1)
1725
- exists = True
1738
+ project = calkit.hub.get(
1739
+ f"/projects/{owner}/{name}", max_retries=1
1740
+ )
1726
1741
  except Exception:
1727
1742
  pass
1728
1743
  # Creating it is the step that can already be done, so a project that
1729
1744
  # exists is not an error: the point is to end up connected
1730
1745
  resp = None
1731
1746
  try:
1732
- if exists:
1747
+ if project is not None:
1733
1748
  raise RuntimeError("Project already exists")
1734
1749
  resp = calkit.hub.post(
1735
1750
  "/projects",
@@ -1760,6 +1775,22 @@ def update_hub(
1760
1775
  typer.echo(f"Adding Git remote: {git_repo_url}")
1761
1776
  repo.git.remote(["add", "origin", git_repo_url])
1762
1777
  ck_info["git_repo_url"] = git_repo_url
1778
+ # The hub decides who owns the project, e.g., an org's repo belongs to
1779
+ # the org, under an account name that's lowercase even when the Git
1780
+ # remote's isn't
1781
+ for info in (project, resp):
1782
+ if isinstance(info, dict) and info.get("owner_account_name"):
1783
+ owner = info["owner_account_name"]
1784
+ name = info.get("name") or name
1785
+ if owner is not None and ck_info.get("owner") != owner:
1786
+ ck_info["owner"] = owner
1787
+ if ck_info.get("name") != name:
1788
+ ck_info["name"] = name
1789
+ if ck_info.get("hub") is None:
1790
+ ck_info["hub"] = hub_url
1791
+ # The DVC remote is named from calkit.yaml, so it has to be written first
1792
+ with open("calkit.yaml", "w") as f:
1793
+ calkit.ryaml.dump(ck_info, f)
1763
1794
  try:
1764
1795
  remote_name = configure_remote()
1765
1796
  set_remote_auth(remote_name=remote_name)
@@ -1773,14 +1804,6 @@ def update_hub(
1773
1804
  raise_error("Current directory is not a Git repository")
1774
1805
  except (ValueError, RuntimeError) as e:
1775
1806
  raise_error(f"Failed to set up DVC remote: {e}")
1776
- if owner is not None and ck_info.get("owner") != owner:
1777
- ck_info["owner"] = owner
1778
- if ck_info.get("name") != name:
1779
- ck_info["name"] = name
1780
- if ck_info.get("hub") is None:
1781
- ck_info["hub"] = hub_url
1782
- with open("calkit.yaml", "w") as f:
1783
- calkit.ryaml.dump(ck_info, f)
1784
1807
  paths = [".dvc/config", "calkit.yaml"]
1785
1808
  # The hub would have written one, and a repo it didn't scaffold has none
1786
1809
  if not os.path.isfile("README.md"):