Repository: calkit/calkit

Issue:
Calkit should be able to run when `dvc` is not on the PATH
Useful for users who may install with `pipx` or `uv tool install`. They may have DVC installed anyway, but since it's a Python dependency, we'll have the code available in the virtual environment.

Discussion:

Solution patch:
diff --git a/README.md b/README.md
index e9b9cc12..e65f0cee 100644
--- a/README.md
+++ b/README.md
@@ -35,9 +35,15 @@ If you want to use [Docker](https://docker.com) containers,
 which is typically a good idea,
 that should also be installed.
 For Python, we recommend
-[Miniforge](https://conda-forge.org/miniforge/).
+[uv](https://docs.astral.sh/uv/).
 
-After Python is installed, run:
+With uv installed, install Calkit with:
+
+```sh
+uv tool install calkit-python
+```
+
+Alternatively, but less ideally, you can install with your system Python:
 
 ```sh
 pip install calkit-python
diff --git a/calkit/__init__.py b/calkit/__init__.py
index 6682fcb9..8111220d 100644
--- a/calkit/__init__.py
+++ b/calkit/__init__.py
@@ -1,4 +1,4 @@
-__version__ = "0.21.5"
+__version__ = "0.21.6"
 
 from .core import *
 from . import git
diff --git a/calkit/cli/core.py b/calkit/cli/core.py
index 55476ab2..e2c659fa 100644
--- a/calkit/cli/core.py
+++ b/calkit/cli/core.py
@@ -1,5 +1,7 @@
 """Core CLI functionality."""
 
+from __future__ import annotations
+
 import os
 import subprocess
 
diff --git a/calkit/cli/import_.py b/calkit/cli/import_.py
index a2e8291f..5e2c2006 100644
--- a/calkit/cli/import_.py
+++ b/calkit/cli/import_.py
@@ -5,6 +5,7 @@
 import base64
 import os
 import subprocess
+import sys
 from copy import deepcopy
 from typing import Annotated
 
@@ -204,7 +205,7 @@ def import_dataset(
     if not no_dvc_pull and dvc_import is not None:
         # Run dvc pull
         typer.echo("Running dvc pull")
-        subprocess.call(["dvc", "pull", dvc_fpath])
+        subprocess.call([sys.executable, "-m", "dvc", "pull", dvc_fpath])
 
 
 @import_app.command(name="environment")
diff --git a/calkit/cli/main.py b/calkit/cli/main.py
index ef5d781a..6fbe161f 100644
--- a/calkit/cli/main.py
+++ b/calkit/cli/main.py
@@ -122,12 +122,14 @@ def init(
 ):
     """Initialize the current working directory."""
     subprocess.run(["git", "init"])
-    dvc_cmd = ["dvc", "init"]
+    dvc_cmd = [sys.executable, "-m", "dvc", "init"]
     if force:
         dvc_cmd.append("-f")
     subprocess.run(dvc_cmd)
     # Ensure autostage is enabled for DVC
-    subprocess.call(["dvc", "config", "core.autostage", "true"])
+    subprocess.call(
+        [sys.executable, "-m", "dvc", "config", "core.autostage", "true"]
+    )
     # Commit the newly created .dvc directory
     repo = git.Repo()
     repo.git.add(".dvc")
@@ -214,7 +216,7 @@ def clone(
     # DVC pull
     if not no_dvc_pull:
         try:
-            subprocess.check_call(["dvc", "pull"])
+            subprocess.check_call([sys.executable, "-m", "dvc", "pull"])
         except subprocess.CalledProcessError:
             raise_error("Failed to pull from DVC remote(s)")
 
@@ -243,10 +245,10 @@ def get_status():
     run_cmd(["git", "status"])
     typer.echo()
     print_sep("Data (DVC)")
-    run_cmd(["dvc", "data", "status"])
+    run_cmd([sys.executable, "-m", "dvc", "data", "status"])
     typer.echo()
     print_sep("Pipeline (DVC)")
-    run_cmd(["dvc", "status"])
+    run_cmd([sys.executable, "-m", "dvc", "status"])
 
 
 @app.command(name="diff")
@@ -255,7 +257,7 @@ def diff():
     print_sep("Code (Git)")
     run_cmd(["git", "diff"])
     print_sep("Pipeline (DVC)")
-    run_cmd(["dvc", "diff"])
+    run_cmd([sys.executable, "-m", "dvc", "diff"])
 
 
 @app.command(name="add")
@@ -331,7 +333,9 @@ def add(
         warn("DVC not initialized yet; initializing")
         dvc_repo = dvc.repo.Repo.init()
     # Ensure autostage is enabled for DVC
-    subprocess.call(["dvc", "config", "core.autostage", "true"])
+    subprocess.call(
+        [sys.executable, "-m", "dvc", "config", "core.autostage", "true"]
+    )
     subprocess.call(["git", "add", ".dvc/config"])
     dvc_paths = calkit.dvc.list_paths()
     untracked_git_files = repo.untracked_files
@@ -393,15 +397,15 @@ def add(
                 typer.echo(
                     f"Adding {path} to DVC since it's already tracked with DVC"
                 )
-                subprocess.call(["dvc", "add", path])
+                subprocess.call([sys.executable, "-m", "dvc", "add", path])
             elif os.path.splitext(path)[-1] in DVC_EXTENSIONS:
                 typer.echo(f"Adding {path} to DVC per its extension")
-                subprocess.call(["dvc", "add", path])
+                subprocess.call([sys.executable, "-m", "dvc", "add", path])
             elif calkit.get_size(path) > DVC_SIZE_THRESH_BYTES:
                 typer.echo(
                     f"Adding {path} to DVC since it's greater than 1 MB"
                 )
-                subprocess.call(["dvc", "add", path])
+                subprocess.call([sys.executable, "-m", "dvc", "add", path])
             else:
                 typer.echo(f"Adding {path} to Git")
                 subprocess.call(["git", "add", path])
@@ -561,7 +565,7 @@ def pull(
                 typer.echo(f"Checking authentication for DVC remote: {name}")
                 calkit.dvc.set_remote_auth(remote_name=name)
     try:
-        subprocess.check_call(["dvc", "pull"])
+        subprocess.check_call([sys.executable, "-m", "dvc", "pull"])
     except subprocess.CalledProcessError:
         raise_error("DVC pull failed")
 
@@ -589,7 +593,7 @@ def push(
                     )
                     calkit.dvc.set_remote_auth(remote_name=name)
         try:
-            subprocess.check_call(["dvc", "push"])
+            subprocess.check_call([sys.executable, "-m", "dvc", "push"])
         except subprocess.CalledProcessError:
             raise_error("DVC push failed")
 
@@ -716,7 +720,7 @@ def run(
     if downstream is not None:
         args += downstream
     try:
-        subprocess.check_call(["dvc", "repro"] + args)
+        subprocess.check_call([sys.executable, "-m", "dvc", "repro"] + args)
     except subprocess.CalledProcessError:
         raise_error("DVC pipeline failed")
     # Now parse stage metadata for calkit objects
@@ -1391,3 +1395,24 @@ def switch_branch(name: Annotated[str, typer.Argument(help="Branch name.")]):
     else:
         cmd = [name]
     repo.git.checkout(cmd)
+
+
+@app.command(
+    name="dvc",
+    add_help_option=False,
+    context_settings={
+        "ignore_unknown_options": True,
+        "allow_extra_args": True,
+    },
+)
+def call_dvc(
+    ctx: typer.Context,
+    help: Annotated[bool, typer.Option("-h", "--help")] = False,
+):
+    """Run a command with the DVC CLI.
+
+    Useful if Calkit is installed as a tool, e.g., with `uv tool` or `pipx`,
+    and DVC is not installed.
+    """
+    process = subprocess.run([sys.executable, "-m", "dvc"] + sys.argv[2:])
+    sys.exit(process.returncode)
diff --git a/calkit/cli/new.py b/calkit/cli/new.py
index fa1656ad..527573df 100644
--- a/calkit/cli/new.py
+++ b/calkit/cli/new.py
@@ -7,6 +7,7 @@
 import pathlib
 import shutil
 import subprocess
+import sys
 import zipfile
 from enum import Enum
 
@@ -127,7 +128,7 @@ def new_project(
             typer.echo("Initializing DVC repository")
             try:
                 subprocess.run(
-                    ["dvc", "init", "-q"],
+                    [sys.executable, "-m", "dvc", "init", "-q"],
                     cwd=abs_path,
                     capture_output=True,
                     check=True,
@@ -287,7 +288,16 @@ def new_project(
                 "and `calkit config remote`"
             )
             subprocess.call(
-                ["dvc", "remote", "remove", "calkit", "-q"], cwd=abs_path
+                [
+                    sys.executable,
+                    "-m",
+                    "dvc",
+                    "remote",
+                    "remove",
+                    "calkit",
+                    "-q",
+                ],
+                cwd=abs_path,
             )
         try:
             calkit.dvc.set_remote_auth(wdir=abs_path)
@@ -308,7 +318,9 @@ def new_project(
     repo = git.Repo(abs_path)
     if not os.path.isfile(os.path.join(abs_path, ".dvc", "config")):
         typer.echo("Initializing DVC repository")
-        subprocess.run(["dvc", "init", "-q"], cwd=abs_path)
+        subprocess.run(
+            [sys.executable, "-m", "dvc", "init", "-q"], cwd=abs_path
+        )
     # Create calkit.yaml file
     ck_info = calkit.load_calkit_info(wdir=abs_path)
     ck_info = dict(name=name, title=title, description=description) | ck_info
@@ -439,7 +451,7 @@ def new_figure(
         for out in outs:
             outs_cmd += ["-o", out]
         subprocess.check_call(
-            ["dvc", "stage", "add", "-n", stage_name]
+            [sys.executable, "-m", "dvc", "stage", "add", "-n", stage_name]
             + (["-f"] if overwrite else [])
             + deps_cmd
             + outs_cmd
@@ -779,7 +791,7 @@ def new_dataset(
         for out in outs:
             outs_cmd += ["-o", out]
         subprocess.check_call(
-            ["dvc", "stage", "add", "-n", stage_name]
+            [sys.executable, "-m", "dvc", "stage", "add", "-n", stage_name]
             + (["-f"] if overwrite else [])
             + deps_cmd
             + outs_cmd
@@ -1486,7 +1498,7 @@ def new_stage(
         cmd += f"zsh {target}"
     elif kind.value == "r-script":
         cmd += f"Rscript {target}"
-    add_cmd = ["dvc", "stage", "add", "-n", name]
+    add_cmd = [sys.executable, "-m", "dvc", "stage", "add", "-n", name]
     for dep in [target] + deps:
         add_cmd += ["-d", dep]
     for out in outs:
diff --git a/calkit/dvc.py b/calkit/dvc.py
index c93bcc98..ea07c1e6 100644
--- a/calkit/dvc.py
+++ b/calkit/dvc.py
@@ -5,6 +5,7 @@
 import logging
 import os
 import subprocess
+import sys
 
 import dvc.repo
 import git
@@ -40,11 +41,30 @@ def configure_remote(wdir: str = None):
     base_url = calkit.cloud.get_base_url()
     remote_url = f"{base_url}/projects/{project_name}/dvc"
     subprocess.check_call(
-        ["dvc", "remote", "add", "-d", "-f", get_app_name(), remote_url],
+        [
+            sys.executable,
+            "-m",
+            "dvc",
+            "remote",
+            "add",
+            "-d",
+            "-f",
+            get_app_name(),
+            remote_url,
+        ],
         cwd=wdir,
     )
     subprocess.check_call(
-        ["dvc", "remote", "modify", get_app_name(), "auth", "custom"],
+        [
+            sys.executable,
+            "-m",
+            "dvc",
+            "remote",
+            "modify",
+            get_app_name(),
+            "auth",
+            "custom",
+        ],
         cwd=wdir,
     )
 
@@ -95,8 +115,30 @@ def add_external_remote(owner_name: str, project_name: str) -> dict:
     base_url = calkit.cloud.get_base_url()
     remote_url = f"{base_url}/projects/{owner_name}/{project_name}/dvc"
     remote_name = f"{get_app_name()}:{owner_name}/{project_name}"
-    subprocess.call(["dvc", "remote", "add", "-f", remote_name, remote_url])
-    subprocess.call(["dvc", "remote", "modify", remote_name, "auth", "custom"])
+    subprocess.call(
+        [
+            sys.executable,
+            "-m",
+            "dvc",
+            "remote",
+            "add",
+            "-f",
+            remote_name,
+            remote_url,
+        ]
+    )
+    subprocess.call(
+        [
+            sys.executable,
+            "-m",
+            "dvc",
+            "remote",
+            "modify",
+            remote_name,
+            "auth",
+            "custom",
+        ]
+    )
     set_remote_auth(remote_name)
     return {"name": remote_name, "url": remote_url}
 
@@ -114,7 +156,9 @@ def get_remotes(wdir: str = None) -> dict[str, str]:
     value.
     """
     out = (
-        subprocess.check_output(["dvc", "remote", "list"], cwd=wdir)
+        subprocess.check_output(
+            [sys.executable, "-m", "dvc", "remote", "list"], cwd=wdir
+        )
         .decode()
         .strip()
     )
diff --git a/calkit/magics.py b/calkit/magics.py
index 0e9f7d3c..b15abcec 100644
--- a/calkit/magics.py
+++ b/calkit/magics.py
@@ -1,5 +1,7 @@
 """IPython magics."""
 
+from __future__ import annotations
+
 import ast
 import os
 import pathlib
diff --git a/calkit/ops.py b/calkit/ops.py
index 014c3a95..fe315e70 100644
--- a/calkit/ops.py
+++ b/calkit/ops.py
@@ -4,7 +4,10 @@
 collection, a task run on a schedule, or a fixed number of iterations.
 """
 
+from __future__ import annotations
+
 from typing import Literal
+
 from pydantic import BaseModel
 
 
diff --git a/calkit/server.py b/calkit/server.py
index 33be72f8..3ae30fc8 100644
--- a/calkit/server.py
+++ b/calkit/server.py
@@ -7,6 +7,7 @@
 import platform
 import re
 import subprocess
+import sys
 from typing import Literal
 
 import dvc
@@ -218,7 +219,9 @@ def dvc_pull(owner_name: str, project_name: str) -> Message:
     logger.info(f"Looking for project {owner_name}/{project_name}")
     project = get_local_project(owner_name, project_name)
     logger.info(f"Found project at {project.wdir}")
-    subprocess.check_call(["dvc", "pull"], cwd=project.wdir)
+    subprocess.check_call(
+        [sys.executable, "-m", "dvc", "pull"], cwd=project.wdir
+    )
     return Message(message="Success!")
 
 
@@ -227,7 +230,9 @@ def dvc_push(owner_name: str, project_name: str) -> Message:
     logger.info(f"Looking for project {owner_name}/{project_name}")
     project = get_local_project(owner_name, project_name)
     logger.info(f"Found project at {project.wdir}")
-    subprocess.check_call(["dvc", "push"], cwd=project.wdir)
+    subprocess.check_call(
+        [sys.executable, "-m", "dvc", "push"], cwd=project.wdir
+    )
     return Message(message="Success!")
 
 
@@ -453,7 +458,8 @@ def discard_changes(owner_name: str, project_name: str) -> Message:
         for path in changed:
             logger.info(f"Checking out {path} with DVC")
             subprocess.check_call(
-                ["dvc", "checkout", path, "--force"], cwd=project.wdir
+                [sys.executable, "-m", "dvc", "checkout", path, "--force"],
+                cwd=project.wdir,
             )
     except dvc.config.ConfigError:
         pass
@@ -496,7 +502,7 @@ def get_pipeline(owner_name: str, project_name: str) -> Pipeline:
             raw_yaml = f.read()
         pipeline = calkit.ryaml.load(raw_yaml)
         mermaid = subprocess.check_output(
-            ["dvc", "dag", "--mermaid"], cwd=project.wdir
+            [sys.executable, "-m", "dvc", "dag", "--mermaid"], cwd=project.wdir
         ).decode()
         return Pipeline(
             raw_yaml=raw_yaml,
diff --git a/calkit/templates/core.py b/calkit/templates/core.py
index c47c856d..d19a56af 100644
--- a/calkit/templates/core.py
+++ b/calkit/templates/core.py
@@ -1,5 +1,7 @@
 """Core functionality for working with templates."""
 
+from __future__ import annotations
+
 import os
 import shutil
 from typing import Literal
diff --git a/docs/installation.md b/docs/installation.md
index 3509ae12..98eea757 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -5,9 +5,15 @@ If you want to use [Docker](https://docker.com) containers,
 which is typically a good idea,
 that should also be installed.
 For Python, we recommend
-[Miniforge](https://conda-forge.org/miniforge/).
+[uv](https://docs.astral.sh/uv/).
 
-After Python is installed, execute:
+With uv installed, install Calkit with:
+
+```sh
+uv tool install calkit-python
+```
+
+Alternatively, but less ideally, you can install with your system Python:
 
 ```sh
 pip install calkit-python
diff --git a/pyproject.toml b/pyproject.toml
index bc9748b0..d6fee9eb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -93,3 +93,4 @@ show_error_codes = true
 target-version = "py39"
 line-length = 79
 fix = true
+extend-select = ["I"]


Test patch:
diff --git a/calkit/tests/cli/test_main.py b/calkit/tests/cli/test_main.py
index d2971c55..4b3bfb3e 100644
--- a/calkit/tests/cli/test_main.py
+++ b/calkit/tests/cli/test_main.py
@@ -14,8 +14,7 @@
 
 
 def test_run_in_env(tmp_dir):
-    subprocess.check_call("git init", shell=True)
-    subprocess.check_call("dvc init", shell=True)
+    subprocess.check_call("calkit init", shell=True)
     # First create a new Docker environment for this bare project
     subprocess.check_call(
         "calkit new docker-env "
@@ -110,8 +109,7 @@ def test_run_in_env(tmp_dir):
 
 
 def test_run_in_venv(tmp_dir):
-    subprocess.check_call("git init", shell=True)
-    subprocess.check_call("dvc init", shell=True)
+    subprocess.check_call("calkit init", shell=True)
     # Test uv venv
     subprocess.check_call(
         [
@@ -353,3 +351,8 @@ def test_save(tmp_dir):
     )
     last_commit_message = repo.head.commit.message.strip()
     assert last_commit_message == "A unique message"
+
+
+def test_call_dvc():
+    subprocess.check_call(["calkit", "dvc", "--help"])
+    subprocess.check_call(["calkit", "dvc", "stage", "--help"])
diff --git a/calkit/tests/cli/test_new.py b/calkit/tests/cli/test_new.py
index cfb348b3..f1c29809 100644
--- a/calkit/tests/cli/test_new.py
+++ b/calkit/tests/cli/test_new.py
@@ -10,8 +10,7 @@
 
 
 def test_new_foreach_stage(tmp_dir):
-    subprocess.check_call(["git", "init"])
-    subprocess.check_call(["dvc", "init"])
+    subprocess.check_call(["calkit", "init"])
     subprocess.check_call(
         [
             "calkit",
@@ -54,8 +53,7 @@ def test_new_foreach_stage(tmp_dir):
 
 
 def test_new_figure(tmp_dir):
-    subprocess.check_call(["git", "init"])
-    subprocess.check_call(["dvc", "init"])
+    subprocess.check_call(["calkit", "init"])
     subprocess.check_call(
         [
             "calkit",
@@ -134,8 +132,7 @@ def test_new_figure(tmp_dir):
 
 
 def test_new_publication(tmp_dir):
-    subprocess.check_call(["git", "init"])
-    subprocess.check_call(["dvc", "init"])
+    subprocess.check_call(["calkit", "init"])
     subprocess.check_call(
         [
             "calkit",
@@ -173,8 +170,7 @@ def test_new_publication(tmp_dir):
 
 
 def test_new_uv_venv(tmp_dir):
-    subprocess.check_call(["git", "init"])
-    subprocess.check_call(["dvc", "init"])
+    subprocess.check_call(["calkit", "init"])
     subprocess.check_call(
         [
             "calkit",
diff --git a/calkit/tests/test_check.py b/calkit/tests/test_check.py
index bc86010d..80631740 100644
--- a/calkit/tests/test_check.py
+++ b/calkit/tests/test_check.py
@@ -2,6 +2,7 @@
 
 import os
 import subprocess
+import sys
 
 from calkit.check import check_reproducibility
 
@@ -16,7 +17,7 @@ def test_check_reproducibility(tmp_path):
     assert not res.is_dvc_repo
     assert not res.has_readme
     assert "no README.md" in res.recommendation
-    subprocess.run(["dvc", "init"])
+    subprocess.run([sys.executable, "-m", "dvc", "init"])
     res = check_reproducibility()
     assert res.is_dvc_repo
     assert res.n_dvc_remotes == 0
diff --git a/calkit/tests/test_conda.py b/calkit/tests/test_conda.py
index 61836471..6b6fa70f 100644
--- a/calkit/tests/test_conda.py
+++ b/calkit/tests/test_conda.py
@@ -42,8 +42,7 @@ def conda_env_name():
 
 
 def test_check_env(tmp_dir, conda_env_name):
-    subprocess.check_call(["git", "init"])
-    subprocess.check_call(["dvc", "init"])
+    subprocess.check_call(["calkit", "init"])
     subprocess.check_call(
         [
             "calkit",
diff --git a/calkit/tests/test_dvc.py b/calkit/tests/test_dvc.py
index e6a67e22..3db42564 100644
--- a/calkit/tests/test_dvc.py
+++ b/calkit/tests/test_dvc.py
@@ -10,6 +10,14 @@ def test_get_remotes(tmp_dir):
     assert not calkit.dvc.get_remotes()
     subprocess.call(["dvc", "init"])
     assert not calkit.dvc.get_remotes()
-    subprocess.call(["dvc", "remote", "add", "something", "https://sup.com"])
+    subprocess.call(
+        [
+            "dvc",
+            "remote",
+            "add",
+            "something",
+            "https://sup.com",
+        ]
+    )
     resp = calkit.dvc.get_remotes()
     assert resp == {"something": "https://sup.com"}