Skip to content

Commit

Permalink
fix: project bootstrapping in existing folder (#318)
Browse files Browse the repository at this point in the history
- closes #301

Typer bit me quite hard here, but we should be out of the dark now.
  • Loading branch information
janbuchar authored Jul 18, 2024
1 parent a030174 commit c630818
Show file tree
Hide file tree
Showing 3 changed files with 218 additions and 56 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

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

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

Expand Down
116 changes: 60 additions & 56 deletions src/crawlee/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
from __future__ import annotations

from pathlib import Path
from typing import Annotated, Union
from typing import Annotated, Optional, cast

import httpx
import inquirer # type: ignore
import typer
from cookiecutter.main import cookiecutter # type: ignore
from inquirer.render.console import ConsoleRender # type: ignore
from rich.progress import Progress, SpinnerColumn, TextColumn

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


@cli.command()
def create(
project_name: Annotated[
Union[str | None],
typer.Argument(
help='The name of the project and the directory that will be created to contain it. '
'If none is given, you will be prompted.'
),
] = None,
template: Annotated[
Union[str | None],
typer.Option(help='The template to be used to create the project. If none is given, you will be prompted.'),
] = None,
) -> None:
"""Bootstrap a new Crawlee project."""
try:
# Prompt for project name if not provided.
if project_name is None:
answers = (
inquirer.prompt(
[
inquirer.Text(
name='project_name',
message='Name of the new project folder',
validate=lambda _, value: bool(value.strip()),
),
]
)
or {}
def _prompt_for_project_name(initial_project_name: str | None) -> str:
"""Prompt the user for a non-empty project name that does not lead to an existing folder."""
while True:
if initial_project_name is not None:
project_name = initial_project_name
initial_project_name = None
else:
project_name = ConsoleRender().render(
inquirer.Text(
name='project_name',
message='Name of the new project folder',
validate=lambda _, value: bool(value.strip()),
),
)

project_name = answers.get('project_name')

if not project_name:
typer.echo('Project name is required.', err=True)
raise typer.Exit(1)
if not project_name:
typer.echo('Project name is required.', err=True)
continue

project_path = Path.cwd() / project_name

if project_path.exists():
typer.echo(f'Folder {project_path} already exists. Please choose another name.', err=True)
raise typer.Exit(1)
continue

return project_name


def _prompt_for_template() -> str:
"""Prompt the user to select a template from a list."""
# Fetch available templates
response = httpx.get(TEMPLATE_LIST_URL, timeout=httpx.Timeout(10))
response.raise_for_status()
template_choices = [item['name'] for item in response.json() if item['type'] == 'dir']

# Prompt for template choice
return cast(
str,
ConsoleRender().render(
inquirer.List(
name='template',
message='Please select the template for your new Crawlee project',
choices=[(choice[0].upper() + choice[1:], choice) for choice in template_choices],
),
),
)

template_choices: list[str] = []

# Fetch available templates if a template is not provided.
if template is None:
response = httpx.get(TEMPLATE_LIST_URL, timeout=httpx.Timeout(10))
response.raise_for_status()
template_choices = [item['name'] for item in response.json() if item['type'] == 'dir']
@cli.command()
def create(
project_name: Optional[str] = typer.Argument(
default=None,
help='The name of the project and the directory that will be created to contain it. '
'If none is given, you will be prompted.',
show_default=False,
),
template: Optional[str] = typer.Option(
default=None,
help='The template to be used to create the project. If none is given, you will be prompted.',
show_default=False,
),
) -> None:
"""Bootstrap a new Crawlee project."""
try:
# Prompt for project name if not provided.
project_name = _prompt_for_project_name(project_name)

# Prompt for template choice if not provided.
if template is None:
answers = (
inquirer.prompt(
[
inquirer.List(
name='template',
message='Please select the template for your new Crawlee project',
choices=[(choice[0].upper() + choice[1:], choice) for choice in template_choices],
ignore=template is not None,
),
]
)
or {}
)
template = answers.get('template')
template = _prompt_for_template()

if project_name and template:
# Start the bootstrap process.
Expand Down
157 changes: 157 additions & 0 deletions tests/unit/cli/test_create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import os
from unittest.mock import Mock

import pytest
import readchar
from typer.testing import CliRunner

import crawlee.cli

runner = CliRunner()


@pytest.fixture()
def mock_cookiecutter(monkeypatch: pytest.MonkeyPatch) -> Mock:
mock_cookiecutter = Mock()
monkeypatch.setattr(target=crawlee.cli, name='cookiecutter', value=mock_cookiecutter)

return mock_cookiecutter


def test_create_interactive(mock_cookiecutter: Mock, monkeypatch: pytest.MonkeyPatch) -> None:
mock_input = iter(
[
*'my_project',
readchar.key.ENTER,
readchar.key.ENTER,
]
)
monkeypatch.setattr(target=readchar, name='readkey', value=lambda: next(mock_input))

result = runner.invoke(crawlee.cli.cli, ['create'])
assert 'Your project "my_project" was created.' in result.output

mock_cookiecutter.assert_called_with(
template='gh:apify/crawlee-python',
directory='templates/beautifulsoup',
no_input=True,
extra_context={'project_name': 'my_project'},
)


def test_create_interactive_non_default_template(mock_cookiecutter: Mock, monkeypatch: pytest.MonkeyPatch) -> None:
mock_input = iter(
[
*'my_project',
readchar.key.ENTER,
readchar.key.DOWN,
readchar.key.ENTER,
]
)
monkeypatch.setattr(target=readchar, name='readkey', value=lambda: next(mock_input))

result = runner.invoke(crawlee.cli.cli, ['create'])
assert 'Your project "my_project" was created.' in result.output

mock_cookiecutter.assert_called_with(
template='gh:apify/crawlee-python',
directory='templates/playwright',
no_input=True,
extra_context={'project_name': 'my_project'},
)


def test_create_non_interactive(mock_cookiecutter: Mock) -> None:
runner.invoke(crawlee.cli.cli, ['create', 'my_project', '--template', 'playwright'])

mock_cookiecutter.assert_called_with(
template='gh:apify/crawlee-python',
directory='templates/playwright',
no_input=True,
extra_context={'project_name': 'my_project'},
)


def test_create_existing_folder(
mock_cookiecutter: Mock, monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
) -> None:
mock_input = iter(
[
*'my_project',
readchar.key.ENTER,
]
)
monkeypatch.setattr(target=readchar, name='readkey', value=lambda: next(mock_input))

tmp = tmp_path_factory.mktemp('workdir')
os.chdir(tmp)
(tmp / 'existing_project').mkdir()

result = runner.invoke(crawlee.cli.cli, ['create', 'existing_project', '--template', 'playwright'])
assert 'existing_project already exists' in result.output

mock_cookiecutter.assert_called_with(
template='gh:apify/crawlee-python',
directory='templates/playwright',
no_input=True,
extra_context={'project_name': 'my_project'},
)


def test_create_existing_folder_interactive(
mock_cookiecutter: Mock, monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
) -> None:
mock_input = iter(
[
*'existing_project',
readchar.key.ENTER,
*'my_project',
readchar.key.ENTER,
]
)
monkeypatch.setattr(target=readchar, name='readkey', value=lambda: next(mock_input))

tmp = tmp_path_factory.mktemp('workdir')
os.chdir(tmp)
(tmp / 'existing_project').mkdir()

result = runner.invoke(crawlee.cli.cli, ['create', '--template', 'playwright'])
assert 'existing_project already exists' in result.output

mock_cookiecutter.assert_called_with(
template='gh:apify/crawlee-python',
directory='templates/playwright',
no_input=True,
extra_context={'project_name': 'my_project'},
)


def test_create_existing_folder_interactive_multiple_attempts(
mock_cookiecutter: Mock, monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
) -> None:
mock_input = iter(
[
*'existing_project',
readchar.key.ENTER,
*'existing_project_2',
readchar.key.ENTER,
*'my_project',
readchar.key.ENTER,
]
)
monkeypatch.setattr(target=readchar, name='readkey', value=lambda: next(mock_input))

tmp = tmp_path_factory.mktemp('workdir')
os.chdir(tmp)
(tmp / 'existing_project').mkdir()
(tmp / 'existing_project_2').mkdir()

result = runner.invoke(crawlee.cli.cli, ['create', '--template', 'playwright'])
assert 'existing_project already exists' in result.output

mock_cookiecutter.assert_called_with(
template='gh:apify/crawlee-python',
directory='templates/playwright',
no_input=True,
extra_context={'project_name': 'my_project'},
)

0 comments on commit c630818

Please sign in to comment.