Пример #1
0
def main(ctx: click.Context) -> None:
    """
    Helpful CLI to automate the development workflow.

    - Create and manage your local and remote projects

    - Build projects from cookiecutter templates.

    - Easily create/manage virtual environments.

    - Minimal configuration required.
    """
    # Load the config once on launch of the app and pass it down to the child commands
    # through click's context
    try:
        config = Config.load()
    except FileNotFoundError:
        interactive_config()
    else:
        if not config.can_use_api():
            printer.error(
                "You must set your GitHub username and personal access token to use API"
                " features.",
                exits=1,
            )
        # We have a valid config file at the right place so load it into click's
        # context and pass it down to all subcommands
        ctx.obj = config
Пример #2
0
def remote(config: Config, limit: int) -> None:
    """
    Show your remote projects.

    Show the projects that you own on GitHub.

    These may include some you already have locally.
    Use 'show diff' to see the difference between local and remote.

    The "-l/--limit" flag can be used to limit the number of repos
    returned.

    Examples:

    $ pytoil show remote

    $ pytoil show remote --limit 10
    """
    console = Console()
    api = API(username=config.username, token=config.token)

    try:
        repos = api.get_repos()
    except httpx.HTTPStatusError as err:
        utils.handle_http_status_error(err)
    else:
        if not repos:
            printer.error("You don't have any projects on GitHub yet.",
                          exits=1)
            # Return so mypy knows we've narrowed type of repos
            return

        table = Table(box=box.SIMPLE)
        table.add_column("Name", style="bold white")
        table.add_column("Size")
        table.add_column("Created")
        table.add_column("Modified")

        printer.title("Remote Projects", spaced=False)
        console.print(
            f"[bright_black italic]\nShowing {min(limit, len(repos))} out of"
            f" {len(repos)} remote projects [/]")

        for repo in repos[:limit]:
            table.add_row(
                repo["name"],
                humanize.naturalsize(int(repo["diskUsage"]) * 1024),
                humanize.naturaltime(
                    datetime.strptime(repo["createdAt"], GITHUB_TIME_FORMAT),
                    when=datetime.utcnow(),
                ),
                humanize.naturaltime(
                    datetime.strptime(repo["pushedAt"], GITHUB_TIME_FORMAT),
                    when=datetime.utcnow(),
                ),
            )

        console.print(table)
Пример #3
0
def forks(config: Config, limit: int) -> None:
    """
    Show your forked projects.

    Show the projects you own on GitHub that are forks
    of other projects.

    The "-l/--limit" flag can be used to limit the number of
    repos returned.

    Examples:

    $ pytoil show forks

    $ pytoil show forks --limit 10
    """
    console = Console()
    api = API(username=config.username, token=config.token)

    try:
        forks = api.get_forks()
    except httpx.HTTPStatusError as err:
        utils.handle_http_status_error(err)
    else:
        if not forks:
            printer.error("You don't have any forks yet.", exits=1)
            return

        table = Table(box=box.SIMPLE)
        table.add_column("Name", style="bold white")
        table.add_column("Size")
        table.add_column("Forked")
        table.add_column("Modified")
        table.add_column("Parent")

        printer.title("Forked Projects", spaced=False)
        console.print(
            f"[bright_black italic]\nShowing {min(limit, len(forks))} out of"
            f" {len(forks)} forked projects [/]")

        for repo in forks[:limit]:
            table.add_row(
                repo["name"],
                humanize.naturalsize(int(repo["diskUsage"]) * 1024),
                humanize.naturaltime(
                    datetime.strptime(repo["createdAt"], GITHUB_TIME_FORMAT),
                    when=datetime.utcnow(),
                ),
                humanize.naturaltime(
                    datetime.strptime(repo["pushedAt"], GITHUB_TIME_FORMAT),
                    when=datetime.utcnow(),
                ),
                repo["parent"]["nameWithOwner"],
            )

        console = Console()
        console.print(table)
Пример #4
0
def find(config: Config, project: str, limit: int) -> None:
    """
    Quickly locate a project.

    The find command provides a fuzzy search for finding a project when you
    don't know where it is (local or on GitHub).

    It will perform a fuzzy search through all your local and remote projects,
    bring back the best matches and show you where they are.

    Useful if you have a lot of projects and you can't quite remember
    what the one you want is called!

    The "-l/--limit" flag can be used to alter the number of returned
    search results, but bare in mind that matches with sufficient match score
    are returned anyway so the results flag only limits the maximum number
    of results shown.

    Examples:

    $ pytoil find my

    $ pytoil find proj --limit 3
    """
    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(".")
    }
    remote_projects = api.get_repo_names()

    all_projects = local_projects.union(remote_projects)

    matches: list[tuple[str, int]] = process.extractBests(
        project, all_projects, limit=limit, score_cutoff=FUZZY_SCORE_CUTOFF)

    table = Table(box=box.SIMPLE)
    table.add_column("Project", style="bold white")
    table.add_column("Similarity")
    table.add_column("Where")

    if len(matches) == 0:
        printer.error("No matches found!", exits=1)

    for match in matches:
        is_local = match[0] in local_projects
        table.add_row(
            match[0],
            str(match[1]),
            Text("Local", style="green")
            if is_local else Text("Remote", style="dark_orange"),
        )

    console = Console()
    console.print(table)
Пример #5
0
def local(config: Config, limit: int) -> None:
    """
    Show your local projects.

    Show the projects you have locally in your configured
    projects directory.

    You can limit the number of projects shown with the
    "--limit/-l" flag.

    Examples:

    $ pytoil show local

    $ pytoil show local --limit 5
    """
    console = Console()
    local_projects: set[Path] = {
        f
        for f in config.projects_dir.iterdir()
        if f.is_dir() and not f.name.startswith(".")
    }

    if not local_projects:
        printer.error("You don't have any local projects yet!", exits=1)

    table = Table(box=box.SIMPLE)
    table.add_column("Name", style="bold white")
    table.add_column("Created")
    table.add_column("Modified")

    stats = (project.stat() for project in local_projects)

    results = {project: stat for project, stat in zip(local_projects, stats)}

    printer.title("Local Projects", spaced=False)
    console.print(
        f"[bright_black italic]\nShowing {min(limit, len(results))} out of"
        f" {len(local_projects)} local projects [/]")
    for path, result in sorted(results.items(),
                               key=lambda x: str.casefold(str(x[0])))[:limit]:
        table.add_row(
            path.name,
            humanize.naturaltime(
                datetime.utcfromtimestamp(result.st_birthtime),
                when=datetime.utcnow()  # type: ignore[attr-defined]
            ),
            humanize.naturaltime(datetime.utcfromtimestamp(result.st_mtime),
                                 when=datetime.utcnow()),
        )

    console.print(table)
Пример #6
0
def get(config: Config, key: str) -> None:
    """
    Get the currently set value for a config key.

    The get command will only allow valid pytoil config keys.

    Examples:

    $ pytoil config get editor
    """
    if key not in defaults.CONFIG_KEYS:
        printer.error(f"{key} is not a valid pytoil config key.", exits=1)

    console = Console()
    console.print(f"[cyan]{key}[/]: [default]{config.to_dict().get(key)}[/]")
Пример #7
0
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)
Пример #8
0
def handle_http_status_error(error: httpx.HTTPStatusError) -> None:
    """
    Handles a variety of possible HTTP Status errors, print's nicer output
    to the user, and exits the program if necessary.
    Call this in an except block on CLI commands accessing the
    GitHub API.
    Args:
        error (httpx.HTTPStatusError): The error to be handled.
    """
    code = error.response.status_code

    if code == 401:
        printer.error("HTTP 401 - Unauthorized")
        printer.note("This usually means something is wrong with your token!",
                     exits=1)
    elif code == 404:
        printer.error("HTTP 404 - Not Found")
        printer.note("This is a bug we've not handled, please raise an issue!",
                     exits=1)
    elif code == 500:
        printer.error("HTTP 500 - Server Error")
        printer.note("This is very rare but it means GitHub is not happy!",
                     exits=1)
Пример #9
0
def keep(config: Config, projects: tuple[str, ...], force: bool) -> None:
    """
    Remove all but the specified projects.

    The keep command lets you delete all projects from your local
    projects directory whilst keeping the specified ones untouched.

    It is effectively the inverse of `pytoil remove`.

    As with most programmatic deleting, the directories are deleted instantly and
    not sent to trash. As such, pytoil will prompt you for confirmation before
    doing anything.

    The "--force/-f" flag can be used to force deletion without the confirmation
    prompt. Use with caution!

    Examples:

    $ pytoil keep project1 project2 project3

    $ pytoil keep project1 project2 project3 --force
    """
    local_projects: set[str] = {
        f.name
        for f in config.projects_dir.iterdir()
        if f.is_dir() and not f.name.startswith(".")
    }

    if not local_projects:
        printer.error("You don't have any local projects to remove", exits=1)

    # If user gives a project that doesn't exist (e.g. typo), abort
    for project in projects:
        if project not in local_projects:
            printer.error(
                f"{project!r} not found under {config.projects_dir}. Was it a typo?",
                exits=1,
            )

    specified = set(projects)
    to_delete = local_projects.difference(specified)

    if not force:
        if len(to_delete) <= 3:
            # Nice number to show the names
            question = questionary.confirm(
                f"This will delete {', '.join(to_delete)} from your local filesystem."
                " Are you sure?",
                default=False,
                auto_enter=False,
            )
        else:
            # Too many to print the names nicely
            question = questionary.confirm(
                f"This will delete {len(to_delete)} projects from your local"
                " filesystem. Are you sure?",
                default=False,
                auto_enter=False,
            )

        confirmed: bool = question.ask()

        if not confirmed:
            printer.warn("Aborted", exits=1)

    # If we get here, user has used --force or said yes when prompted
    # do the deleting in a threadpool so it's concurrent
    with ThreadPoolExecutor() as executor:
        for project in to_delete:
            executor.submit(remove_and_report, config=config, project=project)
Пример #10
0
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)
Пример #11
0
def diff(config: Config, limit: int) -> None:
    """
    Show the difference in local/remote projects.

    Show the projects that you own on GitHub but do not
    have locally.

    The "-l/--limit" flag can be used to limit the number of repos
    returned.

    Examples:

    $ pytoil show diff

    $ pytoil show diff --limit 10
    """
    console = Console()
    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_repos()
    except httpx.HTTPStatusError as err:
        utils.handle_http_status_error(err)
    else:
        if not remote_projects:
            printer.error("You don't have any projects on GitHub yet!",
                          exits=1)
            return

        remote_names: set[str] = {repo["name"] for repo in remote_projects}
        diff = remote_names.difference(local_projects)

        diff_info: list[dict[str, Any]] = []
        for repo in remote_projects:
            if name := repo.get("name"):
                if name in diff:
                    diff_info.append(repo)

        if not diff:
            printer.good("Your local and remote projects are in sync!")
        else:
            table = Table(box=box.SIMPLE)
            table.add_column("Name", style="bold white")
            table.add_column("Size")
            table.add_column("Created")
            table.add_column("Modified")

            printer.title("Diff: Remote - Local", spaced=False)
            console.print(
                f"[bright_black italic]\nShowing {min(limit, len(diff_info))} out of"
                f" {len(diff_info)} projects [/]")

            for repo in diff_info[:limit]:
                table.add_row(
                    repo["name"],
                    humanize.naturalsize(int(repo["diskUsage"] * 1024)),
                    humanize.naturaltime(
                        datetime.strptime(repo["createdAt"],
                                          GITHUB_TIME_FORMAT),
                        when=datetime.utcnow(),
                    ),
                    humanize.naturaltime(
                        datetime.strptime(repo["pushedAt"],
                                          GITHUB_TIME_FORMAT),
                        when=datetime.utcnow(),
                    ),
                )

            console = Console()
            console.print(table)
Пример #12
0
def remove(config: Config, projects: tuple[str, ...], force: bool,
           all_: bool) -> None:
    """
    Remove projects from your local filesystem.

    The remove command provides an easy interface for decluttering your local
    projects directory.

    You can selectively remove any number of projects by passing them as
    arguments or nuke the whole lot with "--all/-a" if you want.

    As with most programmatic deleting, the directories are deleted instantly and
    not sent to trash. As such, pytoil will prompt you for confirmation before
    doing anything.

    The "--force/-f" flag can be used to force deletion without the confirmation
    prompt. Use with caution!

    Examples:

    $ pytoil remove project1 project2 project3

    $ pytoil remove project1 project2 project3 --force

    $ pytoil remove --all

    $ pytoil remove --all --force
    """
    local_projects: set[str] = {
        f.name
        for f in config.projects_dir.iterdir()
        if f.is_dir() and not f.name.startswith(".")
    }

    if not local_projects:
        printer.error("You don't have any local projects to remove", exits=1)

    if not projects and not all_:
        printer.error(
            "If not using the '--all' flag, you must specify projects to remove.",
            exits=1,
        )

    # If user gives a project that doesn't exist (e.g. typo), abort
    for project in projects:
        if project not in local_projects:
            printer.error(
                f"{project!r} not found under {config.projects_dir}. Was it a typo?",
                exits=1,
            )

    to_delete = local_projects if all_ else projects

    if not force:
        if all_:
            question = questionary.confirm(
                "This will delete ALL of your projects. Are you sure?",
                default=False,
                auto_enter=False,
            )
        elif len(projects) <= 3:
            # Nice number to show the names
            question = questionary.confirm(
                f"This will delete {', '.join(projects)} from your local filesystem."
                " Are you sure?",
                default=False,
                auto_enter=False,
            )
        else:
            # Too many to print the names nicely
            question = questionary.confirm(
                f"This will delete {len(projects)} projects from your local filesystem."
                " Are you sure?",
                default=False,
                auto_enter=False,
            )

        confirmed: bool = question.ask()

        if not confirmed:
            printer.warn("Aborted", exits=1)

    # If we get here, user has used --force or said yes when prompted
    # do the deleting in a threadpool so it's concurrent
    with ThreadPoolExecutor() as executor:
        for project in to_delete:
            executor.submit(remove_and_report, config=config, project=project)
Пример #13
0
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)