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.
- calkit/cli/latex.py +487 -1
- calkit/cli/update.py +36 -13
- calkit/core.py +94 -0
- calkit/docx.py +68 -9
- calkit/dvc/core.py +12 -2
- calkit/fs.py +7 -3
- calkit/git.py +4 -2
- calkit/hub.py +8 -1
- calkit/latex.py +260 -10
- calkit/markdown.py +156 -5
- calkit/models/pipeline.py +126 -3
- calkit/pipeline.py +4 -1
- calkit/resources/devcontainer/devcontainer.json +1 -1
- calkit/resources/vscode/settings.json +1 -1
- calkit/tests/cli/main/test_core.py +15 -0
- calkit/tests/cli/test_latex.py +239 -0
- calkit/tests/cli/test_notebooks.py +6 -0
- calkit/tests/cli/test_update.py +51 -5
- calkit/tests/dvc/test_core.py +45 -0
- calkit/tests/models/test_pipeline.py +59 -0
- calkit/tests/test_core.py +50 -0
- calkit/tests/test_docx.py +90 -3
- calkit/tests/test_git.py +29 -0
- calkit/tests/test_hub.py +8 -8
- calkit/tests/test_markdown.py +88 -0
- {calkit_python-0.48.0.dist-info → calkit_python-0.48.2.dist-info}/METADATA +2 -1
- {calkit_python-0.48.0.dist-info → calkit_python-0.48.2.dist-info}/RECORD +43 -43
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.48.0.data → calkit_python-0.48.2.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.48.0.dist-info → calkit_python-0.48.2.dist-info}/WHEEL +0 -0
- {calkit_python-0.48.0.dist-info → calkit_python-0.48.2.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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
|
-
|
|
1722
|
-
if
|
|
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(
|
|
1725
|
-
|
|
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
|
|
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"):
|