Skip to content

Commit c630818

Browse files
authored
fix: project bootstrapping in existing folder (#318)
- closes #301 Typer bit me quite hard here, but we should be out of the dark now.
1 parent a030174 commit c630818

3 files changed

Lines changed: 218 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
- Fix Pylance `reportPrivateImportUsage` errors by defining `__all__` in modules `__init__.py`.
1313
- Set `HTTPX` logging level to `WARNING` by default.
14+
- Fix CLI behavior with existing project folders
1415

1516
## [0.1.0](https://github.com/apify/crawlee-python/releases/tag/v0.1.0) (2024-07-09)
1617

src/crawlee/cli.py

Lines changed: 60 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
from __future__ import annotations
33

44
from pathlib import Path
5-
from typing import Annotated, Union
5+
from typing import Annotated, Optional, cast
66

77
import httpx
88
import inquirer # type: ignore
99
import typer
1010
from cookiecutter.main import cookiecutter # type: ignore
11+
from inquirer.render.console import ConsoleRender # type: ignore
1112
from rich.progress import Progress, SpinnerColumn, TextColumn
1213

1314
TEMPLATE_LIST_URL = 'https://api.github.com/repos/apify/crawlee-python/contents/templates'
@@ -34,73 +35,76 @@ def callback(
3435
typer.echo(__version__)
3536

3637

37-
@cli.command()
38-
def create(
39-
project_name: Annotated[
40-
Union[str | None],
41-
typer.Argument(
42-
help='The name of the project and the directory that will be created to contain it. '
43-
'If none is given, you will be prompted.'
44-
),
45-
] = None,
46-
template: Annotated[
47-
Union[str | None],
48-
typer.Option(help='The template to be used to create the project. If none is given, you will be prompted.'),
49-
] = None,
50-
) -> None:
51-
"""Bootstrap a new Crawlee project."""
52-
try:
53-
# Prompt for project name if not provided.
54-
if project_name is None:
55-
answers = (
56-
inquirer.prompt(
57-
[
58-
inquirer.Text(
59-
name='project_name',
60-
message='Name of the new project folder',
61-
validate=lambda _, value: bool(value.strip()),
62-
),
63-
]
64-
)
65-
or {}
38+
def _prompt_for_project_name(initial_project_name: str | None) -> str:
39+
"""Prompt the user for a non-empty project name that does not lead to an existing folder."""
40+
while True:
41+
if initial_project_name is not None:
42+
project_name = initial_project_name
43+
initial_project_name = None
44+
else:
45+
project_name = ConsoleRender().render(
46+
inquirer.Text(
47+
name='project_name',
48+
message='Name of the new project folder',
49+
validate=lambda _, value: bool(value.strip()),
50+
),
6651
)
6752

68-
project_name = answers.get('project_name')
69-
70-
if not project_name:
71-
typer.echo('Project name is required.', err=True)
72-
raise typer.Exit(1)
53+
if not project_name:
54+
typer.echo('Project name is required.', err=True)
55+
continue
7356

7457
project_path = Path.cwd() / project_name
7558

7659
if project_path.exists():
7760
typer.echo(f'Folder {project_path} already exists. Please choose another name.', err=True)
78-
raise typer.Exit(1)
61+
continue
62+
63+
return project_name
64+
65+
66+
def _prompt_for_template() -> str:
67+
"""Prompt the user to select a template from a list."""
68+
# Fetch available templates
69+
response = httpx.get(TEMPLATE_LIST_URL, timeout=httpx.Timeout(10))
70+
response.raise_for_status()
71+
template_choices = [item['name'] for item in response.json() if item['type'] == 'dir']
72+
73+
# Prompt for template choice
74+
return cast(
75+
str,
76+
ConsoleRender().render(
77+
inquirer.List(
78+
name='template',
79+
message='Please select the template for your new Crawlee project',
80+
choices=[(choice[0].upper() + choice[1:], choice) for choice in template_choices],
81+
),
82+
),
83+
)
7984

80-
template_choices: list[str] = []
8185

82-
# Fetch available templates if a template is not provided.
83-
if template is None:
84-
response = httpx.get(TEMPLATE_LIST_URL, timeout=httpx.Timeout(10))
85-
response.raise_for_status()
86-
template_choices = [item['name'] for item in response.json() if item['type'] == 'dir']
86+
@cli.command()
87+
def create(
88+
project_name: Optional[str] = typer.Argument(
89+
default=None,
90+
help='The name of the project and the directory that will be created to contain it. '
91+
'If none is given, you will be prompted.',
92+
show_default=False,
93+
),
94+
template: Optional[str] = typer.Option(
95+
default=None,
96+
help='The template to be used to create the project. If none is given, you will be prompted.',
97+
show_default=False,
98+
),
99+
) -> None:
100+
"""Bootstrap a new Crawlee project."""
101+
try:
102+
# Prompt for project name if not provided.
103+
project_name = _prompt_for_project_name(project_name)
87104

88105
# Prompt for template choice if not provided.
89106
if template is None:
90-
answers = (
91-
inquirer.prompt(
92-
[
93-
inquirer.List(
94-
name='template',
95-
message='Please select the template for your new Crawlee project',
96-
choices=[(choice[0].upper() + choice[1:], choice) for choice in template_choices],
97-
ignore=template is not None,
98-
),
99-
]
100-
)
101-
or {}
102-
)
103-
template = answers.get('template')
107+
template = _prompt_for_template()
104108

105109
if project_name and template:
106110
# Start the bootstrap process.

tests/unit/cli/test_create.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import os
2+
from unittest.mock import Mock
3+
4+
import pytest
5+
import readchar
6+
from typer.testing import CliRunner
7+
8+
import crawlee.cli
9+
10+
runner = CliRunner()
11+
12+
13+
@pytest.fixture()
14+
def mock_cookiecutter(monkeypatch: pytest.MonkeyPatch) -> Mock:
15+
mock_cookiecutter = Mock()
16+
monkeypatch.setattr(target=crawlee.cli, name='cookiecutter', value=mock_cookiecutter)
17+
18+
return mock_cookiecutter
19+
20+
21+
def test_create_interactive(mock_cookiecutter: Mock, monkeypatch: pytest.MonkeyPatch) -> None:
22+
mock_input = iter(
23+
[
24+
*'my_project',
25+
readchar.key.ENTER,
26+
readchar.key.ENTER,
27+
]
28+
)
29+
monkeypatch.setattr(target=readchar, name='readkey', value=lambda: next(mock_input))
30+
31+
result = runner.invoke(crawlee.cli.cli, ['create'])
32+
assert 'Your project "my_project" was created.' in result.output
33+
34+
mock_cookiecutter.assert_called_with(
35+
template='gh:apify/crawlee-python',
36+
directory='templates/beautifulsoup',
37+
no_input=True,
38+
extra_context={'project_name': 'my_project'},
39+
)
40+
41+
42+
def test_create_interactive_non_default_template(mock_cookiecutter: Mock, monkeypatch: pytest.MonkeyPatch) -> None:
43+
mock_input = iter(
44+
[
45+
*'my_project',
46+
readchar.key.ENTER,
47+
readchar.key.DOWN,
48+
readchar.key.ENTER,
49+
]
50+
)
51+
monkeypatch.setattr(target=readchar, name='readkey', value=lambda: next(mock_input))
52+
53+
result = runner.invoke(crawlee.cli.cli, ['create'])
54+
assert 'Your project "my_project" was created.' in result.output
55+
56+
mock_cookiecutter.assert_called_with(
57+
template='gh:apify/crawlee-python',
58+
directory='templates/playwright',
59+
no_input=True,
60+
extra_context={'project_name': 'my_project'},
61+
)
62+
63+
64+
def test_create_non_interactive(mock_cookiecutter: Mock) -> None:
65+
runner.invoke(crawlee.cli.cli, ['create', 'my_project', '--template', 'playwright'])
66+
67+
mock_cookiecutter.assert_called_with(
68+
template='gh:apify/crawlee-python',
69+
directory='templates/playwright',
70+
no_input=True,
71+
extra_context={'project_name': 'my_project'},
72+
)
73+
74+
75+
def test_create_existing_folder(
76+
mock_cookiecutter: Mock, monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
77+
) -> None:
78+
mock_input = iter(
79+
[
80+
*'my_project',
81+
readchar.key.ENTER,
82+
]
83+
)
84+
monkeypatch.setattr(target=readchar, name='readkey', value=lambda: next(mock_input))
85+
86+
tmp = tmp_path_factory.mktemp('workdir')
87+
os.chdir(tmp)
88+
(tmp / 'existing_project').mkdir()
89+
90+
result = runner.invoke(crawlee.cli.cli, ['create', 'existing_project', '--template', 'playwright'])
91+
assert 'existing_project already exists' in result.output
92+
93+
mock_cookiecutter.assert_called_with(
94+
template='gh:apify/crawlee-python',
95+
directory='templates/playwright',
96+
no_input=True,
97+
extra_context={'project_name': 'my_project'},
98+
)
99+
100+
101+
def test_create_existing_folder_interactive(
102+
mock_cookiecutter: Mock, monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
103+
) -> None:
104+
mock_input = iter(
105+
[
106+
*'existing_project',
107+
readchar.key.ENTER,
108+
*'my_project',
109+
readchar.key.ENTER,
110+
]
111+
)
112+
monkeypatch.setattr(target=readchar, name='readkey', value=lambda: next(mock_input))
113+
114+
tmp = tmp_path_factory.mktemp('workdir')
115+
os.chdir(tmp)
116+
(tmp / 'existing_project').mkdir()
117+
118+
result = runner.invoke(crawlee.cli.cli, ['create', '--template', 'playwright'])
119+
assert 'existing_project already exists' in result.output
120+
121+
mock_cookiecutter.assert_called_with(
122+
template='gh:apify/crawlee-python',
123+
directory='templates/playwright',
124+
no_input=True,
125+
extra_context={'project_name': 'my_project'},
126+
)
127+
128+
129+
def test_create_existing_folder_interactive_multiple_attempts(
130+
mock_cookiecutter: Mock, monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
131+
) -> None:
132+
mock_input = iter(
133+
[
134+
*'existing_project',
135+
readchar.key.ENTER,
136+
*'existing_project_2',
137+
readchar.key.ENTER,
138+
*'my_project',
139+
readchar.key.ENTER,
140+
]
141+
)
142+
monkeypatch.setattr(target=readchar, name='readkey', value=lambda: next(mock_input))
143+
144+
tmp = tmp_path_factory.mktemp('workdir')
145+
os.chdir(tmp)
146+
(tmp / 'existing_project').mkdir()
147+
(tmp / 'existing_project_2').mkdir()
148+
149+
result = runner.invoke(crawlee.cli.cli, ['create', '--template', 'playwright'])
150+
assert 'existing_project already exists' in result.output
151+
152+
mock_cookiecutter.assert_called_with(
153+
template='gh:apify/crawlee-python',
154+
directory='templates/playwright',
155+
no_input=True,
156+
extra_context={'project_name': 'my_project'},
157+
)

0 commit comments

Comments
 (0)