def test_dispatch_env_correctly_identifies_flit(fake_flit_project: Path): repo = Repo(name="test", owner="me", local_path=fake_flit_project) env = repo.dispatch_env(config=Config()) assert isinstance(env, Flit)
def test_dispatch_env_correctly_identifies_requirements_dev_txt( requirements_dev_project: Path, ): repo = Repo(name="test", owner="me", local_path=requirements_dev_project) env = repo.dispatch_env(config=Config()) assert isinstance(env, Requirements)
def test_dispatch_env_correctly_identifies_conda(mocker: MockerFixture): mocker.patch("pytoil.repo.Repo.is_conda", autospec=True, return_value=True) repo = Repo(name="test", owner="me", local_path=Path("somewhere")) env = repo.dispatch_env(config=Config()) assert isinstance(env, Conda)
def test_is_conda( repo_folder_with_random_existing_files: Path, file: str, expect: bool ): repo = Repo( owner="me", name="test", local_path=repo_folder_with_random_existing_files ) # Add in the required file to trigger repo_folder_with_random_existing_files.joinpath(file).touch() assert repo.is_conda() is expect
def test_does_file_exist( repo_folder_with_random_existing_files: Path, file: str, exists: bool, ): repo = Repo( owner="me", name="test", local_path=repo_folder_with_random_existing_files ) assert repo._file_exists(file) is exists
def test_dispatch_env_returns_none_if_it_cant_detect(mocker: MockerFixture): mocker.patch("pytoil.repo.Repo.is_conda", autospec=True, return_value=False) mocker.patch("pytoil.repo.Repo.is_setuptools", autospec=True, return_value=False) repo = Repo(name="test", owner="me", local_path=Path("somewhere")) env = repo.dispatch_env(config=Config()) assert env is None
def test_exists_remote_returns_true_when_remote_exists( httpx_mock: HTTPXMock, fake_repo_exists_true_response ): api = API(username="******", token="something") repo = Repo(owner="me", name="test", local_path=Path("doesn't/matter")) httpx_mock.add_response( url=api.url, json=fake_repo_exists_true_response, status_code=200 ) result = repo.exists_remote(api=api) assert result is True
def gh(config: Config, project: str, issues: bool, prs: bool) -> None: """ Open one of your projects on GitHub. Given a project name (must exist on GitHub and be owned by you), 'gh' will open your browser and navigate to the project on GitHub. You can also use the "--issues" or "--prs" flags to immediately open up the repo's issues or pull requests page. Examples: $ pytoil gh my_project $ pytoil gh my_project --issues $ pytoil gh my_project --prs """ api = API(username=config.username, token=config.token) repo = Repo( owner=config.username, name=project, local_path=config.projects_dir.joinpath(project), ) try: exists = repo.exists_remote(api) except httpx.HTTPStatusError as err: utils.handle_http_status_error(err) else: if not exists: printer.error( f"Could not find {project!r} on GitHub. Was it a typo?", exits=1) if issues: printer.info(f"Opening {project}'s issues on GitHub") click.launch(url=repo.issues_url) elif prs: printer.info(f"Opening {project}'s pull requests on GitHub") click.launch(url=repo.pulls_url) else: printer.info(f"Opening {project} on GitHub") click.launch(url=repo.html_url)
def test_remote_info_returns_correct_details( httpx_mock: HTTPXMock, fake_repo_info_response ): api = API(username="******", token="something") repo = Repo(owner="me", name="test", local_path=Path("somewhere")) httpx_mock.add_response(url=api.url, json=fake_repo_info_response, status_code=200) result = repo._remote_info(api) assert result == { "Created": "11 months ago", "Description": "CLI to automate the development workflow :robot:", "License": "Apache License 2.0", "Name": "pytoil", "Remote": True, "Size": "3.2 MB", "Updated": "25 days ago", "Language": "Python", }
def test_is_flit_false_if_no_build_backend(project_with_no_build_backend: Path): repo = Repo(owner="blah", name="test", local_path=project_with_no_build_backend) assert repo.is_flit() is False
def test_exists_local(path: Path, exists: bool): repo = Repo(owner="me", name="test", local_path=path) assert repo.exists_local() is exists
def test_repo_repr(): repo = Repo(owner="me", name="project", local_path=Path("somewhere")) assert ( repr(repo) == f"Repo(owner='me', name='project', local_path={Path('somewhere')!r})" )
def test_pulls_url(): repo = Repo(owner="me", name="project", local_path=Path("doesn't/matter")) assert repo.pulls_url == "https://github.com/me/project/pulls"
def test_is_setuptools_pep621(project_with_setuptools_pep621_backend: Path): repo = Repo( owner="me", name="test", local_path=project_with_setuptools_pep621_backend ) assert repo.is_setuptools() is True
def test_is_poetry_false_if_no_build_system(project_with_no_build_system: Path): repo = Repo(owner="blah", name="test", local_path=project_with_no_build_system) assert repo.is_poetry() is False
def test_is_flit_true_on_valid_flit_project(fake_flit_project: Path): repo = Repo(owner="blah", name="test", local_path=fake_flit_project) assert repo.is_flit() is True
def test_is_flit_false_if_no_pyproject_toml(): repo = Repo(owner="blah", name="test", local_path=Path("nowhere")) assert repo.is_flit() is False
def test_is_flit_false_on_non_flit_project(fake_poetry_project: Path): repo = Repo(owner="blah", name="test", local_path=fake_poetry_project) assert repo.is_flit() is False
def new( # noqa: C901 config: Config, project: str, packages: tuple[str, ...], cookie: str | None, _copier: str | None, starter: str | None, venv: str | None, no_git: bool = False, ) -> None: """ Create a new development project. Bare usage will simply create an empty folder in your configured projects directory. You can also create a project from a cookiecutter or copier template by passing a valid url to the '--cookie/-c' or '--copier/-C' flags. If you just want a very simple, language-specific starting template, use the '--starter/-s' option. By default, pytoil will initialise a local git repo in the folder and commit it, following the style of modern language build tools such as rust's cargo. You can disable this behaviour by setting 'git' to false in pytoil's config file or by passing the '--no-git/-n' flag here. If you want pytoil to create a new virtual environment for your project, you can use the '--venv/-v' flag. Standard python and conda virtual environments are supported. If the '--venv/-v' flag is used, you may also pass a list of python packages to install into the created virtual environment. These will be delegated to the appropriate tool (pip or conda) depending on what environment was created. If the environment is conda, the packages will be passed at environment creation time meaning they will have their dependencies resolved together. Normal python environments will first be created and then have specified packages installed. If 'common_packages' is specified in pytoil's config file, these will automatically be included in the environment. To specify versions of packages via the command line, you must enclose them in double quotes e.g. "flask>=1.0.0" not flask>=1.0.0 otherwise this will be interpreted by the shell as a command redirection. Examples: $ pytoil new my_project $ pytoil new my_project --cookie https://github.com/some/cookie.git $ pytoil new my_project --venv conda $ pytoil new my_project -c https://github.com/some/cookie.git -v conda --no-git $ pytoil new my_project -v venv requests "flask>=1.0.0" $ pytoil new my_project --starter python """ api = API(username=config.username, token=config.token) repo = Repo( owner=config.username, name=project, local_path=config.projects_dir.joinpath(project), ) git = Git() # Additional packages to include to_install: list[str] = [*packages] + config.common_packages # Can't use --cookie and --starter if cookie and starter: printer.error("--cookie and --starter are mutually exclusive", exits=1) # Can't use --copier and --starter if _copier and starter: printer.error("--copier and --starter are mutually exclusive", exits=1) # Can't use --venv with non-python starters if ( starter is not None # User specified --starter and starter != "python" # Requested starter is not python and venv is not None # And the user wants a virtual environment ): printer.error(f"Can't create a venv for a {starter} project", exits=1) # Resolve config vs flag for no-git # flag takes priority over config use_git: bool = config.git and not no_git # Does this project already exist? # Mightaswell check concurrently local = repo.exists_local() remote = repo.exists_remote(api) if local: printer.error(f"{repo.name} already exists locally.") printer.note( f"To checkout this project, use `pytoil checkout {repo.name}`.", exits=1 ) if remote: printer.error(f"{repo.name} already exists on GitHub.") printer.note( f"To checkout this project, use `pytoil checkout {repo.name}`.", exits=1 ) # If we get here, we're good to create a new project if cookie: printer.info(f"Creating {repo.name} from cookiecutter: {cookie}.") cookiecutter(template=cookie, output_dir=str(config.projects_dir)) elif _copier: printer.info(f"Creating {repo.name} from copier: {_copier}.") copier.run_auto(src_path=_copier, dst_path=repo.local_path) elif starter == "go": printer.info(f"Creating {repo.name} from starter: {starter}.") go_starter = GoStarter(path=config.projects_dir, name=repo.name) try: go_starter.generate(username=config.username) except GoNotInstalledError: printer.error("Go not installed.", exits=1) else: if use_git: git.init(cwd=repo.local_path, silent=False) git.add(cwd=repo.local_path, silent=False) git.commit(cwd=repo.local_path, silent=False) elif starter == "python": printer.info(f"Creating {repo.name} from starter: {starter}.") python_starter = PythonStarter(path=config.projects_dir, name=repo.name) python_starter.generate() if use_git: git.init(cwd=repo.local_path, silent=False) git.add(cwd=repo.local_path, silent=False) git.commit(cwd=repo.local_path, silent=False) elif starter == "rust": printer.info(f"Creating {repo.name} from starter: {starter}.") rust_starter = RustStarter(path=config.projects_dir, name=repo.name) try: rust_starter.generate() except CargoNotInstalledError: printer.error("Cargo not installed.", exits=1) else: if use_git: git.init(cwd=repo.local_path, silent=False) git.add(cwd=repo.local_path, silent=False) git.commit(cwd=repo.local_path, silent=False) else: # Just a blank new project printer.info(f"Creating {repo.name} at '{repo.local_path}'.") repo.local_path.mkdir() if use_git: git.init(cwd=repo.local_path, silent=False) # Now we need to handle any requested virtual environments if venv == "venv": printer.info(f"Creating virtual environment for {repo.name}") if to_install: printer.note(f"Including {', '.join(to_install)}") env = Venv(root=repo.local_path) with printer.progress() as p: p.add_task("[bold white]Working") env.create(packages=to_install, silent=True) elif venv == "conda": # Note, conda installs take longer so by default we don't hide the output # like we do for normal python environments printer.info(f"Creating conda environment for {repo.name}") if to_install: printer.note(f"Including {', '.join(to_install)}") conda_env = Conda( root=repo.local_path, environment_name=repo.name, conda=config.conda_bin ) try: conda_env.create(packages=to_install) except EnvironmentAlreadyExistsError: printer.error( f"Conda environment {conda_env.environment_name!r} already exists", exits=1, ) else: # Export the environment.yml conda_env.export_yml() # Now handle opening in an editor if config.specifies_editor(): printer.sub_info(f"Opening {repo.name} with {config.editor}") editor.launch(path=repo.local_path, bin=config.editor)
def pull(config: Config, projects: tuple[str, ...], force: bool, all_: bool) -> None: """ Pull down your remote projects. The pull command provides easy methods for pulling down remote projects. It is effectively a nice wrapper around git clone but you don't have to worry about urls or what your cwd is, pull will grab your remote projects by name and clone them to your configured projects directory. You can also use pull to batch clone multiple repos, even all of them ("--all/-a") if you're into that sorta thing. If more than 1 repo is passed (or if "--all/-a" is used) pytoil will pull the repos concurrently, speeding up the process. Any remote project that already exists locally will be skipped and none of your local projects are changed in any way. pytoil will only pull down those projects that don't already exist locally. It's very possible to accidentally clone a lot of repos when using pull so you will be prompted for confirmation before pytoil does anything. The "--force/-f" flag can be used to override this confirmation prompt if desired. Examples: $ pytoil pull project1 project2 project3 $ pytoil pull project1 project2 project3 --force $ pytoil pull --all $ pytoil pull --all --force """ if not projects and not all_: printer.error( "If not using the '--all' flag, you must specify projects to pull.", exits=1) api = API(username=config.username, token=config.token) local_projects: set[str] = { f.name for f in config.projects_dir.iterdir() if f.is_dir() and not f.name.startswith(".") } try: remote_projects = api.get_repo_names() except httpx.HTTPStatusError as err: utils.handle_http_status_error(err) else: if not remote_projects: printer.error("You don't have any remote projects to pull.", exits=1) specified_remotes = remote_projects if all_ else set(projects) # Check for typos for project in projects: if project not in remote_projects: printer.error( f"{project!r} not found on GitHub. Was it a typo?", exits=1) diff = specified_remotes.difference(local_projects) if not diff: printer.good("Your local and remote projects are in sync!", exits=0) if not force: if len(diff) <= 3: message = f"This will pull down {', '.join(diff)}. Are you sure?" else: # Too many to show nicely message = f"This will pull down {len(diff)} projects. Are you sure?" confirmed: bool = questionary.confirm(message, default=False, auto_enter=False).ask() if not confirmed: printer.warn("Aborted", exits=1) # Now we're good to go to_clone = [ Repo( owner=config.username, name=project, local_path=config.projects_dir.joinpath(project), ) for project in diff ] git = Git() with ThreadPoolExecutor() as executor: for repo in to_clone: executor.submit(clone_and_report, repo=repo, git=git, config=config)