def ensure_path(location: Path, *, force: bool) -> Tuple[bool, bool]: """Ensure location is in user's PATH or add it to PATH. Returns True if location was added to PATH """ location_str = str(location) path_added = False need_shell_restart = userpath.need_shell_restart(location_str) in_current_path = userpath.in_current_path(location_str) if force or (not in_current_path and not need_shell_restart): userpath.append(location_str, "pipx") print( pipx_wrap( f"Success! Added {location_str} to the PATH environment variable.", subsequent_indent=" " * 4, )) path_added = True need_shell_restart = userpath.need_shell_restart(location_str) elif not in_current_path and need_shell_restart: print( pipx_wrap( f""" {location_str} has been been added to PATH, but you need to open a new terminal or re-login for this PATH change to take effect. """, subsequent_indent=" " * 4, )) else: print( pipx_wrap(f"{location_str} is already in PATH.", subsequent_indent=" " * 4)) return (path_added, need_shell_restart)
def _symlink_package_apps( local_bin_dir: Path, app_paths: List[Path], *, force: bool, suffix: str = "" ) -> None: for app_path in app_paths: app_name = app_path.name app_name_suffixed = add_suffix(app_name, suffix) symlink_path = Path(local_bin_dir / app_name_suffixed) if not symlink_path.parent.is_dir(): mkdir(symlink_path.parent) if force: logger.info(f"Force is true. Removing {str(symlink_path)}.") try: symlink_path.unlink() except FileNotFoundError: pass except IsADirectoryError: rmdir(symlink_path) exists = symlink_path.exists() is_symlink = symlink_path.is_symlink() if exists: if symlink_path.samefile(app_path): logger.info(f"Same path {str(symlink_path)} and {str(app_path)}") else: logger.warning( pipx_wrap( f""" {hazard} File exists at {str(symlink_path)} and points to {symlink_path.resolve()}, not {str(app_path)}. Not modifying. """, subsequent_indent=" " * 4, ) ) continue if is_symlink and not exists: logger.info( f"Removing existing symlink {str(symlink_path)} since it " "pointed non-existent location" ) symlink_path.unlink() existing_executable_on_path = which(app_name_suffixed) symlink_path.symlink_to(app_path) if existing_executable_on_path: logger.warning( pipx_wrap( f""" {hazard} Note: {app_name_suffixed} was already on your PATH at {existing_executable_on_path} """, subsequent_indent=" " * 4, ) )
def ensure_pipx_paths(force: bool) -> ExitCode: """Returns pipx exit code.""" bin_paths = [constants.LOCAL_BIN_DIR] pipx_user_bin_path = get_pipx_user_bin_path() if pipx_user_bin_path is not None: bin_paths.append(pipx_user_bin_path) path_added = False need_shell_restart = False for bin_path in bin_paths: (path_added_current, need_shell_restart_current) = ensure_path( bin_path, force=force ) path_added |= path_added_current need_shell_restart |= need_shell_restart_current print() if path_added: print( pipx_wrap( """ Consider adding shell completions for pipx. Run 'pipx completions' for instructions. """ ) + "\n" ) elif not need_shell_restart: sys.stdout.flush() logger.warning( pipx_wrap( f""" {hazard} All pipx binary directories have been added to PATH. If you are sure you want to proceed, try again with the '--force' flag. """ ) + "\n" ) if need_shell_restart: print( pipx_wrap( """ You will need to open a new terminal or re-login for the PATH changes to take effect. """ ) + "\n" ) print(f"Otherwise pipx is ready to go! {stars}") return EXIT_CODE_OK
def setup(args: argparse.Namespace) -> None: if "version" in args and args.version: print_version() sys.exit(0) setup_logging("verbose" in args and args.verbose) logger.debug(f"{time.strftime('%Y-%m-%d %H:%M:%S')}") logger.debug(f"{' '.join(sys.argv)}") logger.info(f"pipx version is {__version__}") logger.info(f"Default python interpreter is {repr(DEFAULT_PYTHON)}") mkdir(constants.PIPX_LOCAL_VENVS) mkdir(constants.LOCAL_BIN_DIR) mkdir(constants.PIPX_VENV_CACHEDIR) old_pipx_venv_location = constants.PIPX_LOCAL_VENVS / "pipx-app" if old_pipx_venv_location.exists(): logger.warning( pipx_wrap( f""" {hazard} A virtual environment for pipx was detected at {str(old_pipx_venv_location)}. The 'pipx-app' package has been renamed back to 'pipx' (https://github.com/pipxproject/pipx/issues/82). """, subsequent_indent=" " * 4, ))
def __init__(self, path: Path, *, verbose: bool = False, python: str = DEFAULT_PYTHON) -> None: self.root = path self.python = python self.bin_path, self.python_path = get_venv_paths(self.root) self.pipx_metadata = PipxMetadata(venv_dir=path) self.verbose = verbose self.do_animation = not verbose try: self._existing = self.root.exists() and next(self.root.iterdir()) except StopIteration: self._existing = False if self._existing and self.uses_shared_libs: if shared_libs.is_valid: if shared_libs.needs_upgrade: shared_libs.upgrade(verbose=verbose) else: shared_libs.create(verbose) if not shared_libs.is_valid: raise PipxError( pipx_wrap(f""" Error: pipx's shared venv {shared_libs.root} is invalid and needs re-installation. To fix this, install or reinstall a package. For example: """) + f"\n pipx install {self.root.name} --force", wrap_message=False, )
def parse_specifier_for_install( package_spec: str, pip_args: List[str] ) -> Tuple[str, List[str]]: """Return package_or_url and pip_args suitable for pip install Specifically: * Strip any markers (e.g. python_version > "3.4") * Ensure --editable is removed for any package_spec not a local path * Convert local paths to absolute paths """ parsed_package = _parse_specifier(package_spec) package_or_url = _parsed_package_to_package_or_url( parsed_package, remove_version_specifiers=False ) if "--editable" in pip_args and not parsed_package.valid_local_path: logger.warning( pipx_wrap( f""" {hazard} Ignoring --editable install option. pipx disallows it for anything but a local path, to avoid having to create a new src/ directory. """, subsequent_indent=" " * 4, ) ) pip_args.remove("--editable") return (package_or_url, pip_args)
def _parsed_package_to_package_or_url( parsed_package: ParsedPackage, remove_version_specifiers: bool ) -> str: if parsed_package.valid_pep508 is not None: if parsed_package.valid_pep508.marker is not None: logger.warning( pipx_wrap( f""" {hazard} Ignoring environment markers ({parsed_package.valid_pep508.marker}) in package specification. Use pipx options to specify this type of information. """, subsequent_indent=" " * 4, ) ) package_or_url = package_or_url_from_pep508( parsed_package.valid_pep508, remove_version_specifiers=remove_version_specifiers, ) elif parsed_package.valid_url is not None: package_or_url = parsed_package.valid_url elif parsed_package.valid_local_path is not None: package_or_url = parsed_package.valid_local_path logger.info(f"cleaned package spec: {package_or_url}") return package_or_url
def remove_venv(self) -> None: if self.safe_to_remove(): rmdir(self.root) else: logger.warning( pipx_wrap( f""" {hazard} Not removing existing venv {self.root} because it was not created in this session """, subsequent_indent=" " * 4, ))
def warn_if_not_on_path(local_bin_dir: Path) -> None: if not userpath.in_current_path(str(local_bin_dir)): logger.warning( pipx_wrap( f""" {hazard} Note: {str(local_bin_dir)!r} is not on your PATH environment variable. These apps will not be globally accessible until your PATH is updated. Run `pipx ensurepath` to automatically add it, or manually modify your PATH in your shell's config file (i.e. ~/.bashrc). """, subsequent_indent=" " * 4, ))
def read(self, verbose: bool = False) -> None: try: with open(self.venv_dir / PIPX_INFO_FILENAME, "r") as pipx_metadata_fh: self.from_dict( json.load(pipx_metadata_fh, object_hook=_json_decoder_object_hook) ) except IOError: # Reset self if problem reading if verbose: logger.warning( pipx_wrap( f""" {hazard} Unable to read {PIPX_INFO_FILENAME} in {self.venv_dir}. This may cause this or future pipx operations involving {self.venv_dir.name} to fail or behave incorrectly. """, subsequent_indent=" " * 4, ) ) return
def fix_package_name(package_or_url: str, package_name: str) -> str: try: package_req = Requirement(package_or_url) except InvalidRequirement: # not a valid PEP508 package specification return package_or_url if canonicalize_name(package_req.name) != canonicalize_name(package_name): logger.warning( pipx_wrap( f""" {hazard} Name supplied in package specifier was {package_req.name!r} but package found has name {package_name!r}. Using {package_name!r}. """, subsequent_indent=" " * 4, )) package_req.name = package_name return str(package_req)
def write(self) -> None: self._validate_before_write() try: with open(self.venv_dir / PIPX_INFO_FILENAME, "w") as pipx_metadata_fh: json.dump( self.to_dict(), pipx_metadata_fh, indent=4, sort_keys=True, cls=JsonEncoderHandlesPath, ) except IOError: logger.warning( pipx_wrap( f""" {hazard} Unable to write {PIPX_INFO_FILENAME} to {self.venv_dir}. This may cause future pipx operations involving {self.venv_dir.name} to fail or behave incorrectly. """, subsequent_indent=" " * 4, ) )
PIPX_DESCRIPTION = textwrap.dedent(f""" Install and execute apps from Python packages. Binaries can either be installed globally into isolated Virtual Environments or run directly in a temporary Virtual Environment. Virtual Environment location is {str(constants.PIPX_LOCAL_VENVS)}. Symlinks to apps are placed in {str(constants.LOCAL_BIN_DIR)}. """) PIPX_DESCRIPTION += pipx_wrap( """ optional environment variables: PIPX_HOME Overrides default pipx location. Virtual Environments will be installed to $PIPX_HOME/venvs. PIPX_BIN_DIR Overrides location of app installations. Apps are symlinked or copied here. USE_EMOJI Overrides emoji behavior. Default value varies based on platform. PIPX_DEFAULT_PYTHON Overrides default python used for commands. """, subsequent_indent=" " * 24, # match the indent of argparse options keep_newlines=True, ) DOC_DEFAULT_PYTHON = os.getenv("PIPX__DOC_DEFAULT_PYTHON", DEFAULT_PYTHON) INSTALL_DESCRIPTION = textwrap.dedent(f""" The install command is the preferred way to globally install apps from python packages on your system. It creates an isolated virtual environment for the package, then ensures the package's apps are accessible on your $PATH. The result: apps you can run from anywhere, located in packages
def _upgrade_package( venv: Venv, package_name: str, pip_args: List[str], is_main_package: bool, force: bool, upgrading_all: bool, ) -> int: """Returns 1 if package version changed, 0 if same version""" package_metadata = venv.package_metadata[package_name] if package_metadata.package_or_url is None: raise PipxError( f"Internal Error: package {package_name} has corrupt pipx metadata." ) package_or_url = parse_specifier_for_upgrade( package_metadata.package_or_url) old_version = package_metadata.package_version venv.upgrade_package( package_name, package_or_url, pip_args, include_dependencies=package_metadata.include_dependencies, include_apps=package_metadata.include_apps, is_main_package=is_main_package, suffix=package_metadata.suffix, ) package_metadata = venv.package_metadata[package_name] display_name = f"{package_metadata.package}{package_metadata.suffix}" new_version = package_metadata.package_version if package_metadata.include_apps: expose_apps_globally( constants.LOCAL_BIN_DIR, package_metadata.app_paths, force=force, suffix=package_metadata.suffix, ) if package_metadata.include_dependencies: for _, app_paths in package_metadata.app_paths_of_dependencies.items(): expose_apps_globally( constants.LOCAL_BIN_DIR, app_paths, force=force, suffix=package_metadata.suffix, ) if old_version == new_version: if upgrading_all: pass else: print( pipx_wrap(f""" {display_name} is already at latest version {old_version} (location: {str(venv.root)}) """)) return 0 else: print( pipx_wrap(f""" upgraded package {display_name} from {old_version} to {new_version} (location: {str(venv.root)}) """)) return 1
def install( venv_dir: Optional[Path], package_name: Optional[str], package_spec: str, local_bin_dir: Path, python: str, pip_args: List[str], venv_args: List[str], verbose: bool, *, force: bool, include_dependencies: bool, suffix: str = "", ) -> ExitCode: """Returns pipx exit code.""" # package_spec is anything pip-installable, including package_name, vcs spec, # zip file, or tar.gz file. if package_name is None: package_name = package_name_from_spec(package_spec, python, pip_args=pip_args, verbose=verbose) if venv_dir is None: venv_container = VenvContainer(constants.PIPX_LOCAL_VENVS) venv_dir = venv_container.get_venv_dir(f"{package_name}{suffix}") try: exists = venv_dir.exists() and bool(next(venv_dir.iterdir())) except StopIteration: exists = False venv = Venv(venv_dir, python=python, verbose=verbose) if exists: if force: print(f"Installing to existing venv {venv.name!r}") else: print( pipx_wrap(f""" {venv.name!r} already seems to be installed. Not modifying existing installation in {str(venv_dir)!r}. Pass '--force' to force installation. """)) return EXIT_CODE_INSTALL_VENV_EXISTS try: venv.create_venv(venv_args, pip_args) venv.install_package( package_name=package_name, package_or_url=package_spec, pip_args=pip_args, include_dependencies=include_dependencies, include_apps=True, is_main_package=True, suffix=suffix, ) run_post_install_actions( venv, package_name, local_bin_dir, venv_dir, include_dependencies, force=force, ) except (Exception, KeyboardInterrupt): print() venv.remove_venv() raise # Any failure to install will raise PipxError, otherwise success return EXIT_CODE_OK
def run( app: str, package_or_url: str, app_args: List[str], python: str, pip_args: List[str], venv_args: List[str], pypackages: bool, verbose: bool, use_cache: bool, ) -> NoReturn: """Installs venv to temporary dir (or reuses cache), then runs app from package """ if urllib.parse.urlparse(app).scheme: if not app.endswith(".py"): raise PipxError(""" pipx will only execute apps from the internet directly if they end with '.py'. To run from an SVN, try pipx --spec URL BINARY """) logger.info( "Detected url. Downloading and executing as a Python file.") content = _http_get_request(app) exec_app([str(python), "-c", content]) elif which(app): logger.warning( pipx_wrap( f""" {hazard} {app} is already on your PATH and installed at {which(app)}. Downloading and running anyway. """, subsequent_indent=" " * 4, )) if WINDOWS: app_filename = f"{app}.exe" logger.info(f"Assuming app is {app_filename!r} (Windows only)") else: app_filename = app pypackage_bin_path = get_pypackage_bin_path(app) if pypackage_bin_path.exists(): logger.info( f"Using app in local __pypackages__ directory at {str(pypackage_bin_path)}" ) run_pypackage_bin(pypackage_bin_path, app_args) if pypackages: raise PipxError(f""" '--pypackages' flag was passed, but {str(pypackage_bin_path)!r} was not found. See https://github.com/cs01/pythonloc to learn how to install here, or omit the flag. """) venv_dir = _get_temporary_venv_path(package_or_url, python, pip_args, venv_args) venv = Venv(venv_dir) bin_path = venv.bin_path / app_filename _prepare_venv_cache(venv, bin_path, use_cache) if venv.has_app(app, app_filename): logger.info(f"Reusing cached venv {venv_dir}") venv.run_app(app, app_filename, app_args) else: logger.info(f"venv location is {venv_dir}") _download_and_run( Path(venv_dir), package_or_url, app, app_filename, app_args, python, pip_args, venv_args, use_cache, verbose, )