Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 15 additions & 20 deletions src/create_dlthub_workspace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,26 +81,21 @@ def build_parser() -> argparse.ArgumentParser:
choices=AGENTS,
help=f"Coding agent to set up ({', '.join(AGENTS)}). If omitted, you'll be prompted to choose (default: {RECOMMENDED.agent}).",
)
# Hidden, non-interactive shortcut for tests/CI only. Absent from --help so the
# interactive flow is the sole documented path. See MSG_TESTING_SHORTCUT_NOTE.
parser.add_argument(
"--yes",
"-y",
action="store_true",
help=argparse.SUPPRESS,
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help="Stream output from underlying subprocesses (uv, dlthub).",
)
# Hidden, like --yes: a non-interactive shortcut for tests/CI that stops
# before dependency sync (and, with it, the guided first run), leaving an
# incomplete workspace. Kept functional but absent from --help so the
# interactive flow is the sole documented path. See MSG_TESTING_SHORTCUT_NOTE.
# Dev/CI only; hidden from --help. Both skip the first run.
# --setup-only installs deps; --scaffold-only skips deps too.
parser.add_argument(
"--setup-only",
action="store_true",
help=argparse.SUPPRESS,
)
parser.add_argument(
"--skip-uv-sync",
"--scaffold-only",
action="store_true",
help=argparse.SUPPRESS,
)
Expand Down Expand Up @@ -139,7 +134,7 @@ def run(args: argparse.Namespace) -> None:
print_banner()
console.print()

if args.yes or args.skip_uv_sync:
if args.setup_only or args.scaffold_only:
err_console.print(strings.MSG_TESTING_SHORTCUT_NOTE)

verbose = args.verbose
Expand All @@ -165,15 +160,15 @@ def run(args: argparse.Namespace) -> None:

uv_executable = find_uv()
if uv_executable is None:
if args.yes or confirm(strings.PROMPT_INSTALL_UV, recommended=RECOMMENDED.install_uv):
if args.setup_only or confirm(strings.PROMPT_INSTALL_UV, recommended=RECOMMENDED.install_uv):
uv_executable = execute_uv_install(verbose=verbose)
else:
_finalize_agent(project_dir, scaffold, args, verbose=verbose)
console.print(strings.MSG_SKIPPED_UV_AND_SYNC)
print_resume_steps(project_dir, uv_installed=False)
return

if args.skip_uv_sync:
if args.scaffold_only:
_finalize_agent(project_dir, scaffold, args, verbose=verbose)
console.print(strings.MSG_SKIPPED_SYNC)
print_resume_steps(project_dir, uv_installed=True)
Expand All @@ -182,11 +177,11 @@ def run(args: argparse.Namespace) -> None:
with substep(strings.MSG_INSTALLING_DEPS, strings.MSG_INSTALLED_DEPS, verbose=verbose):
run_uv_sync(uv_executable, project_dir, verbose=verbose)

# Skipped under --yes: the first run's login is interactive. A run that exits
# Skipped under --setup-only: the first run's login is interactive. A run that exits
# non-zero degrades to a warning so the rest of setup still completes.
# LIMITATION: `dlthub run --follow` can exit 0 on a failed remote run, so a
# genuine run failure isn't caught here — we'd still report success. Revisit.
first_pipeline_ran = not args.yes
first_pipeline_ran = not args.setup_only
if first_pipeline_ran:
try:
_run_first_pipeline(uv_executable, project_dir, verbose=verbose)
Expand All @@ -206,8 +201,8 @@ def run(args: argparse.Namespace) -> None:


def _finalize_agent(project_dir: Path, scaffold: str, args: argparse.Namespace, *, verbose: bool) -> str:
"""Resolve the agent (prompting unless --agent/--yes set it) and lay down its AI files."""
agent = args.agent or (RECOMMENDED.agent if args.yes else choose_agent())
"""Resolve the agent (prompting unless --agent/--setup-only set it) and lay down its AI files."""
agent = args.agent or (RECOMMENDED.agent if args.setup_only else choose_agent())
with substep(
strings.MSG_ADDING_AGENT_FILES.format(agent=agent),
strings.MSG_ADDED_AGENT_FILES.format(agent=agent),
Expand Down
2 changes: 1 addition & 1 deletion src/create_dlthub_workspace/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@

@dataclass(frozen=True)
class RecommendedPath:
"""The path we recommend new users follow. Also the path `--yes` runs."""
"""The path we recommend new users follow. Also the path `--setup-only` runs."""

scaffold: str
install_uv: bool
Expand Down
2 changes: 1 addition & 1 deletion src/create_dlthub_workspace/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from .config import VERSION

console = Console()
# Separate stderr stream for out-of-band notes (e.g. the hidden --yes warning),
# Separate stderr stream for out-of-band notes (e.g. the hidden testing-shortcut warning),
# so they don't interleave with the primary stdout output.
err_console = Console(stderr=True)

Expand Down
8 changes: 2 additions & 6 deletions src/create_dlthub_workspace/strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,9 @@


# Status / info messages ------------------------------------------------
# Printed to stderr when a hidden testing shortcut (--yes or --skip-uv-sync) is
# used. Both are non-interactive testing/CI affordances only and cut the guided
# setup short — leaving the workspace scaffolded but not run (and, for
# --skip-uv-sync, dependencies not installed). The normal, complete path is the
# interactive one (no flag). Hidden from --help so it stays the sole documented path.
# Out-of-band stderr note shown when a hidden dev flag (--setup-only / --scaffold-only) is used.
MSG_TESTING_SHORTCUT_NOTE = (
"[yellow]Note:[/yellow] --yes / --skip-uv-sync are non-interactive shortcuts for testing/CI. "
"[yellow]Note:[/yellow] --setup-only / --scaffold-only are non-interactive shortcuts for testing/CI. "
"They cut the guided setup short, so onboarding is incomplete. "
"Run without them for the full interactive setup."
)
Expand Down
50 changes: 22 additions & 28 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,32 +35,26 @@ def test_rejects_unknown_agent(self) -> None:
with _silenced(), self.assertRaises(SystemExit):
parser.parse_args(["my_workspace", "--agent", "not-an-agent"])

def test_yes_flag_still_parses_to_true(self) -> None:
# --yes stays functional for tests/CI even though it's hidden from --help.
def test_setup_only_flag_parses_to_true(self) -> None:
# --setup-only stays functional for tests/CI even though it's hidden from --help.
parser = build_parser()
args = parser.parse_args(["my_workspace", "--yes"])
self.assertTrue(args.yes)
args = parser.parse_args(["my_workspace", "--setup-only"])
self.assertTrue(args.setup_only)

def test_short_yes_flag_parses_to_true(self) -> None:
parser = build_parser()
args = parser.parse_args(["my_workspace", "-y"])
self.assertTrue(args.yes)

def test_yes_defaults_to_false(self) -> None:
def test_setup_only_defaults_to_false(self) -> None:
parser = build_parser()
args = parser.parse_args(["my_workspace"])
self.assertFalse(args.yes)
self.assertFalse(args.setup_only)

def test_testing_shortcuts_are_hidden_from_help(self) -> None:
help_text = build_parser().format_help()
self.assertNotIn("--yes", help_text)
self.assertNotIn("-y", help_text)
self.assertNotIn("--skip-uv-sync", help_text)
self.assertNotIn("--setup-only", help_text)
self.assertNotIn("--scaffold-only", help_text)

def test_skip_uv_sync_flag_parses_to_true(self) -> None:
def test_scaffold_only_flag_parses_to_true(self) -> None:
parser = build_parser()
args = parser.parse_args(["my_workspace", "--skip-uv-sync"])
self.assertTrue(args.skip_uv_sync)
args = parser.parse_args(["my_workspace", "--scaffold-only"])
self.assertTrue(args.scaffold_only)

def test_agent_parses_single_value(self) -> None:
parser = build_parser()
Expand Down Expand Up @@ -114,9 +108,9 @@ def _make_args(**overrides: object) -> argparse.Namespace:
defaults: dict[str, object] = {
"project_dir": "/tmp/test_workspace",
"agent": None,
"yes": False,
"setup_only": False,
"verbose": False,
"skip_uv_sync": False,
"scaffold_only": False,
}
defaults.update(overrides)
return argparse.Namespace(**defaults)
Expand Down Expand Up @@ -211,8 +205,8 @@ def test_explicit_agent_skips_the_prompt(self) -> None:
self.m["choose_agent"].assert_not_called()
self.assertEqual(self.m["overlay_agent"].call_args.kwargs["agent"], "codex")

def test_yes_skips_first_run_and_prompt_but_still_adds_agent_files(self) -> None:
self._run(yes=True)
def test_setup_only_skips_first_run_and_prompt_but_still_adds_agent_files(self) -> None:
self._run(setup_only=True)
self.m["run_uv_command"].assert_not_called()
self.m["choose_agent"].assert_not_called()
self.assertEqual(self.m["overlay_agent"].call_args.kwargs["agent"], RECOMMENDED.agent)
Expand All @@ -230,8 +224,8 @@ def test_uv_declined_stops_at_scaffold_only_but_adds_agent_files(self) -> None:
self.m["print_next_steps"].assert_not_called()
self.m["print_resume_steps"].assert_called_once_with(Path("/tmp/test_workspace"), uv_installed=False)

def test_skip_uv_sync_stops_after_install_but_adds_agent_files(self) -> None:
self._run(skip_uv_sync=True)
def test_scaffold_only_stops_after_install_but_adds_agent_files(self) -> None:
self._run(scaffold_only=True)
self.m["run_uv_sync"].assert_not_called()
self.m["overlay_agent"].assert_called_once()
self.m["print_next_steps"].assert_not_called()
Expand Down Expand Up @@ -311,14 +305,14 @@ def _run_with(self, **flags: bool) -> MagicMock:
run(_make_args(**flags))
return err_console

def test_yes_prints_testing_notice(self) -> None:
self._run_with(yes=True).print.assert_called_once()
def test_setup_only_prints_testing_notice(self) -> None:
self._run_with(setup_only=True).print.assert_called_once()

def test_skip_uv_sync_prints_testing_notice(self) -> None:
self._run_with(skip_uv_sync=True).print.assert_called_once()
def test_scaffold_only_prints_testing_notice(self) -> None:
self._run_with(scaffold_only=True).print.assert_called_once()

def test_both_shortcuts_print_a_single_notice(self) -> None:
self._run_with(yes=True, skip_uv_sync=True).print.assert_called_once()
self._run_with(setup_only=True, scaffold_only=True).print.assert_called_once()

def test_interactive_run_prints_no_notice(self) -> None:
self._run_with().print.assert_not_called()
Expand Down
34 changes: 17 additions & 17 deletions tests_integration/test_e2e_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,25 +22,25 @@


class WorkspaceCreationFastTests(unittest.TestCase):
"""E2E paths that use --skip-uv-sync: no real `uv sync`, runs in ~1s.
"""E2E paths that use --scaffold-only: no real `uv sync`, runs in ~1s.

Validates the orchestration layer (argparse → run → copy_scaffold) end-to-end
without paying the sync cost.
"""

def test_yes_skip_sync_creates_workspace_without_venv(self) -> None:
def test_scaffold_only_creates_workspace_without_venv(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
ws = Path(tmpdir) / "test_ws"
with silenced():
exit_code = main([str(ws), "--yes", "--skip-uv-sync"])
exit_code = main([str(ws), "--setup-only", "--scaffold-only"])

self.assertEqual(exit_code, 0)
self.assertTrue(ws.is_dir())
self.assertTrue((ws / "pyproject.toml").exists())
self.assertTrue((ws / "pipeline.py").exists())
self.assertFalse(
(ws / ".venv").exists(),
"--skip-uv-sync should prevent .venv creation",
"--scaffold-only should prevent .venv creation",
)

@unittest.skipUnless(
Expand All @@ -52,7 +52,7 @@ def test_single_agent_selection_filters_other_agents(self) -> None:
ws = Path(tmpdir) / "test_ws"
with silenced():
exit_code = main(
[str(ws), "--yes", "--skip-uv-sync", "--agent", "claude"],
[str(ws), "--setup-only", "--scaffold-only", "--agent", "claude"],
)

self.assertEqual(exit_code, 0)
Expand All @@ -76,14 +76,14 @@ def test_single_agent_selection_filters_other_agents(self) -> None:
scaffold_has_ai_files(),
"AI workbench files not committed yet — run `make generate-ai` first.",
)
def test_yes_default_brings_in_only_the_recommended_agent(self) -> None:
def test_setup_only_brings_in_only_the_recommended_agent(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
ws = Path(tmpdir) / "test_ws"
with silenced():
exit_code = main([str(ws), "--yes", "--skip-uv-sync"])
exit_code = main([str(ws), "--setup-only", "--scaffold-only"])

self.assertEqual(exit_code, 0)
# --yes uses the recommended agent (claude) and only that agent.
# --setup-only uses the recommended agent (claude) and only that agent.
for entry in EXPECTED_AGENT_ROOT_ENTRIES["claude"]:
self.assertTrue((ws / entry).exists(), f"recommended agent's {entry!r} should be present")
for entry in (*EXPECTED_AGENT_ROOT_ENTRIES["cursor"], *EXPECTED_AGENT_ROOT_ENTRIES["codex"]):
Expand All @@ -93,11 +93,11 @@ def test_yes_default_brings_in_only_the_recommended_agent(self) -> None:
class WorkspaceCreationSlowTests(unittest.TestCase):
"""E2E paths that run a real `uv sync` (~30-60s, network needed)."""

def test_yes_default_runs_uv_sync_to_completion(self) -> None:
def test_setup_only_runs_uv_sync_to_completion(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
ws = Path(tmpdir) / "test_ws"
with silenced():
exit_code = main([str(ws), "--yes"])
exit_code = main([str(ws), "--setup-only"])

self.assertEqual(exit_code, 0)
self.assertTrue((ws / ".venv").is_dir(), "uv sync should have created .venv")
Expand All @@ -112,12 +112,12 @@ def test_second_run_at_same_path_relocates_to_sibling(self) -> None:
ws = Path(tmpdir) / "collision_test"

with silenced():
first_exit = main([str(ws), "--yes", "--skip-uv-sync"])
first_exit = main([str(ws), "--setup-only", "--scaffold-only"])
self.assertEqual(first_exit, 0, "First run should succeed")
self.assertTrue(ws.is_dir())

with silenced():
second_exit = main([str(ws), "--yes", "--skip-uv-sync"])
second_exit = main([str(ws), "--setup-only", "--scaffold-only"])
self.assertEqual(second_exit, 0, "Second run should succeed by relocating, not fail")

sibling = ws.with_name("collision_test-1")
Expand All @@ -129,7 +129,7 @@ def test_second_run_at_same_path_relocates_to_sibling(self) -> None:

class InstalledEntryPointTests(unittest.TestCase):
"""Spawns the actual CLI binary via subprocess to validate the installed
entry point (`dlthub-start` on PATH). Uses --skip-uv-sync to stay fast —
entry point (`dlthub-start` on PATH). Uses --scaffold-only to stay fast —
the sync itself is covered by WorkspaceCreationSlowTests.
"""

Expand All @@ -142,8 +142,8 @@ def test_subprocess_invocation_succeeds(self) -> None:
"-m",
"create_dlthub_workspace",
str(ws),
"--yes",
"--skip-uv-sync",
"--setup-only",
"--scaffold-only",
],
capture_output=True,
check=False,
Expand All @@ -170,8 +170,8 @@ def test_subprocess_relocates_occupied_target(self) -> None:
"-m",
"create_dlthub_workspace",
str(ws),
"--yes",
"--skip-uv-sync",
"--setup-only",
"--scaffold-only",
],
capture_output=True,
check=False,
Expand Down