calkit-python 0.48.0__py3-none-any.whl → 0.48.1__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 (33) hide show
  1. calkit/cli/latex.py +33 -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/hub.py +8 -1
  8. calkit/models/pipeline.py +22 -2
  9. calkit/tests/cli/test_notebooks.py +6 -0
  10. calkit/tests/cli/test_update.py +51 -5
  11. calkit/tests/dvc/test_core.py +45 -0
  12. calkit/tests/models/test_pipeline.py +59 -0
  13. calkit/tests/test_core.py +50 -0
  14. calkit/tests/test_docx.py +53 -0
  15. calkit/tests/test_hub.py +8 -8
  16. {calkit_python-0.48.0.dist-info → calkit_python-0.48.1.dist-info}/METADATA +1 -1
  17. {calkit_python-0.48.0.dist-info → calkit_python-0.48.1.dist-info}/RECORD +33 -33
  18. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
  19. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
  20. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
  21. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
  22. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js +0 -0
  23. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png +0 -0
  24. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js +0 -0
  25. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js +0 -0
  26. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js +0 -0
  27. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js +0 -0
  28. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js +0 -0
  29. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
  30. {calkit_python-0.48.0.data → calkit_python-0.48.1.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
  31. {calkit_python-0.48.0.dist-info → calkit_python-0.48.1.dist-info}/WHEEL +0 -0
  32. {calkit_python-0.48.0.dist-info → calkit_python-0.48.1.dist-info}/entry_points.txt +0 -0
  33. {calkit_python-0.48.0.dist-info → calkit_python-0.48.1.dist-info}/licenses/LICENSE +0 -0
calkit/cli/latex.py CHANGED
@@ -2121,6 +2121,15 @@ def to_docx(
2121
2121
  )
2122
2122
  maths = calkit.docx.latex_to_omml([p[2] for p in pieces], preamble)
2123
2123
  converted = dict(zip([(id(p[0]), p[1]) for p in pieces], maths))
2124
+ # What each link within the document shows, e.g., an equation's
2125
+ # number, by the bookmark it goes to
2126
+ link_text = {
2127
+ h.get(calkit.docx._tag(calkit.docx.W, "anchor"), ""): "".join(
2128
+ t.text or ""
2129
+ for t in h.iter(calkit.docx._tag(calkit.docx.W, "t"))
2130
+ ).strip()
2131
+ for h in doc.doc.iter(calkit.docx._tag(calkit.docx.W, "hyperlink"))
2132
+ }
2124
2133
  bid = 100000
2125
2134
  last_number: str | None = None
2126
2135
  for group, gap, numbers, lead, before_para, after_para in plans:
@@ -2156,7 +2165,30 @@ def to_docx(
2156
2165
  ):
2157
2166
  if para.element is not None and para.element in unit.iter(w_p):
2158
2167
  doc.move_out(para.element, unit, after=end)
2159
- doc.insert_equations(gap[0], rows, bid)
2168
+ # The conversion's own bookmarks on the equations, which
2169
+ # references in the text link to, wholly inside what's replaced,
2170
+ # each on the row whose number the references show
2171
+ w_name = calkit.docx._tag(calkit.docx.W, "name")
2172
+ w_id = calkit.docx._tag(calkit.docx.W, "id")
2173
+ ended = {
2174
+ e.get(w_id)
2175
+ for el in gap
2176
+ for e in el.iter(
2177
+ calkit.docx._tag(calkit.docx.W, "bookmarkEnd")
2178
+ )
2179
+ }
2180
+ row_of = {num: i for i, (_, num, _) in enumerate(rows) if num}
2181
+ keep: dict[int, list[tuple[str, str]]] = {}
2182
+ for el in gap:
2183
+ for b in el.iter(
2184
+ calkit.docx._tag(calkit.docx.W, "bookmarkStart")
2185
+ ):
2186
+ name = b.get(w_name, "")
2187
+ if name in original or b.get(w_id) not in ended:
2188
+ continue
2189
+ row = row_of.get(link_text.get(name, ""), 0)
2190
+ keep.setdefault(row, []).append((name, b.get(w_id, "")))
2191
+ doc.insert_equations(gap[0], rows, bid, keep=keep)
2160
2192
  bid += len(rows)
2161
2193
  for el in gap:
2162
2194
  doc.remove(el)
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"):
calkit/core.py CHANGED
@@ -41,6 +41,82 @@ logger = logging.getLogger(__package__)
41
41
  logger.setLevel(logging.INFO)
42
42
 
43
43
 
44
+ class _FoldedPlainStr(str):
45
+ """A plain scalar read from several lines, remembering where they broke.
46
+
47
+ ``fold_pos`` holds the index of each space that was a line break, so
48
+ the value can be written back the way it was wrapped rather than
49
+ re-folded at whatever width ruamel is set to, which rewrote every
50
+ paragraph of a file wrapped any other way.
51
+ """
52
+
53
+ fold_pos: list[int]
54
+
55
+
56
+ class _FoldPreservingConstructor(ruamel.yaml.constructor.RoundTripConstructor):
57
+ def construct_scalar(self, node: Any) -> Any:
58
+ value = super().construct_scalar(node)
59
+ if (
60
+ type(value) is not str
61
+ or node.style is not None
62
+ or node.start_mark.line == node.end_mark.line
63
+ ):
64
+ return value
65
+ # Only text loaded from a string has its source in the marks
66
+ buffer = getattr(node.start_mark, "buffer", None)
67
+ if buffer is None:
68
+ return value
69
+ source = buffer[node.start_mark.index : node.end_mark.index]
70
+ lines = [line.strip() for line in source.split("\n")]
71
+ # Blank lines are newlines in the value, which the emitter handles
72
+ if "" in lines or " ".join(lines) != value:
73
+ return value
74
+ out = _FoldedPlainStr(value)
75
+ out.fold_pos = []
76
+ pos = -1
77
+ for line in lines[:-1]:
78
+ pos += len(line) + 1
79
+ out.fold_pos.append(pos)
80
+ return out
81
+
82
+
83
+ class _FoldPreservingRepresenter(ruamel.yaml.representer.RoundTripRepresenter):
84
+ pass
85
+
86
+
87
+ _FoldPreservingRepresenter.add_representer(
88
+ _FoldedPlainStr, ruamel.yaml.representer.RoundTripRepresenter.represent_str
89
+ )
90
+
91
+
92
+ class _FoldPreservingEmitter(ruamel.yaml.emitter.Emitter):
93
+ def write_plain(self, text: Any, split: Any = True) -> None:
94
+ fold_pos = getattr(text, "fold_pos", None)
95
+ if not fold_pos or not split or self.root_context:
96
+ return super().write_plain(text, split)
97
+
98
+ def write(data: str) -> None:
99
+ self.column += len(data)
100
+ self.stream.write(
101
+ data.encode(self.encoding) if self.encoding else data
102
+ )
103
+
104
+ # The same bookkeeping as the parent's, breaking where the source
105
+ # did instead of where the width says to
106
+ if not self.whitespace:
107
+ write(" ")
108
+ self.whitespace = False
109
+ self.indention = False
110
+ start = 0
111
+ for pos in fold_pos:
112
+ write(text[start:pos])
113
+ self.write_indent()
114
+ self.whitespace = False
115
+ self.indention = False
116
+ start = pos + 1
117
+ write(text[start:])
118
+
119
+
44
120
  class _ThreadLocalYAML(threading.local):
45
121
  """Holds one configured ruamel ``YAML`` per thread.
46
122
 
@@ -65,6 +141,10 @@ class _ThreadLocalYAML(threading.local):
65
141
  # 79-80 columns is written back as it was; at 70, lines of 71-80
66
142
  # characters were re-folded with a stray word on the next line
67
143
  self.yaml.width = 80
144
+ # Prose wrapped some other way is still written back as it was
145
+ self.yaml.Constructor = _FoldPreservingConstructor
146
+ self.yaml.Representer = _FoldPreservingRepresenter
147
+ self.yaml.Emitter = _FoldPreservingEmitter
68
148
 
69
149
 
70
150
  _yaml_local = _ThreadLocalYAML()
@@ -83,6 +163,20 @@ class _ThreadLocalYAMLProxy:
83
163
  def __setattr__(self, name: str, value: Any) -> None:
84
164
  setattr(_yaml_local.yaml, name, value)
85
165
 
166
+ def load(self, stream: Any) -> Any:
167
+ """Load YAML, reading it to a string first.
168
+
169
+ Only a string load leaves the source in the marks, which is how
170
+ multi-line values remember where they were wrapped.
171
+ """
172
+ from pathlib import Path
173
+
174
+ if isinstance(stream, Path):
175
+ stream = stream.read_text(encoding="utf-8")
176
+ elif hasattr(stream, "read"):
177
+ stream = stream.read()
178
+ return _yaml_local.yaml.load(stream)
179
+
86
180
  def dump(self, data: Any, stream: Any = None, **kwargs: Any) -> Any:
87
181
  """Dump YAML, dropping the space ruamel leaves before a fold.
88
182
 
calkit/docx.py CHANGED
@@ -633,10 +633,13 @@ class Document:
633
633
  before: ET.Element,
634
634
  rows: list[tuple[ET.Element, str | None, str]],
635
635
  first_bid: int,
636
+ keep: dict[int, list[tuple[str, str]]] | None = None,
636
637
  ) -> None:
637
638
  """Insert display equations before a body element as a borderless
638
639
  table of (math, number, bookmark name) rows, which Word and
639
640
  LibreOffice both lay out like LaTeX: centered, numbered at right.
641
+ ``keep`` is bookmarks, as (name, ID) by row, from what the
642
+ equations replace, so links to them still land.
640
643
  """
641
644
  width = self.column_width(before)
642
645
  side = width // 8
@@ -677,16 +680,19 @@ class Document:
677
680
  )
678
681
  # The bookmark spans the equation, since Word drops an
679
682
  # empty one beside it
680
- bid = str(first_bid + i)
681
- ET.SubElement(
682
- p,
683
- _tag(W, "bookmarkStart"),
684
- {_tag(W, "id"): bid, _tag(W, "name"): name},
685
- )
683
+ marks = [(name, str(first_bid + i))]
684
+ marks += (keep or {}).get(i, [])
685
+ for mark, bid in marks:
686
+ ET.SubElement(
687
+ p,
688
+ _tag(W, "bookmarkStart"),
689
+ {_tag(W, "id"): bid, _tag(W, "name"): mark},
690
+ )
686
691
  ET.SubElement(p, _tag(M, "oMathPara")).append(math)
687
- ET.SubElement(
688
- p, _tag(W, "bookmarkEnd"), {_tag(W, "id"): bid}
689
- )
692
+ for _, bid in marks:
693
+ ET.SubElement(
694
+ p, _tag(W, "bookmarkEnd"), {_tag(W, "id"): bid}
695
+ )
690
696
  elif j == 2 and number:
691
697
  ET.SubElement(
692
698
  ppr, _tag(W, "jc"), {_tag(W, "val"): "right"}
@@ -1120,6 +1126,44 @@ def find_soffice() -> str | None:
1120
1126
  return next((c for c in candidates if os.path.isfile(c)), None)
1121
1127
 
1122
1128
 
1129
+ def _link_into_objects(content: str) -> str:
1130
+ """Point links into embedded objects at the objects themselves.
1131
+
1132
+ TeX4ht puts each equation in an object of its own and links a
1133
+ reference to it to an anchor inside, e.g., ``main-m7.4om#x7-3001r2``,
1134
+ which Word can't follow, so LibreOffice writes it out as a path to a
1135
+ file in the build directory. A bookmark of that name beside the object
1136
+ in the main text makes it an ordinary link within the document.
1137
+ """
1138
+
1139
+ def relink(m: re.Match) -> str:
1140
+ name, anchor = m.group("name"), m.group("anchor")
1141
+ if name not in objects:
1142
+ return str(m.group(0))
1143
+ anchors.setdefault(name, set()).add(anchor)
1144
+ return f"{m.group('attr')}{m.group('q')}#{anchor}{m.group('q')}"
1145
+
1146
+ def mark(m: re.Match) -> str:
1147
+ names = sorted(anchors.get(m.group("name"), ()))
1148
+ marks = "".join(f"<text:bookmark text:name='{a}'/>" for a in names)
1149
+ return marks + str(m.group(0))
1150
+
1151
+ frame_re = re.compile(
1152
+ r"<draw:frame\b(?:(?!</draw:frame>).)*?<draw:object\b[^>]*?"
1153
+ r"xlink:href=['\"]\./(?P<name>[^'\"/]+)['\"]",
1154
+ flags=re.S,
1155
+ )
1156
+ objects = {m.group("name") for m in frame_re.finditer(content)}
1157
+ anchors: dict[str, set[str]] = {}
1158
+ content = re.sub(
1159
+ r"(?P<attr><text:a\b[^>]*?xlink:href=)(?P<q>['\"])"
1160
+ r"(?P<name>[^'\"#/]+)\.[^'\"#./]+#(?P<anchor>[^'\"]+)(?P=q)",
1161
+ relink,
1162
+ content,
1163
+ )
1164
+ return frame_re.sub(mark, content)
1165
+
1166
+
1123
1167
  def odt_to_docx(odt_path: str, docx_path: str) -> None:
1124
1168
  """Convert an OpenDocument text to .docx with LibreOffice."""
1125
1169
  import shutil
@@ -1130,6 +1174,21 @@ def odt_to_docx(odt_path: str, docx_path: str) -> None:
1130
1174
  if soffice is None:
1131
1175
  raise RuntimeError("Converting to Word requires LibreOffice")
1132
1176
  with tempfile.TemporaryDirectory() as tmp:
1177
+ # A copy with its links fixed up, leaving the original as made
1178
+ src = os.path.join(tmp, "src", Path(odt_path).name)
1179
+ os.makedirs(os.path.dirname(src))
1180
+ with (
1181
+ zipfile.ZipFile(odt_path) as zin,
1182
+ zipfile.ZipFile(src, "w") as zout,
1183
+ ):
1184
+ for item in zin.infolist():
1185
+ data = zin.read(item)
1186
+ if item.filename == "content.xml":
1187
+ data = _link_into_objects(data.decode("utf-8")).encode(
1188
+ "utf-8"
1189
+ )
1190
+ zout.writestr(item, data)
1191
+ odt_path = src
1133
1192
  # A profile of its own, since a LibreOffice already open would take
1134
1193
  # the job and exit without doing it
1135
1194
  res = subprocess.run(
calkit/dvc/core.py CHANGED
@@ -367,6 +367,16 @@ class CalkitDVCFileSystem(ObjectFileSystem):
367
367
  fs.info = cached_info # type: ignore[method-assign]
368
368
  return fs
369
369
 
370
+ def _info_many(self, paths: list[str], **kwargs: Any) -> dict:
371
+ # Only a .dir is read once it's found, so any other object's content
372
+ # would be downloaded just to say it exists
373
+ dirs = [p for p in paths if p.endswith(".dir")]
374
+ others = [p for p in paths if not p.endswith(".dir")]
375
+ infos = self.fs.info_many(dirs, **kwargs) if dirs else {}
376
+ if others:
377
+ infos |= self.fs.info_many(others, content=False, **kwargs)
378
+ return infos
379
+
370
380
  def _extract_owner_project(self) -> tuple[str, str] | None:
371
381
  """Extract owner and project from the path_info."""
372
382
  try:
@@ -432,7 +442,7 @@ class CalkitDVCFileSystem(ObjectFileSystem):
432
442
  uncached_paths.append(p)
433
443
  # Only fetch info for uncached paths
434
444
  if uncached_paths:
435
- infos = self.fs.info_many(uncached_paths, **kwargs)
445
+ infos = self._info_many(uncached_paths, **kwargs)
436
446
  # Cache the newly fetched info
437
447
  for p in uncached_paths:
438
448
  exists = p in infos and isinstance(infos[p], dict)
@@ -490,7 +500,7 @@ class CalkitDVCFileSystem(ObjectFileSystem):
490
500
  # Only fetch info for uncached paths
491
501
  # Use info_many to get and cache both info and existence
492
502
  if uncached_paths:
493
- infos = self.fs.info_many(uncached_paths, **kwargs)
503
+ infos = self._info_many(uncached_paths, **kwargs)
494
504
  for p in uncached_paths:
495
505
  exists = p in infos and isinstance(infos[p], dict)
496
506
  if p not in self._cache:
calkit/fs.py CHANGED
@@ -866,9 +866,13 @@ class CalkitFileSystem(AbstractFileSystem):
866
866
  return self._get_info_for_parsed_path(owner, project, file_path)
867
867
 
868
868
  def info_many(
869
- self, paths: list[str], **kwargs
869
+ self, paths: list[str], content: bool = True, **kwargs
870
870
  ) -> dict[str, dict[str, Any]]:
871
- """Get metadata for multiple paths in a batch API call."""
871
+ """Get metadata for multiple paths in a batch API call.
872
+
873
+ With ``content``, each small file's bytes come back too, saving
874
+ a request per file for callers that will read them.
875
+ """
872
876
  if not paths:
873
877
  return {}
874
878
  grouped: dict[tuple[str, str], list[tuple[str, str]]] = {}
@@ -888,7 +892,7 @@ class CalkitFileSystem(AbstractFileSystem):
888
892
  project=project,
889
893
  paths=file_paths,
890
894
  operation="info",
891
- include=["info", "content"],
895
+ include=["info", "content"] if content else ["info"],
892
896
  )
893
897
  batch_results = resp.get("results")
894
898
  if isinstance(batch_results, dict):
calkit/hub.py CHANGED
@@ -40,6 +40,13 @@ _REFRESH_BUFFER_SECONDS = 60
40
40
  # they don't recurse into the auth-retry paths in ``_request``.
41
41
  _device_login_lock = threading.Lock()
42
42
 
43
+ # Reused so each request after the first skips the TLS handshake, which
44
+ # was most of the time of a small request. The pool is sized for DVC's
45
+ # concurrent transfers.
46
+ _session = requests.Session()
47
+ _session.mount("https://", requests.adapters.HTTPAdapter(pool_maxsize=64))
48
+ _session.mount("http://", requests.adapters.HTTPAdapter(pool_maxsize=64))
49
+
43
50
 
44
51
  class DeviceLoginError(RuntimeError):
45
52
  """Raised when the OAuth device login flow cannot complete."""
@@ -381,7 +388,7 @@ def _request(
381
388
  # Bound how long a single attempt can hang so stalled connections become
382
389
  # retryable timeouts rather than blocking forever. Callers can override.
383
390
  kwargs.setdefault("timeout", (10, 120))
384
- func = getattr(requests, kind)
391
+ func = getattr(_session, kind)
385
392
  if base_url is None:
386
393
  base_url = get_base_url()
387
394
  refresh_attempted = False
calkit/models/pipeline.py CHANGED
@@ -228,6 +228,23 @@ def _allow_null(schema: dict[str, Any]) -> None:
228
228
  schema["anyOf"] = [inner, {"type": "null"}]
229
229
 
230
230
 
231
+ def _unique_paths(paths: list[str]) -> list[str]:
232
+ """Drop repeated paths, keeping the order they were first added in.
233
+
234
+ A stage can name the same path more than once: a document's target is
235
+ often also one of its inputs, and one source can be copied to several
236
+ destinations. DVC warns about a duplicate dep on every run, so the
237
+ generated stage lists each path once.
238
+ """
239
+ seen: set[str] = set()
240
+ unique: list[str] = []
241
+ for path in paths:
242
+ if path not in seen:
243
+ seen.add(path)
244
+ unique.append(path)
245
+ return unique
246
+
247
+
231
248
  class Stage(BaseModel):
232
249
  """A stage in the pipeline."""
233
250
 
@@ -709,6 +726,9 @@ class Stage(BaseModel):
709
726
  path = i if isinstance(i, str) else i.path
710
727
  if path not in deps:
711
728
  deps.append(path)
729
+ # Each path appears once: a stage that names the same one twice, e.g.
730
+ # a target that is also an input, otherwise reaches DVC as a duplicate
731
+ deps = _unique_paths(deps)
712
732
  outs = self.dvc_outs
713
733
  log_out = self.scheduler_log_output
714
734
  if log_out is not None:
@@ -954,7 +974,7 @@ class MapPathsStage(Stage):
954
974
  deps = []
955
975
  for path in self.paths:
956
976
  deps.append(path.src)
957
- return deps + super().dvc_deps
977
+ return _unique_paths(deps + super().dvc_deps)
958
978
 
959
979
  @property
960
980
  def dvc_outs(self) -> list[dict]:
@@ -1288,7 +1308,7 @@ class LatexStage(Stage):
1288
1308
  deps = [self.target_path] + super().dvc_deps
1289
1309
  if self.latexmkrc_path is not None:
1290
1310
  deps.append(self.latexmkrc_path)
1291
- return deps
1311
+ return _unique_paths(deps)
1292
1312
 
1293
1313
  @property
1294
1314
  def dvc_outs(self) -> list[str | dict]:
@@ -179,6 +179,9 @@ def test_execute_notebook(tmp_dir):
179
179
  sys.platform == "win32",
180
180
  reason="TODO: Julia env init fails on Windows GHA runners (Pkg stdlib missing)",
181
181
  )
182
+ # Both install and build IJulia in the shared depot, and one precompiling it
183
+ # while the other builds it fails with "IJulia not properly installed"
184
+ @pytest.mark.xdist_group("ijulia")
182
185
  def test_execute_notebook_julia(tmp_dir):
183
186
  subprocess.check_call(
184
187
  [
@@ -218,6 +221,9 @@ def test_execute_notebook_julia(tmp_dir):
218
221
  sys.platform == "win32",
219
222
  reason="TODO: Julia env init fails on Windows GHA runners (Pkg stdlib missing)",
220
223
  )
224
+ # Both install and build IJulia in the shared depot, and one precompiling it
225
+ # while the other builds it fails with "IJulia not properly installed"
226
+ @pytest.mark.xdist_group("ijulia")
221
227
  def test_check_env_kernel_julia(tmp_dir):
222
228
  subprocess.check_call(["calkit", "init"])
223
229
  subprocess.check_call(
@@ -23,7 +23,7 @@ def test_update_project_config(tmp_dir, monkeypatch):
23
23
 
24
24
  import requests
25
25
 
26
- monkeypatch.setattr(requests, "get", fail)
26
+ monkeypatch.setattr(requests.Session, "request", fail)
27
27
  subprocess.check_call(["calkit", "init"])
28
28
  result = runner.invoke(update_app, ["devcontainer"])
29
29
  assert result.exit_code == 0
@@ -561,24 +561,39 @@ def test_update_hub_creates_repo(tmp_dir, tmp_path_factory, monkeypatch):
561
561
  posted: list[dict] = []
562
562
  remote = {"url": ""}
563
563
 
564
+ posted_resp: dict = {}
565
+
564
566
  def post(path, json=None, **kwargs):
565
567
  posted.append(json)
566
- return {"git_repo_url": remote["url"]}
568
+ return {"git_repo_url": remote["url"]} | posted_resp
567
569
 
568
570
  existing: set[str] = set()
569
-
570
- def get(path, **kwargs):
571
+ on_hub: dict[str, dict] = {}
572
+
573
+ def get(path, params=None, **kwargs):
574
+ if path == "/projects":
575
+ found = on_hub.get(params["github_repo"].lower())
576
+ return {
577
+ "data": [found] if found else [],
578
+ "count": int(bool(found)),
579
+ }
571
580
  if path not in existing:
572
581
  raise RuntimeError("404: Not found")
573
582
  return {}
574
583
 
584
+ remote_names: list[str] = []
585
+
586
+ def configure_remote() -> str:
587
+ remote_names.append(calkit.detect_project_name())
588
+ return "calkit"
589
+
575
590
  monkeypatch.setattr(calkit.hub, "post", post)
576
591
  monkeypatch.setattr(calkit.hub, "get", get)
577
592
  monkeypatch.setattr(
578
593
  calkit.hub, "get_current_user", lambda: {"github_username": "someone"}
579
594
  )
580
595
  monkeypatch.setattr(calkit.hub, "get_hub_url", lambda: "https://hub.test")
581
- monkeypatch.setattr(calkit.dvc, "configure_remote", lambda: "calkit")
596
+ monkeypatch.setattr(calkit.dvc, "configure_remote", configure_remote)
582
597
  monkeypatch.setattr(
583
598
  calkit.dvc, "set_remote_auth", lambda remote_name: None
584
599
  )
@@ -626,3 +641,34 @@ def test_update_hub_creates_repo(tmp_dir, tmp_path_factory, monkeypatch):
626
641
  assert "already exists" in result.output
627
642
  assert len(posted) == n_posted
628
643
  assert calkit.load_calkit_info()["hub"] == "https://hub.test"
644
+ # An org's repo already on the hub is found by its URL, and the remote
645
+ # is named for the account that owns it there, which is lowercase
646
+ on_hub["acme-lab/widget"] = {
647
+ "owner_account_name": "acme-lab",
648
+ "name": "widget",
649
+ }
650
+ git("remote", "set-url", "origin", "git@github.com:Acme-Lab/widget.git")
651
+ with open("calkit.yaml", "w") as f:
652
+ f.write("title: Widget\n")
653
+ result = runner.invoke(
654
+ update_app, ["hub", "https://hub.test", "--no-commit"]
655
+ )
656
+ assert result.exit_code == 0, result.output
657
+ assert len(posted) == n_posted
658
+ assert remote_names[-1] == "acme-lab/widget"
659
+ ck_info = calkit.load_calkit_info()
660
+ assert ck_info["owner"] == "acme-lab"
661
+ assert ck_info["name"] == "widget"
662
+ assert "https://hub.test/acme-lab/widget" in result.output
663
+ # One the client creates takes the owner the hub gave it
664
+ on_hub.clear()
665
+ with open("calkit.yaml", "w") as f:
666
+ f.write("name: widget\ntitle: Widget\n")
667
+ posted_resp["owner_account_name"] = "acme-lab"
668
+ result = runner.invoke(
669
+ update_app, ["hub", "https://hub.test", "--no-commit"]
670
+ )
671
+ assert result.exit_code == 0, result.output
672
+ assert posted[-1]["git_repo_url"] == "https://github.com/Acme-Lab/widget"
673
+ assert remote_names[-1] == "acme-lab/widget"
674
+ assert calkit.load_calkit_info()["owner"] == "acme-lab"
@@ -586,3 +586,48 @@ def test_commit_path_with_missing_dep(tmp_dir):
586
586
  # The output's hash should be kept, since there's nothing new to hash
587
587
  assert lock["stages"]["my-stage"]["outs"][0]["path"] == "output.txt"
588
588
  assert "md5" in lock["stages"]["my-stage"]["outs"][0]
589
+
590
+
591
+ def test_ck_fs_reads_content_only_for_dirs() -> None:
592
+ from calkit.dvc.core import CalkitDVCFileSystem
593
+
594
+ calls: list[tuple[list[str], bool]] = []
595
+ stored = {
596
+ "o/p/files/md5/aa/1.dir": b"[]",
597
+ "o/p/files/md5/bb/2": b"data",
598
+ }
599
+
600
+ class FakeFS:
601
+ def info_many(
602
+ self, paths: list[str], content: bool = True, **kwargs: object
603
+ ) -> dict[str, dict]:
604
+ calls.append((sorted(paths), content))
605
+ infos = {}
606
+ for path in paths:
607
+ if path in stored:
608
+ infos[path] = {"name": path, "size": len(stored[path])}
609
+ if content:
610
+ infos[path]["content"] = stored[path]
611
+ return infos
612
+
613
+ fs = CalkitDVCFileSystem()
614
+ fs.__dict__["fs"] = FakeFS()
615
+ paths = [
616
+ "o/p/files/md5/aa/1.dir",
617
+ "o/p/files/md5/bb/2",
618
+ "o/p/files/md5/cc/3.dir",
619
+ "o/p/files/md5/dd/4",
620
+ ]
621
+ assert fs.exists(paths) == [True, True, False, False]
622
+ assert calls == [
623
+ (["o/p/files/md5/aa/1.dir", "o/p/files/md5/cc/3.dir"], True),
624
+ (["o/p/files/md5/bb/2", "o/p/files/md5/dd/4"], False),
625
+ ]
626
+ infos = fs.info(paths[:2])
627
+ assert infos[0]["content"] == b"[]"
628
+ assert "content" not in infos[1]
629
+ # Everything asked about is answered from what was already fetched
630
+ assert len(calls) == 2
631
+ with pytest.raises(FileNotFoundError):
632
+ fs.info(paths)
633
+ assert len(calls) == 2
@@ -17,6 +17,7 @@ from calkit.models.pipeline import (
17
17
  Pipeline,
18
18
  PythonScriptStage,
19
19
  QuartoStage,
20
+ ShellScriptStage,
20
21
  StageIteration,
21
22
  WordToPdfStage,
22
23
  )
@@ -1005,3 +1006,61 @@ def test_system_env_can_wrap_a_runtime():
1005
1006
  bad.set_stage_scheduler_options(
1006
1007
  envs | {"sched": {"kind": "slurm", "host": "hpc.edu"}}
1007
1008
  )
1009
+
1010
+
1011
+ def test_dvc_deps_are_unique_per_stage():
1012
+ # A generated dvc.yaml lists each dependency once. A stage that names the
1013
+ # same path twice otherwise reaches DVC as a duplicate, which it warns
1014
+ # about on every run
1015
+ def assert_unique(deps: list[str]) -> None:
1016
+ assert len(deps) == len(set(deps)), f"duplicates in {deps}"
1017
+
1018
+ # The target is also a common input, e.g. a bib or a figure the document
1019
+ # reads, so it arrives as both target_path and an input
1020
+ latex = LatexStage(
1021
+ name="paper",
1022
+ environment="tex",
1023
+ target_path="main.tex",
1024
+ inputs=["main.tex", "figures/plot.png"],
1025
+ )
1026
+ assert_unique(latex.dvc_deps)
1027
+ # One source copied to two destinations is one dependency, not two
1028
+ mapped = MapPathsStage(
1029
+ name="copy",
1030
+ paths=[
1031
+ dict(kind="file-to-file", src="paper/main.tex", dest="out/a.tex"),
1032
+ dict(kind="file-to-file", src="paper/main.tex", dest="out/b.tex"),
1033
+ ],
1034
+ )
1035
+ assert_unique(mapped.dvc_deps)
1036
+ # The compiled stage is what reaches dvc.yaml
1037
+ for stage in [latex, mapped]:
1038
+ assert_unique(stage.to_dvc()["deps"])
1039
+
1040
+
1041
+ def test_to_dvc_deps_have_no_duplicates():
1042
+ # A script named as its own input reaches the generated stage twice:
1043
+ # once through the script_path override and once through inputs
1044
+ for stage in [
1045
+ PythonScriptStage(
1046
+ name="run",
1047
+ environment="py",
1048
+ script_path="run.py",
1049
+ inputs=["run.py", "data/in.csv"],
1050
+ ),
1051
+ ShellScriptStage(
1052
+ name="shell",
1053
+ kind="shell-script",
1054
+ environment="_system",
1055
+ script_path="run.sh",
1056
+ inputs=["run.sh"],
1057
+ ),
1058
+ WordToPdfStage(
1059
+ name="doc",
1060
+ environment="tex",
1061
+ word_doc_path="document.docx",
1062
+ inputs=["document.docx"],
1063
+ ),
1064
+ ]:
1065
+ deps = stage.to_dvc()["deps"]
1066
+ assert len(deps) == len(set(deps)), f"duplicates in {deps}"
calkit/tests/test_core.py CHANGED
@@ -364,6 +364,56 @@ def test_ryaml_dump_keeps_significant_trailing_whitespace():
364
364
  assert calkit.ryaml.load(buf.getvalue()) == {"k": value}
365
365
 
366
366
 
367
+ @pytest.mark.usefixtures("tmp_dir")
368
+ def test_ryaml_keeps_how_prose_was_wrapped() -> None:
369
+ import copy
370
+ import io
371
+ from pathlib import Path
372
+
373
+ import calkit
374
+
375
+ def dumped(data: dict) -> str:
376
+ buf = io.StringIO()
377
+ calkit.ryaml.dump(data, buf)
378
+ return buf.getvalue()
379
+
380
+ # Wrapped narrower than ruamel's width, which used to re-fold it all
381
+ text = (
382
+ "title: A project\n"
383
+ "questions:\n"
384
+ " - question: Does the new mesh change the drag coefficient by\n"
385
+ " more than the measurement uncertainty?\n"
386
+ " notes: The coarse and fine meshes agree to within two\n"
387
+ " percent, so the remaining difference comes from the\n"
388
+ " turbulence model rather than the resolution.\n"
389
+ )
390
+ with open("calkit.yaml", "w") as f:
391
+ f.write(text)
392
+
393
+ # However it's loaded, an untouched file is written back as it was
394
+ with open("calkit.yaml") as f:
395
+ from_file = calkit.ryaml.load(f)
396
+ for data in [
397
+ calkit.ryaml.load(text),
398
+ from_file,
399
+ calkit.ryaml.load(Path("calkit.yaml")),
400
+ copy.deepcopy(from_file),
401
+ ]:
402
+ assert dumped(data) == text
403
+ info = calkit.load_calkit_info()
404
+ info["owner"] = "someone"
405
+ calkit.save_calkit_info(info)
406
+ with open("calkit.yaml") as f:
407
+ assert f.read() == text + "owner: someone\n"
408
+ # A value that's changed is wrapped at the width like any other
409
+ data = calkit.ryaml.load(text)
410
+ data["questions"][0]["notes"] += " And one more sentence to fold."
411
+ out = dumped(data)
412
+ assert "agree to within two\n" not in out
413
+ assert calkit.ryaml.load(out) == data
414
+ assert " more than the measurement uncertainty?\n" in out
415
+
416
+
367
417
  def test_update_readme_content():
368
418
  import calkit
369
419
 
calkit/tests/test_docx.py CHANGED
@@ -15,6 +15,7 @@ import re
15
15
  import shutil
16
16
  import subprocess
17
17
  from pathlib import Path
18
+ from xml.etree import ElementTree as ET
18
19
 
19
20
  import pytest
20
21
 
@@ -604,6 +605,13 @@ def test_docx_round_trip(
604
605
  assert lo_sent is not None and len(lo_sent.equations) == 1
605
606
  assert len(lo_sent.paragraphs) >= 15
606
607
  assert any(p.text == "(1)" for p in lo.paragraphs)
608
+ # A reference to an equation links to it inside the document, not
609
+ # to TeX4ht's build directory
610
+ body = lo.parts["word/document.xml"].decode("utf-8")
611
+ anchors = re.findall(r'w:anchor="([^"]+)"', body)
612
+ marks = set(re.findall(r'w:bookmarkStart [^>]*w:name="([^"]+)"', body))
613
+ assert anchors and set(anchors) <= marks
614
+ assert "HYPERLINK" not in body and ".4om" not in body
607
615
  main = Path("paper/main.tex").read_text(encoding="utf-8")
608
616
  methods = Path("paper/methods.tex").read_text(encoding="utf-8")
609
617
  res = subprocess.run(
@@ -615,3 +623,48 @@ def test_docx_round_trip(
615
623
  assert "Applied 0 edits" in res.stdout
616
624
  assert Path("paper/main.tex").read_text(encoding="utf-8") == main
617
625
  assert Path("paper/methods.tex").read_text(encoding="utf-8") == methods
626
+
627
+
628
+ def test_equation_links() -> None:
629
+ content = (
630
+ "<text:p>See (<text:a xlink:href='main-m7.4om#x7-3001r2' "
631
+ "xlink:type='simple'>2</text:a>), "
632
+ '<text:a xlink:href="main-m7.4om#x7-3001r3">3</text:a>, '
633
+ "<text:a xlink:href='#Xsmith2020'>Smith</text:a>, "
634
+ "<text:a xlink:href='https://calkit.io/a.html#b'>web</text:a> and "
635
+ "<text:a xlink:href='other.4om#x9'>gone</text:a>.</text:p>"
636
+ "<text:p><draw:frame draw:name='obj-7'><draw:object "
637
+ "xlink:href='./main-m7' xlink:show='embed'/></draw:frame></text:p>"
638
+ )
639
+ out = calkit.docx._link_into_objects(content)
640
+ # Links into an object point at bookmarks beside it
641
+ assert "xlink:href='#x7-3001r2'" in out
642
+ assert 'xlink:href="#x7-3001r3"' in out
643
+ assert (
644
+ "<text:p><text:bookmark text:name='x7-3001r2'/>"
645
+ "<text:bookmark text:name='x7-3001r3'/><draw:frame" in out
646
+ )
647
+ # Links within the document or out of it, or to an object that isn't
648
+ # there, are left alone
649
+ assert "xlink:href='#Xsmith2020'" in out
650
+ assert "xlink:href='https://calkit.io/a.html#b'" in out
651
+ assert "xlink:href='other.4om#x9'" in out
652
+ assert calkit.docx._link_into_objects(out) == out
653
+ # A kept bookmark goes on the row it's for, not just the first
654
+ doc = calkit.docx.Document(str(FIXTURES / "export.docx"))
655
+ w, m = calkit.docx.W, calkit.docx.M
656
+ before = next(doc.doc.iter(calkit.docx._tag(w, "p")))
657
+ rows = [
658
+ (ET.Element(calkit.docx._tag(m, "oMath")), "4", "ck_a"),
659
+ (ET.Element(calkit.docx._tag(m, "oMath")), "5", "ck_a_r2"),
660
+ ]
661
+ doc.insert_equations(before, rows, 100, keep={1: [("x13-3004r5", "7")]})
662
+ tbl = next(doc.doc.iter(calkit.docx._tag(w, "tbl")))
663
+ names = [
664
+ [
665
+ b.get(calkit.docx._tag(w, "name"))
666
+ for b in tr.iter(calkit.docx._tag(w, "bookmarkStart"))
667
+ ]
668
+ for tr in tbl.iter(calkit.docx._tag(w, "tr"))
669
+ ]
670
+ assert names == [["ck_a"], ["ck_a_r2", "x13-3004r5"]]
calkit/tests/test_hub.py CHANGED
@@ -294,7 +294,7 @@ def test_request_retries_on_401_with_refresh(monkeypatch):
294
294
  return Resp401()
295
295
  return Resp200()
296
296
 
297
- monkeypatch.setattr(hub.requests, "get", _fake_get)
297
+ monkeypatch.setattr(hub._session, "get", _fake_get)
298
298
  monkeypatch.setitem(hub._tokens, base_url, fresh)
299
299
  monkeypatch.setattr(hub, "_try_refresh", lambda: fresh)
300
300
  result = hub._request("get", "/test", base_url=base_url)
@@ -333,7 +333,7 @@ def test_request_invalid_credentials_403_triggers_refresh(monkeypatch):
333
333
  call_count["n"] += 1
334
334
  return Resp403() if call_count["n"] == 1 else Resp200()
335
335
 
336
- monkeypatch.setattr(hub.requests, "get", _fake_get)
336
+ monkeypatch.setattr(hub._session, "get", _fake_get)
337
337
  monkeypatch.setitem(hub._tokens, base_url, fresh)
338
338
  monkeypatch.setattr(hub, "_try_refresh", lambda: fresh)
339
339
  result = hub._request("get", "/test", base_url=base_url)
@@ -369,7 +369,7 @@ def test_request_permission_403_does_not_trigger_refresh(monkeypatch):
369
369
  refresh_calls["n"] += 1
370
370
  return fresh
371
371
 
372
- monkeypatch.setattr(hub.requests, "post", _fake_post)
372
+ monkeypatch.setattr(hub._session, "post", _fake_post)
373
373
  monkeypatch.setitem(hub._tokens, base_url, fresh)
374
374
  monkeypatch.setattr(hub, "_try_refresh", _fake_refresh)
375
375
  with pytest.raises(Exception):
@@ -410,7 +410,7 @@ def test_request_retries_on_transient_5xx(monkeypatch):
410
410
  calls["n"] += 1
411
411
  return Resp(status)
412
412
 
413
- monkeypatch.setattr(hub.requests, "get", _fake_get)
413
+ monkeypatch.setattr(hub._session, "get", _fake_get)
414
414
  result = hub._request("get", "/test", base_url=base_url)
415
415
  assert result == {"ok": True}
416
416
  assert calls["n"] == 2
@@ -421,7 +421,7 @@ def test_request_retries_on_transient_5xx(monkeypatch):
421
421
  persistent["n"] += 1
422
422
  return Resp(500)
423
423
 
424
- monkeypatch.setattr(hub.requests, "get", _fake_get_500)
424
+ monkeypatch.setattr(hub._session, "get", _fake_get_500)
425
425
  with pytest.raises(HTTPError):
426
426
  hub._request("get", "/test", base_url=base_url)
427
427
  # Initial attempt plus max_retries follow-ups.
@@ -458,7 +458,7 @@ def test_request_retries_on_network_error(monkeypatch):
458
458
  raise errors[calls["n"] - 1]
459
459
  return Resp200()
460
460
 
461
- monkeypatch.setattr(hub.requests, "get", _fake_get)
461
+ monkeypatch.setattr(hub._session, "get", _fake_get)
462
462
  assert hub._request("get", "/test", base_url=base_url) == {"ok": True}
463
463
  assert calls["n"] == 3
464
464
  # Case 2: a persistent network error exhausts retries and propagates.
@@ -468,7 +468,7 @@ def test_request_retries_on_network_error(monkeypatch):
468
468
  persistent["n"] += 1
469
469
  raise Timeout("read timeout")
470
470
 
471
- monkeypatch.setattr(hub.requests, "get", _always_timeout)
471
+ monkeypatch.setattr(hub._session, "get", _always_timeout)
472
472
  with pytest.raises(Timeout):
473
473
  hub._request("get", "/test", base_url=base_url)
474
474
  assert persistent["n"] == 11
@@ -533,7 +533,7 @@ def test_request_can_skip_retries_and_login(monkeypatch):
533
533
  attempts["n"] += 1
534
534
  return _Resp()
535
535
 
536
- monkeypatch.setattr(calkit.hub.requests, "post", fake_post)
536
+ monkeypatch.setattr(calkit.hub._session, "post", fake_post)
537
537
  monkeypatch.setattr(calkit.hub, "get_headers", lambda h, auth=True: {})
538
538
  monkeypatch.setattr(calkit.hub, "get_base_url", lambda: "http://x")
539
539
  with pytest.raises(calkit.hub.HTTPError):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: calkit-python
3
- Version: 0.48.0
3
+ Version: 0.48.1
4
4
  Summary: Reproducibility simplified.
5
5
  Project-URL: Homepage, https://calkit.org
6
6
  Project-URL: Issues, https://github.com/calkit/calkit/issues
@@ -3,18 +3,18 @@ calkit/__main__.py,sha256=whqS7I7q_c9004emvKptZ9B9OVn-jgr5kaLaCFKmwa0,112
3
3
  calkit/calc.py,sha256=5RQVwH3goakmeDwHtmslcAZJXEZihIBuLNw_lF6QCcc,7924
4
4
  calkit/conda.py,sha256=ddsHScMlPkpPU-d5vi-X--ANFvO2fi0fRFUFd_Ux51Y,33399
5
5
  calkit/config.py,sha256=kMGYi-S4Ojp_VRGgxapvsci3XYcIBx3jGllmdLe7wuo,21780
6
- calkit/core.py,sha256=eI5VcAxTJlaleBsnCCGVsP764QTwrUw53WAKXN8SNe4,54766
6
+ calkit/core.py,sha256=HBG4gho491y5tZL_VTXo_HFB-sIZnoKhuFWQ5X0Kplc,58092
7
7
  calkit/datasets.py,sha256=3YtDmvBCzyX5AC-7Mb7ylnxM1mFLyTLj5CH7QqblDwA,2212
8
8
  calkit/dependencies.py,sha256=MI_wuvQaY9okkmui37qz2yIxxBXJ3yuJUOQRj95IEbw,15362
9
9
  calkit/detect.py,sha256=qEdYOrXMVCUYRoQ7YBQZBn_YZQT3K2GeWSOnWceGpNA,88418
10
10
  calkit/docker.py,sha256=kMMcmAsVIuvYlANiVow2DuLTkMKJBN8HabyW5UsgOYs,50668
11
- calkit/docx.py,sha256=RzqiTHfyKrf_tHuaVtGKGPfW6pGTYpZbXyvSNYmc36c,52670
11
+ calkit/docx.py,sha256=wP0vwzcTdRDknUyaelr1_OVgW8C6RDnfZHKKRPXWp4k,55141
12
12
  calkit/environments.py,sha256=O10Ppy2Sy0IKzFYoAJRbdxELBDG04u6p9zMKWuRh0-8,102536
13
- calkit/fs.py,sha256=C6Xubi6BBdyHwJrPhFr6I07zusGQJ6E5Cnl-Xu9O7U8,45469
13
+ calkit/fs.py,sha256=7huOJIDPni4bTJ3MZyyifgE4NqbfbT_bPW8Uff-7JMU,45658
14
14
  calkit/git.py,sha256=ikSKyvx-xuk64deSeBTDqXsNlaFVePlP8Vc_Zupd7IU,27324
15
15
  calkit/github.py,sha256=nBR3CH3mFIDk_atWtZ_EdQEP3ktDi-xNPbe6Bbusarg,1699
16
16
  calkit/gui.py,sha256=2UCrMyosc5v4CLlDcHO7oDwW9eLn5_WbNaLaG5bjMTc,23
17
- calkit/hub.py,sha256=mZ1rAnDOOK2J6jAXqKsjaNJnlxveZkIlwE1BjvIxmgU,19612
17
+ calkit/hub.py,sha256=cZ8ltzambJ9MObb5Xj1B8MblmO0XVws7Y1MoOO0gWlM,19959
18
18
  calkit/install.py,sha256=y3-ZUZt1Tf3H9mP5v8dXtrNnhx7NiJPolF9ptlWRUkk,16997
19
19
  calkit/invenio.py,sha256=i3UrEkzWXw4buhVwgjpLJL4AierBYnyWJCg0ytLuaBo,6214
20
20
  calkit/julia.py,sha256=4GxUSItNXpYcRlCD9N8dLiuX44-HQaKFcuEudD8At1I,5640
@@ -47,7 +47,7 @@ calkit/cli/describe.py,sha256=LMODpla_g7hyTI7Oi7V5dAb9WSp5nZ_4-XhSYmZP1eo,3608
47
47
  calkit/cli/dev.py,sha256=_BZqDdfF1VSfXaFfdBvofwE6cpcevhbgjRYNUHRQU2M,1761
48
48
  calkit/cli/hub.py,sha256=ZBOlL7-mV57q_ynr4hPuhV7NlkKEkINSRksXD-jjiOg,2972
49
49
  calkit/cli/import_.py,sha256=QOZEfKUmqMntle47PRZhN8cs7gfWCBte_smPZqksl54,25563
50
- calkit/cli/latex.py,sha256=uCVeQoRHPbhRI6Zbrl2S6PqdZGqbzSjFQ9sxmSzn6EM,110414
50
+ calkit/cli/latex.py,sha256=CXpPsu8359pUR7fJkXosRFaiag8aLa1YAk_GUCf04Y0,111959
51
51
  calkit/cli/list.py,sha256=dcpDdeG8OdjL5--DbE27URXQnuB4w1pPYltsBFC45_s,21969
52
52
  calkit/cli/new.py,sha256=aGPHpz8poGt_F04sdaKsvOLr4Kowc0qaV-n4dhO6VPM,158819
53
53
  calkit/cli/notebooks.py,sha256=aR4I0qatOtkrAxEG5PVLej20SBLKo8qr-lxDSzgljUQ,38836
@@ -56,12 +56,12 @@ calkit/cli/overleaf.py,sha256=vuV0GktH2ZlI93s-pA7RZ4zRFM9BLUC20lMrVZ92mro,38727
56
56
  calkit/cli/scheduler.py,sha256=s4q9wLcvTCmxuLmAoz7ptJZiNXPWCamn-NU1S5l28go,52719
57
57
  calkit/cli/show.py,sha256=zI9dqviFxXlcjnpEXnN7Ip2kmw9bVwP74rJDM9UqZ4s,3074
58
58
  calkit/cli/sync.py,sha256=o6EnwADaUUK1yudWTc0gllKNLYD54jJbMnYBIuG5SfY,12643
59
- calkit/cli/update.py,sha256=_q8XOxJWIeNn4FKez-T07AebufGksFmMUjLIwqrhptQ,63660
59
+ calkit/cli/update.py,sha256=CfjghxEQIB8dJ7XahH64kroULFhQHLJqRAiOKNEOwZY,64595
60
60
  calkit/cli/main/__init__.py,sha256=qu1POZPyqs33ZKfasOxv_Wc-EzVcEgK3Dt_vwFL8Bi8,65
61
61
  calkit/cli/main/core.py,sha256=cHeRj62MREnc6_t7FFUs2mtu-vl6UiVtyYelmrXdF7g,179890
62
62
  calkit/cli/main/xr.py,sha256=cYQBNVlRTzp6ba3EwEwZylOxVpk9ThWhy6IAD7_ahWw,37594
63
63
  calkit/dvc/__init__.py,sha256=-B6tilCb_jw7QFmJ2srILez24wDhNKUzIBhZUMt01dE,381
64
- calkit/dvc/core.py,sha256=f_gvVn5TglwdwKL6uebb4qTQ9Ac-oaFvRi0ZKHKdY6Y,45377
64
+ calkit/dvc/core.py,sha256=RadL_0va0kIaalIkDLzCa-KPbiOltlR1ADFPKtsP_ro,45866
65
65
  calkit/dvc/zip.py,sha256=8_FdpEeQ5N3mbq0nwqm69F3enOHsQkSZpx2KBYLbSCg,21888
66
66
  calkit/jupyterlab/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
67
67
  calkit/jupyterlab/routes.py,sha256=sARrFjWnHVV-LunrgqO4DCt_x7XLhVPAqA1eaUFf7HQ,51443
@@ -70,7 +70,7 @@ calkit/models/core.py,sha256=mL9ImXwt1iTtDKzdmt96F9ONevLSTWhIKb_xAEJFb5I,80570
70
70
  calkit/models/docx.py,sha256=pVfBSmb2pfe-AeDYhJuYCvGohE98ixDuBBbkmtDyOMU,2897
71
71
  calkit/models/io.py,sha256=0nup-gDz3FbiD8AT5pTpxji6XN_rW5AsijErsIFdpbg,2330
72
72
  calkit/models/iteration.py,sha256=_QajYhWVXV9ge9naX8ZkT1PBNzfc75cpl2BR2f8VPOo,2945
73
- calkit/models/pipeline.py,sha256=jVh03eoPSNUjxv0zJFoDnZX5HL9yFcJs_RlHeqFDcw4,99661
73
+ calkit/models/pipeline.py,sha256=6JiABNJDx_MgcsswT8AgNiY2R3ff3cqt6cFZ22CCPfM,100460
74
74
  calkit/resources/README.md,sha256=sqqGXkhVLGeo50cXHx6XDm_yYB3klyDhudisZlLpNuE,1463
75
75
  calkit/resources/__init__.py,sha256=QhDLrihdVQGyW2xEGzj2fIHGheq6Pn8W8jwlrjmmRv0,5122
76
76
  calkit/resources/devcontainer/Dockerfile,sha256=U56BhC7sB9qlwpSMMb8CNHgNyuF-ZCV7X7E88rx6KQQ,3091
@@ -101,16 +101,16 @@ calkit/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
101
101
  calkit/tests/test_calc.py,sha256=h-wOUIRqfyi06LTzhY_rS-a1xChZTkmwdEfBybMUM7E,2033
102
102
  calkit/tests/test_conda.py,sha256=aJzuKHT81DfbmcRpae-x0I8s6I-ZTdbSlq5yjIYIvTM,17097
103
103
  calkit/tests/test_config.py,sha256=hPJ-PRDOXjrwwO_PcP8KG1ehlrlHUI2Qhlkja9NfMCU,14834
104
- calkit/tests/test_core.py,sha256=t21yli5UU66LDaK2zpcxWQdp9dcJ8WV6VktsUZk9dEE,16379
104
+ calkit/tests/test_core.py,sha256=m-P2OSWv1MjABY2YgSJJdtkvzPNPOspdvcLXT2j0GDs,18106
105
105
  calkit/tests/test_dependencies.py,sha256=Stb_pQr8HTrkUSTjZFAJ2aFOXAzlFNXtEjf2Uh0PF2E,12672
106
106
  calkit/tests/test_detect.py,sha256=rNi_W5ck4_TZo2_tXfIfTc-U_GxOePdz_jJjaqnewlA,49981
107
107
  calkit/tests/test_docker.py,sha256=mBjj_z47fqVLfZTbuJoJwXTAo-udd22RZGR-IXTX9a4,20704
108
108
  calkit/tests/test_docs_references.py,sha256=n9XZMniIH1XvlUxdlFcLeQuNBwJsDydyVYZOeAh2XrA,5264
109
- calkit/tests/test_docx.py,sha256=NWiP7zR8T8jWKTTpYoa11u13W7aO8BBM3XKK4C85BU8,26019
109
+ calkit/tests/test_docx.py,sha256=wYTVX2axWkMZC4cxzQsJjllqf__O-6WaAHMB67FHTbg,28481
110
110
  calkit/tests/test_environments.py,sha256=sJoarJMpSub1u9FvqY-KcVzdfg7svpgDEUeajC4DcDQ,62874
111
111
  calkit/tests/test_fs.py,sha256=vBbzZlikzwAQkPwn8_-wdsD-l1R4YTH7g-ghGfWrtzE,13785
112
112
  calkit/tests/test_git.py,sha256=i_m11l5MkgjzuI0hB6PwmSCQ-Vx_Bgk2moZEorpODxo,26016
113
- calkit/tests/test_hub.py,sha256=bxo_aRj3G_vmWlxBM6A7A9hjG_RBI9DW8fdA2wivhlc,17824
113
+ calkit/tests/test_hub.py,sha256=H7We9KFZnW2Y5SqKVRqQCOksCjZHDoFc_IFJmiXDANQ,17824
114
114
  calkit/tests/test_install.py,sha256=ZpM0HAVLoddtyWuuOoATIkJoCpst0w6TIzXIY-ZQik4,14739
115
115
  calkit/tests/test_invenio.py,sha256=TJI8HsWkrLl0XOW-K51wdoRd-LH6RhpfvgRonjcdnZI,3693
116
116
  calkit/tests/test_julia.py,sha256=q7UYIJAvCQ8b6eMaledcV3rV3PPL6hDnpYJEzn2tmCE,6390
@@ -145,19 +145,19 @@ calkit/tests/cli/test_import.py,sha256=snQgCw6SM9V3g5NS8oBK-2B3bYFVyJcuXtJuFtDtf
145
145
  calkit/tests/cli/test_latex.py,sha256=6UTu1vMjv3a7PCXQvB6nLdyRFDyWn-VvlgiOpUIRqhQ,36998
146
146
  calkit/tests/cli/test_list.py,sha256=rnoDS2JOiy2TN_zj1LI2IgIa0WKP8UA1zlj4_-uLjpI,13510
147
147
  calkit/tests/cli/test_new.py,sha256=WqesquN9fuDs04en5TW06MhVNbwITQBcsi_D_fuTwcQ,76963
148
- calkit/tests/cli/test_notebooks.py,sha256=RcFnZAnyEnWpgPj1Hsnhryn0gCncgtHRxDSEROanOlE,11904
148
+ calkit/tests/cli/test_notebooks.py,sha256=FkD2HD9kveyKuXU55MgHoYyTnOGNzS-FtFIdVtMHzkc,12270
149
149
  calkit/tests/cli/test_overleaf.py,sha256=VRBXGmU_8dvIsn3marpV5BmviCq3ofij_FtvqFkW_eQ,49790
150
150
  calkit/tests/cli/test_scheduler.py,sha256=YfJi_fcRlQt1bdFyo3b8BFelA1PcH-wn5lAVXiH7Zj4,24109
151
151
  calkit/tests/cli/test_show.py,sha256=9vXLdRvHuFvXDRbTgTrMJZS9o_EYr55GyqMfpHHBUi4,3327
152
152
  calkit/tests/cli/test_sync.py,sha256=K8FbLq0PPE_JJmYRdXxlWcMx7IAAo-VjiVsfIPj-aYI,4811
153
- calkit/tests/cli/test_update.py,sha256=FVm2A2umERFMkmA-irMaHR7nT6Zd5Sec8c36XPqYXbQ,21804
153
+ calkit/tests/cli/test_update.py,sha256=5OL7VjSFxdD4i1pztFIWd_5WDx2oEgh4jTIBBEb27fo,23648
154
154
  calkit/tests/cli/main/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
155
155
  calkit/tests/cli/main/test_core.py,sha256=lyTQcMBmKIqPRiUaVXE5HqGNqomQU4ySSxtpVVD174U,112702
156
156
  calkit/tests/cli/main/test_lock.py,sha256=1iX8iSbGtr4O9OkaAi4d3gP3_QzNl6Rr6HLtbEpS6iU,9847
157
157
  calkit/tests/cli/main/test_subprojects.py,sha256=2IHwonTH_nI6yraFsgvOQTQ6z6JWOFOmMjZngOeMrwY,18920
158
158
  calkit/tests/cli/main/test_xr.py,sha256=0ExuYuub66wsC2tYHnj3lzlgsFsjGjkDyCV2RBIkm4U,28832
159
159
  calkit/tests/dvc/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
160
- calkit/tests/dvc/test_core.py,sha256=xAMtoTV1d893y35mQ-2tk_IjRJVK_hS-DUD6g7ciz-4,20575
160
+ calkit/tests/dvc/test_core.py,sha256=ANDdwOO-eKwV_jGxrkOY8CVC8h8EQHt9Loq0JuLP9aE,22043
161
161
  calkit/tests/dvc/test_zip.py,sha256=UjluBZYTnw5nisHELz42VRPDk88b0TvDx4QJNVaR-iA,12369
162
162
  calkit/tests/jupyterlab/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
163
163
  calkit/tests/jupyterlab/conftest.py,sha256=Xsh5Zwg0fdqEFPkHDgG7oCHhHJj6WuZLWbmsl7ATABA,207
@@ -165,28 +165,28 @@ calkit/tests/jupyterlab/test_routes.py,sha256=TEEyoHiKnzzKOkQsJcMPKdqpKFQ25yBCvV
165
165
  calkit/tests/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
166
166
  calkit/tests/models/test_core.py,sha256=iDi0mvgZDBj2n7bo-cyKd8dvRZHl5ywWbcab4BPsqcE,8421
167
167
  calkit/tests/models/test_iteration.py,sha256=vjuDKwyYQAV4w1qF9VJWQEHksq3KRd6gtT39_a-RhnI,644
168
- calkit/tests/models/test_pipeline.py,sha256=gfDv-n5QywtTJebOp4nNY8v-vje-QRs0EpNARjsNyjA,35667
168
+ calkit/tests/models/test_pipeline.py,sha256=K-q4LHh35AjvcLxgJnVTMcwsXxIdynCKO16xqdDTtO8,37692
169
169
  calkit/agent_skills/add-pipeline-stage/SKILL.md,sha256=kpYYb-rJsS4B9p1Ub3gL21irCG5ZZ4DvWZpdJeEwPZk,3772
170
170
  calkit/agent_skills/build-paper-pipeline/SKILL.md,sha256=aV9t86FxQNkeXyswM03fb5LW3ns4pcsWQyYNZ33niLI,6829
171
171
  calkit/agent_skills/check-questions/SKILL.md,sha256=yn6geOQUNQnJbM_Bn9grULerzreGvZob8vNtawWgvCs,9653
172
172
  calkit/agent_skills/check-reproducibility/SKILL.md,sha256=5SGmtnbq8bSJW0Zwav_aVzfuhilHwBkkdxGuZvPHxGU,4699
173
173
  calkit/agent_skills/conventions/SKILL.md,sha256=W9ayIG76Q8BTBgIRhpZz52dCKDLsSVj8_7O3LPeffU4,10323
174
174
  calkit/agent_skills/create-pipeline/SKILL.md,sha256=2FGw1iDQVRk5FUq79GP3lPdgvgof2Wi1O9O-abGGtgs,5422
175
- calkit_python-0.48.0.data/data/etc/jupyter/jupyter_server_config.d/calkit.json,sha256=CWrZP--JDGz8fsvbAlKr_duTL1vDriGT48SL1YCtkY0,81
176
- calkit_python-0.48.0.data/data/share/jupyter/labextensions/calkit/package.json,sha256=0hnHqhCly59Wg7urFGahc2mSupCWwiPfwKWCYfnbB2E,6202
177
- calkit_python-0.48.0.data/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig,sha256=egkbkHmJYzDARH3_4qpoF75_Svpj_dfPaIwRU-PNjW4,6064
178
- calkit_python-0.48.0.data/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json,sha256=YSpIrwpwB-lQBk9Mwv-npedWTOOZreO6nlWZhqlWyGI,840
179
- calkit_python-0.48.0.data/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js,sha256=0PxdmgdZvLeh6RBcfgN1pcriNO8oCeTK5WJvUIEA3MY,112384
180
- calkit_python-0.48.0.data/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png,sha256=svHD7-cMtTnRITFwugwsVaB9nZ-h8A61ose8z32HiRE,24850
181
- calkit_python-0.48.0.data/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js,sha256=d0rHq7cqAjj1GNAMLOpl8dyY8yyKUQcLPCOUr-wJGdQ,8129
182
- calkit_python-0.48.0.data/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js,sha256=4E0sK8x-ldVkL-1sM07wgJGS1vtVD9Uly7VtoFTi23c,221
183
- calkit_python-0.48.0.data/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js,sha256=RNTIItG9k-A_Ip54fRR3QMCjiffkY0bo8zQ2Xh7TyOY,57096
184
- calkit_python-0.48.0.data/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js,sha256=Cmp0KWM5YDZ-zcsJWWgqCMIF6adeHQH80rxz1sas45E,51183
185
- calkit_python-0.48.0.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js,sha256=4JavPLcn703qVSC2E1_MD1thrJNL4N-eITjnfNhSDSM,15066
186
- calkit_python-0.48.0.data/data/share/jupyter/labextensions/calkit/static/style.js,sha256=LTlA3M58hwjqmhomdcrNRO9GM58zZcf0V76WX9HI6EQ,146
187
- calkit_python-0.48.0.data/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json,sha256=2BGdItLJwO3fAopLl4eJvp26TEwuscYC0-yFaF-LaLU,13683
188
- calkit_python-0.48.0.dist-info/METADATA,sha256=Fs8kH4nO9atEaO1S3HKquRoqtExw2OaldGbuivr-8t8,22044
189
- calkit_python-0.48.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
190
- calkit_python-0.48.0.dist-info/entry_points.txt,sha256=TtpHpcv2vFVAVYxFVp0XrwCV_z7FWIOguEiypXRDoOw,131
191
- calkit_python-0.48.0.dist-info/licenses/LICENSE,sha256=9ZamCaSUTZk9rcrnf-sWFKLOHr3ws-S_dgKMegW4nw8,1056
192
- calkit_python-0.48.0.dist-info/RECORD,,
175
+ calkit_python-0.48.1.data/data/etc/jupyter/jupyter_server_config.d/calkit.json,sha256=CWrZP--JDGz8fsvbAlKr_duTL1vDriGT48SL1YCtkY0,81
176
+ calkit_python-0.48.1.data/data/share/jupyter/labextensions/calkit/package.json,sha256=0hnHqhCly59Wg7urFGahc2mSupCWwiPfwKWCYfnbB2E,6202
177
+ calkit_python-0.48.1.data/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig,sha256=egkbkHmJYzDARH3_4qpoF75_Svpj_dfPaIwRU-PNjW4,6064
178
+ calkit_python-0.48.1.data/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json,sha256=YSpIrwpwB-lQBk9Mwv-npedWTOOZreO6nlWZhqlWyGI,840
179
+ calkit_python-0.48.1.data/data/share/jupyter/labextensions/calkit/static/506.c34b070098184e33.js,sha256=0PxdmgdZvLeh6RBcfgN1pcriNO8oCeTK5WJvUIEA3MY,112384
180
+ calkit_python-0.48.1.data/data/share/jupyter/labextensions/calkit/static/57d13a7399cec3c5.png,sha256=svHD7-cMtTnRITFwugwsVaB9nZ-h8A61ose8z32HiRE,24850
181
+ calkit_python-0.48.1.data/data/share/jupyter/labextensions/calkit/static/616.528427eb54a4a0c0.js,sha256=d0rHq7cqAjj1GNAMLOpl8dyY8yyKUQcLPCOUr-wJGdQ,8129
182
+ calkit_python-0.48.1.data/data/share/jupyter/labextensions/calkit/static/740.6bf87276e9e5788a.js,sha256=4E0sK8x-ldVkL-1sM07wgJGS1vtVD9Uly7VtoFTi23c,221
183
+ calkit_python-0.48.1.data/data/share/jupyter/labextensions/calkit/static/899.f9b9f9bf705a6493.js,sha256=RNTIItG9k-A_Ip54fRR3QMCjiffkY0bo8zQ2Xh7TyOY,57096
184
+ calkit_python-0.48.1.data/data/share/jupyter/labextensions/calkit/static/935.46ecc6bf99aa593a.js,sha256=Cmp0KWM5YDZ-zcsJWWgqCMIF6adeHQH80rxz1sas45E,51183
185
+ calkit_python-0.48.1.data/data/share/jupyter/labextensions/calkit/static/remoteEntry.109a3a8379365e59.js,sha256=4JavPLcn703qVSC2E1_MD1thrJNL4N-eITjnfNhSDSM,15066
186
+ calkit_python-0.48.1.data/data/share/jupyter/labextensions/calkit/static/style.js,sha256=LTlA3M58hwjqmhomdcrNRO9GM58zZcf0V76WX9HI6EQ,146
187
+ calkit_python-0.48.1.data/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json,sha256=2BGdItLJwO3fAopLl4eJvp26TEwuscYC0-yFaF-LaLU,13683
188
+ calkit_python-0.48.1.dist-info/METADATA,sha256=91q0lRwvRNsd6Pri_L1zXu-N3_DvupV4gKF3RAKc_qE,22044
189
+ calkit_python-0.48.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
190
+ calkit_python-0.48.1.dist-info/entry_points.txt,sha256=TtpHpcv2vFVAVYxFVp0XrwCV_z7FWIOguEiypXRDoOw,131
191
+ calkit_python-0.48.1.dist-info/licenses/LICENSE,sha256=9ZamCaSUTZk9rcrnf-sWFKLOHr3ws-S_dgKMegW4nw8,1056
192
+ calkit_python-0.48.1.dist-info/RECORD,,