├── .gitignore ├── pythonup ├── __init__.py ├── conf.py ├── operations │ ├── versions.py │ ├── common.py │ ├── use.py │ ├── install.py │ └── link.py ├── installations.py ├── paths.py ├── __main__.py └── versions.py ├── Pipfile ├── tools └── dump_requirements.py ├── LICENSE ├── README.rst └── Pipfile.lock /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /pythonup/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.0.0' 2 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | click = "*" 8 | dataclasses = "*" 9 | packaging = "*" 10 | 11 | [dev-packages] 12 | ipython = "*" 13 | pytest = "*" 14 | requirementslib = "~=1.0" 15 | 16 | [requires] 17 | python_version = "3.6" 18 | 19 | [scripts] 20 | pythonup = "python -m pythonup" 21 | -------------------------------------------------------------------------------- /tools/dump_requirements.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import requirementslib 4 | 5 | 6 | def main(): 7 | p = requirementslib.Pipfile.load(sys.argv[1]) 8 | for s in p.sections: 9 | if s.name != 'packages': 10 | continue 11 | for r in s.requirements: 12 | print(r.as_line()) 13 | 14 | 15 | if __name__ == '__main__': 16 | if len(sys.argv) < 2: 17 | print('usage: {cmd} Pipfile'.format(cmd=sys.argv[0]), file=sys.stderr) 18 | sys.exit(1) 19 | main() 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Tzu-ping Chung 2 | 3 | Permission to use, copy, modify, and distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /pythonup/conf.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import json 3 | 4 | from . import paths 5 | 6 | 7 | def safe_load(f): 8 | try: 9 | return json.load(f) 10 | except json.JSONDecodeError: 11 | return {} 12 | 13 | 14 | class Settings: 15 | 16 | @property 17 | def config(self): 18 | path = paths.get_root_dir().joinpath('config') 19 | if not path.exists(): 20 | path.touch(mode=0o644, exist_ok=True) 21 | return path 22 | 23 | def __getitem__(self, key): 24 | with self.config.open() as f: 25 | return safe_load(f)[key] 26 | 27 | def __setitem__(self, key, value): 28 | with contextlib.ExitStack() as stack: 29 | try: 30 | f = stack.enter_context(self.config.open('w+')) 31 | except FileNotFoundError: 32 | data = {} 33 | else: 34 | data = safe_load(f) 35 | data[key] = value 36 | json.dump(data, f, indent=4) 37 | 38 | def get(self, key, default=None): 39 | with self.config.open() as f: 40 | return safe_load(f).get(key, default) 41 | 42 | 43 | settings = Settings() 44 | -------------------------------------------------------------------------------- /pythonup/operations/versions.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from .. import conf, installations, versions 4 | 5 | from .common import check_installation, version_command 6 | 7 | 8 | @version_command() 9 | def where(version): 10 | installation = check_installation(version, expect=True) 11 | click.echo(str(installation.python)) 12 | 13 | 14 | def list_(list_all): 15 | outputed = False 16 | used_names = set(conf.settings.get('using', [])) 17 | for version in sorted(versions.iter_versions()): 18 | try: 19 | installation = version.find_installation() 20 | except installations.InstallationNotFoundError: 21 | installation = None 22 | if installation: 23 | if version.name in used_names: 24 | marker = '*' 25 | else: 26 | marker = 'o' 27 | outputed = True 28 | else: 29 | if not list_all: 30 | continue 31 | marker = ' ' 32 | click.echo(f'{marker} {version}') 33 | 34 | if not list_all and not outputed: 35 | click.echo( 36 | 'No installed versions. Use --all to list all available versions ' 37 | 'for installation.', 38 | err=True, 39 | ) 40 | -------------------------------------------------------------------------------- /pythonup/installations.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import pathlib 3 | import re 4 | import subprocess 5 | 6 | from . import paths 7 | 8 | 9 | class InstallationNotFoundError(ValueError, FileNotFoundError): 10 | pass 11 | 12 | 13 | class InvalidBuildError(ValueError): 14 | pass 15 | 16 | 17 | @dataclasses.dataclass 18 | class Installation: 19 | 20 | root: pathlib.Path 21 | 22 | @classmethod 23 | def find(cls, version, *, strict=True): 24 | path = paths.get_versions_dir().joinpath(version.name) 25 | try: 26 | path = path.resolve(strict=strict) 27 | except FileNotFoundError: 28 | raise InstallationNotFoundError(version) 29 | return cls(root=path) 30 | 31 | @property 32 | def python(self): 33 | return self.root.joinpath('bin', 'python') 34 | 35 | @property 36 | def pip(self): 37 | return self.root.joinpath('bin', 'pip') 38 | 39 | def get_build_name(self): 40 | process = subprocess.run( 41 | [str(self.python), '--version'], check=True, encoding='ascii', 42 | stdout=subprocess.PIPE, stderr=subprocess.STDOUT, 43 | ) 44 | # Newer versions use stdout, but older (mainly 2.7) use stderr. 45 | output = process.stdout.strip() or process.stderr.strip() 46 | match = re.match(r'^Python (\d+\.\d+\.\d+)$', output) 47 | if not match: 48 | raise InvalidBuildError 49 | return match.group(1) 50 | -------------------------------------------------------------------------------- /pythonup/operations/common.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | import click 4 | 5 | from .. import installations, versions 6 | 7 | 8 | def check_installation(version, *, expect=True, on_exit=None): 9 | try: 10 | installation = version.find_installation() 11 | except installations.InstallationNotFoundError: 12 | if not expect: 13 | return None 14 | message = f'{version} is not installed' 15 | else: 16 | if expect: 17 | return installation 18 | message = f'{version} is already installed' 19 | click.echo(message, err=True) 20 | if on_exit: 21 | on_exit() 22 | click.get_current_context().exit(1) 23 | 24 | 25 | def parse_version(name): 26 | try: 27 | return versions.Version.parse(name) 28 | except versions.VersionNotFoundError: 29 | click.echo('No such version: {}'.format(name), err=True) 30 | click.get_current_context().exit(1) 31 | 32 | 33 | def version_command(*, plural=False): 34 | """Decorator to convert version name arguments to actual version instances. 35 | """ 36 | def decorator(f): 37 | 38 | @functools.wraps(f) 39 | def wrapped(*args, version, **kwargs): 40 | if plural: 41 | kwargs['versions'] = [parse_version(n) for n in version] 42 | else: 43 | kwargs['version'] = parse_version(version) 44 | return f(*args, **kwargs) 45 | 46 | return wrapped 47 | 48 | return decorator 49 | -------------------------------------------------------------------------------- /pythonup/paths.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import pathlib 3 | 4 | 5 | def ensure_exists(*, directory=True): 6 | """Decorator to ensure the returning path exists. 7 | """ 8 | def wrapper(f): 9 | 10 | @functools.wraps(f) 11 | def wrapped(*args, **kwargs): 12 | path = f(*args, **kwargs) 13 | if not path.exists(): 14 | if directory: 15 | path.mkdir(parents=True) 16 | else: 17 | path.parent.mkdir(parents=True) 18 | path.touch() 19 | return path 20 | 21 | return wrapped 22 | 23 | return wrapper 24 | 25 | 26 | @ensure_exists() 27 | def get_root_dir(): 28 | """Return the root directory, as a :class:`pathlib.Path`. 29 | 30 | This tries to smart-detect the best location to host PythonUp. It tries 31 | `~/Library`, which likely only exists on Macs; if that does not exist, use 32 | the Linux standard `~/.local/share` instead. 33 | """ 34 | macos_library = pathlib.Path.home().joinpath('Library') 35 | if macos_library.exists(): 36 | return macos_library.joinpath('PythonUp') 37 | return pathlib.Path.home().joinpath('.local', 'share', 'pythonup') 38 | 39 | 40 | @ensure_exists() 41 | def get_versions_dir(): 42 | return get_root_dir().joinpath('versions') 43 | 44 | 45 | @ensure_exists() 46 | def get_cmd_dir(): 47 | return get_root_dir().joinpath('cmd') 48 | 49 | 50 | @ensure_exists() 51 | def get_bin_dir(): 52 | return get_root_dir().joinpath('bin') 53 | -------------------------------------------------------------------------------- /pythonup/operations/use.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from ..conf import settings 4 | from ..versions import Version 5 | 6 | from .common import check_installation, version_command 7 | from .link import use_versions 8 | 9 | 10 | @version_command(plural=True) 11 | def use(versions, add): 12 | used_names = settings.get('using', []) 13 | if add is None and not versions: 14 | # Bare "use": Display active versions. 15 | names = used_names 16 | if names: 17 | click.echo(' '.join(names)) 18 | else: 19 | click.echo('Not using any versions', err=True) 20 | return 21 | 22 | # Remove duplicate inputs (keep first apperance). 23 | versions = list(dict((v.name, v) for v in versions).values()) 24 | 25 | for version in versions: 26 | check_installation(version) 27 | 28 | # Add new versions to the back of existing versions. 29 | used_versions = [Version.parse(name) for name in used_names] 30 | if add: 31 | new_versions = [] 32 | for v in versions: 33 | if v in used_versions: 34 | click.echo('Already using {}'.format(v), err=True) 35 | else: 36 | new_versions.append(v) 37 | versions = used_versions + new_versions 38 | 39 | if used_versions == versions: 40 | click.echo('No version changes', err=True) 41 | return 42 | if versions: 43 | click.echo('Using: {}'.format(', '.join(v.name for v in versions))) 44 | elif not add: 45 | click.echo('Not using any versions') 46 | else: 47 | click.echo('No active versions', err=True) 48 | click.get_current_context().exit(1) 49 | 50 | use_versions(versions) 51 | -------------------------------------------------------------------------------- /pythonup/__main__.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | class PythonUpGroup(click.Group): 5 | """Force command name to show 'pythonup'. 6 | """ 7 | def make_context(self, info_name, *args, **kwargs): 8 | return super().make_context('pythonup', *args, **kwargs) 9 | 10 | 11 | @click.group(cls=PythonUpGroup, invoke_without_command=True) 12 | @click.option('--version', is_flag=True, help='Print version and exit.') 13 | @click.pass_context 14 | def cli(ctx, version): 15 | if ctx.invoked_subcommand is None: 16 | if version: 17 | from . import __version__ 18 | click.echo('PythonUp (POSIX) {}'.format(__version__)) 19 | else: 20 | click.echo(ctx.get_help(), color=ctx.color) 21 | ctx.exit(1) 22 | 23 | 24 | @cli.command(help='Install a Python version.') 25 | @click.argument('version') 26 | @click.option('--use', is_flag=True, help='Use version after installation.') 27 | def install(**kwargs): 28 | from .operations.install import install 29 | install(**kwargs) 30 | 31 | 32 | @cli.command(help='Uninstall a Python version.') 33 | @click.argument('version') 34 | def uninstall(**kwargs): 35 | from .operations.install import uninstall 36 | uninstall(**kwargs) 37 | 38 | 39 | @cli.command(help='Upgrade an installed Python version.') 40 | @click.argument('version') 41 | def upgrade(**kwargs): 42 | from .operations.install import upgrade 43 | upgrade(**kwargs) 44 | 45 | 46 | @cli.command(help='Set active Python versions.') 47 | @click.argument('version', nargs=-1) 48 | @click.option( 49 | '--add/--reset', default=None, help='Add version to use without removing.', 50 | ) 51 | def use(**kwargs): 52 | from .operations.use import use 53 | use(**kwargs) 54 | 55 | 56 | @cli.command( 57 | help='Prints where the executable of the given Python version is.', 58 | short_help='Print python executable location.', 59 | ) 60 | @click.argument('version') 61 | def where(**kwargs): 62 | from .operations.versions import where 63 | where(**kwargs) 64 | 65 | 66 | @cli.command(name='list', help='List Python versions.') 67 | @click.option( 68 | '--all', 'list_all', is_flag=True, 69 | help='List all versions (instead of only installed ones).', 70 | ) 71 | def list_(**kwargs): 72 | from .operations.versions import list_ 73 | list_(**kwargs) 74 | 75 | 76 | if __name__ == '__main__': 77 | cli() 78 | -------------------------------------------------------------------------------- /pythonup/operations/install.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | import click 4 | import packaging.version 5 | 6 | from .. import installations, versions 7 | 8 | from .common import check_installation, version_command 9 | from .link import link_commands, unlink_commands, use_versions 10 | 11 | 12 | def has_any_installations(): 13 | for version in versions.iter_versions(): 14 | try: 15 | version.find_installation() 16 | except installations.InstallationNotFoundError: 17 | continue 18 | else: 19 | return True 20 | return False 21 | 22 | 23 | @version_command() 24 | def install(version, use): 25 | check_installation( 26 | version, expect=False, 27 | on_exit=functools.partial(link_commands, version), 28 | ) 29 | 30 | if not use and not has_any_installations(): 31 | use = True 32 | click.echo('Will use {} after installation'.format(version)) 33 | 34 | version.install() 35 | link_commands(version) 36 | if use: 37 | use_versions([version]) 38 | 39 | 40 | @version_command() 41 | def uninstall(version): 42 | check_installation( 43 | version, expect=True, 44 | on_exit=functools.partial(unlink_commands, version), 45 | ) 46 | click.echo(f'Uninstalling {version}...') 47 | unlink_commands(version) 48 | removed_path = version.uninstall() 49 | click.echo(f'Removed {version} from {removed_path}') 50 | 51 | 52 | @version_command() 53 | def upgrade(version): 54 | installation = check_installation( 55 | version, expect=True, 56 | on_exit=functools.partial(link_commands, version), 57 | ) 58 | try: 59 | curr_build = packaging.version.Version(installation.get_build_name()) 60 | except installation.InvalidBuildError: 61 | click.echo(f'Unrecognized build at {installation.root}', err=True) 62 | click.get_current_context().exit(1) 63 | best_build = packaging.version.Version(version.find_best_build_name()) 64 | if curr_build == best_build: 65 | click.echo(f'{version} is up to date ({curr_build})') 66 | elif curr_build > best_build: 67 | click.echo(f'{version} is up to date ({curr_build} > {best_build})') 68 | else: 69 | click.echo(f'Upgrading {version} from {curr_build} to {best_build}...') 70 | version.install(build_name=str(best_build)) 71 | link_commands(version) 72 | -------------------------------------------------------------------------------- /pythonup/versions.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import os 3 | import re 4 | import shutil 5 | import subprocess 6 | import sys 7 | 8 | import packaging.version 9 | 10 | from . import installations, paths 11 | 12 | 13 | class VersionNotFoundError(ValueError): 14 | pass 15 | 16 | 17 | def iter_installable_matches(): 18 | """Iterate through CPython versions available for PythonUp to install. 19 | """ 20 | output = subprocess.check_output( 21 | ['python-build', '--definitions'], encoding='ascii', 22 | ) 23 | for name in output.splitlines(): 24 | match = re.match(r'^(\d+\.\d+)\.\d+$', name) 25 | if match: 26 | yield match 27 | 28 | 29 | @dataclasses.dataclass(order=True, frozen=True) 30 | class Version: 31 | 32 | major: int 33 | minor: int 34 | 35 | @classmethod 36 | def parse(cls, name): 37 | match = re.match(r'^(?P\d+)\.(?P\d+)$', name) 38 | if not match: 39 | raise VersionNotFoundError(name) 40 | return cls( 41 | major=int(match.group('major')), 42 | minor=int(match.group('minor')), 43 | ) 44 | 45 | def __str__(self): 46 | return self.name 47 | 48 | @property 49 | def name(self): 50 | return f'{self.major}.{self.minor}' 51 | 52 | @property 53 | def python_commands(self): 54 | return [paths.get_cmd_dir().joinpath(f'python{self.name}')] 55 | 56 | @property 57 | def pip_commands(self): 58 | return [paths.get_cmd_dir().joinpath(f'pip{self.name}')] 59 | 60 | def iter_matched_build_name(self): 61 | """Iterate through CPython version names matching this version. 62 | """ 63 | for match in iter_installable_matches(): 64 | if match.group(1) == self.name: 65 | yield match.group(0) 66 | 67 | def find_best_build_name(self): 68 | return max( 69 | self.iter_matched_build_name(), 70 | key=packaging.version.Version, 71 | ) 72 | 73 | def install(self, *, build_name=None): 74 | if build_name is None: 75 | build_name = self.find_best_build_name() 76 | installation = self.find_installation(strict=False) 77 | env = os.environ.copy() 78 | if sys.platform == 'darwin': 79 | opts = env.get('PYTHON_CONFIGURE_OPTS', '').split() 80 | opts.append('--enable-framework') 81 | env['PYTHON_CONFIGURE_OPTS'] = ' '.join(opts) 82 | subprocess.check_call( 83 | ['python-build', build_name, str(installation.root)], 84 | env=env, 85 | ) 86 | return installation 87 | 88 | def uninstall(self): 89 | root = self.find_installation().root 90 | shutil.rmtree(root) 91 | return root 92 | 93 | def find_installation(self, *, strict=True): 94 | return installations.Installation.find(self, strict=strict) 95 | 96 | 97 | def iter_versions(): 98 | exist_names = set() 99 | for match in iter_installable_matches(): 100 | name = match.group(1) 101 | if name not in exist_names: 102 | exist_names.add(name) 103 | yield Version.parse(name) 104 | -------------------------------------------------------------------------------- /pythonup/operations/link.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from .. import conf, paths 4 | 5 | 6 | def safe_link(source, target): 7 | if target.exists(): 8 | if source.samefile(target): 9 | return False 10 | target.unlink() 11 | target.symlink_to(source) 12 | return True 13 | 14 | 15 | def safe_unlink(target): 16 | if target.exists(): 17 | target.unlink() 18 | 19 | 20 | def link_commands(version): 21 | installation = version.find_installation() 22 | for target in version.python_commands: 23 | safe_link(installation.python, target) 24 | for target in version.pip_commands: # TODO: These should be shimmed. 25 | safe_link(installation.pip, target) 26 | 27 | 28 | def unlink_commands(version): 29 | for target in version.python_commands: 30 | safe_unlink(target) 31 | for target in version.pip_commands: 32 | safe_unlink(target) 33 | 34 | 35 | def collect_link_sources(versions): 36 | link_sources = {} 37 | shim_sources = {} 38 | for version in versions: 39 | installation = version.find_installation() 40 | blacklisted_names = { 41 | # Encourage people to always use qualified commands. 42 | 'python', 'easy_install', 'pip', 43 | # Fully qualified names are already populated on installation. 44 | 'python{}'.format(version.name), 45 | 'pip{}'.format(version.name), 46 | # Config commands could confuse ./configure scripts. 47 | # Tools like Homebrew don't like them. 48 | 'python-config', 49 | 'python{}-config'.format(version.major), 50 | 'python{}-config'.format(version.name), 51 | 'python{}m-config'.format(version.name), 52 | } 53 | shimmed_names = { 54 | # Major version names, e.g. "pip3". 55 | 'pip{}'.format(version.major), 56 | # Fully-qualified easy_install. 57 | 'easy_install-{}'.format(version.name), 58 | } 59 | for path in installation.root.joinpath('bin').iterdir(): 60 | if path.name in blacklisted_names: 61 | continue 62 | if path.name in shimmed_names: 63 | if path.name not in shim_sources: 64 | shim_sources[path.name] = path 65 | else: 66 | if path.name not in link_sources: 67 | link_sources[path.name] = path 68 | return link_sources, shim_sources 69 | 70 | 71 | def use_versions(versions): 72 | link_sources, shim_sources = collect_link_sources(versions) 73 | bindir = paths.get_bin_dir() 74 | 75 | # TODO: Only show this if there really are things to link. 76 | # We will need to calculate samefiles for this to happen. 77 | if link_sources or shim_sources: 78 | click.echo('Publishing executables...') 79 | 80 | for name, source in sorted(link_sources.items()): 81 | if safe_link(source, bindir.joinpath(name)): 82 | click.echo(f' {name}') 83 | 84 | # TODO: Shim these instead. 85 | for name, source in sorted(shim_sources.items()): 86 | if safe_link(source, bindir.joinpath(name)): 87 | click.echo(f' {name}') 88 | 89 | conf.settings['using'] = [v.name for v in versions] 90 | 91 | stale_targets = set( 92 | path for path in bindir.iterdir() 93 | if path.name not in link_sources and path.name not in shim_sources 94 | ) 95 | if stale_targets: 96 | click.echo('Cleaning stale executables...') 97 | for path in stale_targets: 98 | safe_unlink(path) 99 | click.echo(f' {path.name}') 100 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============================================= 2 | PythonUp — The Python Runtime Manager (POSIX) 3 | ============================================= 4 | 5 | PythonUp helps your install and manage Python runtimes on your computer. This 6 | is the POSIX version. 7 | 8 | .. highlights:: 9 | 10 | Windows user? Check out `PythonUp-Windows`_. 11 | 12 | .. _`PythonUp-Windows`: https://github.com/uranusjr/pythonup-windows 13 | 14 | .. highlights:: 15 | This is a work in progress. You are welcomed to try it, and I’ll try to 16 | resolve any problems you have using it. I’m still experimenting much of the 17 | internals, however, so anything can break when you upgrade without 18 | backward-compatibility in mind. 19 | 20 | 21 | Distribution 22 | ============ 23 | 24 | 1. Clone the repository somethere. 25 | 2. Create a Python environment for the project. 26 | 3. Create a shim to run `pythonup` withe the environment. 27 | 4. Add locations PythonUp installs scripts into to your PATH. 28 | 29 | This is the steps I use:: 30 | 31 | mkdir -p ~/.local/libexec/pythonup-posix 32 | cd ~/.local/libexec/pythonup-posix 33 | git clone https://github.com/uranusjr/pythonup-posix.git repo 34 | python3.6 -m venv --prompt=pythonup-posix venv 35 | ./venv/bin/python -m pip install --upgrade setuptools pip 36 | ./venv/bin/python -m pip install click dataclasses packaging 37 | ln -s $PWD/repo/pythonup ./venv/lib/python3.6/site-packages 38 | 39 | Shim to invoke PythonUp:: 40 | 41 | #!/bin/sh 42 | exec $HOME/.local/libexec/pythonup-posix/venv/bin/python -m pythonup $* 43 | 44 | Aside from usual Python dependencies, PythonUp also requires 45 | 46 | 1. `python-build` from pyenv_. You don’t need to install pyenv, only the 47 | ``python-build`` command. Clone the repository, and ``ln -s`` the command 48 | (in ``pyenv/plugins/python-build/bin``) into your ``PATH``. 49 | 50 | 2. Build dependencies for Python. pyenv maintains lists for common package 51 | managers: https://github.com/pyenv/pyenv/wiki#suggested-build-environment 52 | 53 | .. _pyenv: https://github.com/pyenv/pyenv 54 | 55 | 56 | Quick Start 57 | =========== 58 | 59 | Install Python 3.6:: 60 | 61 | $ pythonup install 3.6 62 | 63 | Run Python:: 64 | 65 | $ python3 66 | 67 | Install Pipenv to Python 3.6:: 68 | 69 | $ pip3.6 install pipenv 70 | 71 | And use it immediately (DOES NOT WORK YET, see TODO below):: 72 | 73 | $ pipenv --version 74 | pipenv, version 9.0.1 75 | 76 | Install Python 2.7:: 77 | 78 | $ pythonup install 3.5-32 79 | 80 | Switch to a specific version:: 81 | 82 | $ pythonup use 3.5 83 | $ python3 --version 84 | Python 3.5.4 85 | 86 | Switch back to 3.6:: 87 | 88 | $ pythonup use 3.6 89 | $ python3 --version 90 | Python 3.6.4 91 | $ python3.5 --version 92 | Python 3.5.4 93 | 94 | Uninstall Python:: 95 | 96 | $ pythonup uninstall 3.5 97 | 98 | Use ``--help`` to find more:: 99 | 100 | $ pythonup --help 101 | $ pythonup install --help 102 | 103 | 104 | Internals 105 | ========= 106 | 107 | PythonUp uses pyenv’s ``python-build`` command to build the best match, and 108 | install it into ``$HOME/Library/PythonUp/versions/X.Y``. Unlike pyenv, PythonUp 109 | only lets you specify X.Y, not the micro part, so you can upgrade within a 110 | minor version without breaking all your existing virtual environments. 111 | 112 | 113 | Todo 114 | ==== 115 | 116 | Shims 117 | ----- 118 | 119 | Similar to pyenv (and PythonUp on Windows), ``pip`` and ``easy_install`` 120 | commands should be shimmed to allow auto-publishing hooks after you install a 121 | package. Unlike the Windows implementation, some simple shell scripts will 122 | suffice, fortunately. The script will be generated dynamically, when the user 123 | ``use`` versions, to point to the correct version. 124 | 125 | 126 | Bundle python-build 127 | ------------------- 128 | 129 | There are several disadvantages depending on Homebrew’s pyenv: 130 | 131 | * pyenv does not release a new version to add a new Python definition. 132 | * Homebrew does not always update the pyenv formula when pyenv releases. 133 | 134 | Python 3.6.4, for example, was released on 2017-12-19. The python-build 135 | definition landed a few hours later, but is still not available as a versioned 136 | release (as of 2018-01-05). Judging from recent release patterns, availability 137 | of new Python versions can be delayed to up to one month after their official 138 | distribution. 139 | 140 | I’m personally working around this by using the ``HEAD`` version of pyenv ( 141 | ``brew install --HEAD pyenv``), but this is not a good long-term solution. It 142 | would be better to vendor python-build (maybe as a Git subtree), and update 143 | when user queries Python versions (e.g. with ``install`` and ``list``). 144 | 145 | Another benefit of vendoring is that we don’t need the ``python-build`` command 146 | to be globally available. 147 | 148 | 149 | Explain things 150 | -------------- 151 | 152 | Obvious question: Why not just use pyenv? Because you always want to use the 153 | latest micro of a Python version, but pyenv doesn’t let you do that easily 154 | without breaking all your virtual environments and globally installed tools. 155 | Also the shims are a terrible idea. 156 | 157 | 158 | Tests 159 | ----- 160 | 161 | I always say this, but all my projects are under-tested. Hashtag help-wanted. 162 | 163 | 164 | Documentation 165 | ------------- 166 | 167 | It *might* be a good idea to unify the documentation? It makes sense from a 168 | user’s perspective because the interfaces are almost identical. The 169 | implementation and all underlying parts are different though. This would 170 | require some very careful planning. 171 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "880ee831371061df936de2ce8dafced4616c155690160e12f78e8c74aee37888" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.6" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "click": { 20 | "hashes": [ 21 | "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", 22 | "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" 23 | ], 24 | "index": "pypi", 25 | "version": "==6.7" 26 | }, 27 | "dataclasses": { 28 | "hashes": [ 29 | "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f", 30 | "sha256:6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84" 31 | ], 32 | "index": "pypi", 33 | "version": "==0.6" 34 | }, 35 | "packaging": { 36 | "hashes": [ 37 | "sha256:e9215d2d2535d3ae866c3d6efc77d5b24a0192cce0ff20e42896cc0664f889c0", 38 | "sha256:f019b770dd64e585a99714f1fd5e01c7a8f11b45635aa953fd41c689a657375b" 39 | ], 40 | "index": "pypi", 41 | "version": "==17.1" 42 | }, 43 | "pyparsing": { 44 | "hashes": [ 45 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 46 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 47 | ], 48 | "version": "==2.4.7" 49 | }, 50 | "six": { 51 | "hashes": [ 52 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 53 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 54 | ], 55 | "version": "==1.15.0" 56 | } 57 | }, 58 | "develop": { 59 | "atomicwrites": { 60 | "hashes": [ 61 | "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197", 62 | "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a" 63 | ], 64 | "version": "==1.4.0" 65 | }, 66 | "attrs": { 67 | "hashes": [ 68 | "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", 69 | "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" 70 | ], 71 | "version": "==20.3.0" 72 | }, 73 | "backcall": { 74 | "hashes": [ 75 | "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e", 76 | "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255" 77 | ], 78 | "version": "==0.2.0" 79 | }, 80 | "contoml": { 81 | "hashes": [ 82 | "sha256:2353275caef3726131c4192379252cc48eb4a15c06df3e1046f783de937eba94", 83 | "sha256:f62960b57a9489187653787bd67756d15a79e4579c7d779d8b94f581dd260a7d" 84 | ], 85 | "version": "==0.32" 86 | }, 87 | "decorator": { 88 | "hashes": [ 89 | "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760", 90 | "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7" 91 | ], 92 | "version": "==4.4.2" 93 | }, 94 | "distlib": { 95 | "hashes": [ 96 | "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb", 97 | "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1" 98 | ], 99 | "version": "==0.3.1" 100 | }, 101 | "first": { 102 | "hashes": [ 103 | "sha256:8d8e46e115ea8ac652c76123c0865e3ff18372aef6f03c22809ceefcea9dec86", 104 | "sha256:ff285b08c55f8c97ce4ea7012743af2495c9f1291785f163722bd36f6af6d3bf" 105 | ], 106 | "version": "==2.0.2" 107 | }, 108 | "ipython": { 109 | "hashes": [ 110 | "sha256:a0c96853549b246991046f32d19db7140f5b1a644cc31f0dc1edc86713b7676f", 111 | "sha256:eca537aa61592aca2fef4adea12af8e42f5c335004dfa80c78caf80e8b525e5c" 112 | ], 113 | "index": "pypi", 114 | "version": "==6.4.0" 115 | }, 116 | "ipython-genutils": { 117 | "hashes": [ 118 | "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8", 119 | "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8" 120 | ], 121 | "version": "==0.2.0" 122 | }, 123 | "iso8601": { 124 | "hashes": [ 125 | "sha256:8aafd56fa0290496c5edbb13c311f78fa3a241f0853540da09d9363eae3ebd79", 126 | "sha256:e7e1122f064d626e17d47cd5106bed2c620cb38fe464999e0ddae2b6d2de6004" 127 | ], 128 | "version": "==0.1.14" 129 | }, 130 | "jedi": { 131 | "hashes": [ 132 | "sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93", 133 | "sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707" 134 | ], 135 | "version": "==0.18.0" 136 | }, 137 | "more-itertools": { 138 | "hashes": [ 139 | "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced", 140 | "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713" 141 | ], 142 | "version": "==8.7.0" 143 | }, 144 | "packaging": { 145 | "hashes": [ 146 | "sha256:e9215d2d2535d3ae866c3d6efc77d5b24a0192cce0ff20e42896cc0664f889c0", 147 | "sha256:f019b770dd64e585a99714f1fd5e01c7a8f11b45635aa953fd41c689a657375b" 148 | ], 149 | "index": "pypi", 150 | "version": "==17.1" 151 | }, 152 | "parso": { 153 | "hashes": [ 154 | "sha256:15b00182f472319383252c18d5913b69269590616c947747bc50bf4ac768f410", 155 | "sha256:8519430ad07087d4c997fda3a7918f7cfa27cb58972a8c89c2a0295a1c940e9e" 156 | ], 157 | "version": "==0.8.1" 158 | }, 159 | "pexpect": { 160 | "hashes": [ 161 | "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937", 162 | "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c" 163 | ], 164 | "markers": "sys_platform != 'win32'", 165 | "version": "==4.8.0" 166 | }, 167 | "pickleshare": { 168 | "hashes": [ 169 | "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca", 170 | "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56" 171 | ], 172 | "version": "==0.7.5" 173 | }, 174 | "pluggy": { 175 | "hashes": [ 176 | "sha256:7f8ae7f5bdf75671a718d2daf0a64b7885f74510bcd98b1a0bb420eb9a9d0cff", 177 | "sha256:d345c8fe681115900d6da8d048ba67c25df42973bda370783cd58826442dcd7c", 178 | "sha256:e160a7fcf25762bb60efc7e171d4497ff1d8d2d75a3d0df7a21b76821ecbf5c5" 179 | ], 180 | "version": "==0.6.0" 181 | }, 182 | "prompt-toolkit": { 183 | "hashes": [ 184 | "sha256:37925b37a4af1f6448c76b7606e0285f79f434ad246dda007a27411cca730c6d", 185 | "sha256:dd4fca02c8069497ad931a2d09914c6b0d1b50151ce876bc15bde4c747090126", 186 | "sha256:f7eec66105baf40eda9ab026cd8b2e251337eea8d111196695d82e0c5f0af852" 187 | ], 188 | "version": "==1.0.18" 189 | }, 190 | "ptyprocess": { 191 | "hashes": [ 192 | "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", 193 | "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" 194 | ], 195 | "version": "==0.7.0" 196 | }, 197 | "py": { 198 | "hashes": [ 199 | "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3", 200 | "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a" 201 | ], 202 | "version": "==1.10.0" 203 | }, 204 | "pygments": { 205 | "hashes": [ 206 | "sha256:bc9591213a8f0e0ca1a5e68a479b4887fdc3e75d0774e5c71c31920c427de435", 207 | "sha256:df49d09b498e83c1a73128295860250b0b7edd4c723a32e9bc0d295c7c2ec337" 208 | ], 209 | "index": "pypi", 210 | "version": "==2.7.4" 211 | }, 212 | "pyparsing": { 213 | "hashes": [ 214 | "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", 215 | "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" 216 | ], 217 | "version": "==2.4.7" 218 | }, 219 | "pytest": { 220 | "hashes": [ 221 | "sha256:0453c8676c2bee6feb0434748b068d5510273a916295fd61d306c4f22fbfd752", 222 | "sha256:4b208614ae6d98195430ad6bde03641c78553acee7c83cec2e85d613c0cd383d" 223 | ], 224 | "index": "pypi", 225 | "version": "==3.6.3" 226 | }, 227 | "pytz": { 228 | "hashes": [ 229 | "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da", 230 | "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798" 231 | ], 232 | "version": "==2021.1" 233 | }, 234 | "requirements-parser": { 235 | "hashes": [ 236 | "sha256:5963ee895c2d05ae9f58d3fc641082fb38021618979d6a152b6b1398bd7d4ed4", 237 | "sha256:76650b4a9d98fc65edf008a7920c076bb2a76c08eaae230ce4cfc6f51ea6a773" 238 | ], 239 | "version": "==0.2.0" 240 | }, 241 | "requirementslib": { 242 | "hashes": [ 243 | "sha256:0e6be312ec49c33ddf37af42f72d2c5217afe64ded8ae25d41b8fddfb70e4e8e", 244 | "sha256:547338f85a0cd6a3834795b9699119cccdeafde512ad6162a84e3fab3612e8f8" 245 | ], 246 | "index": "pypi", 247 | "version": "==1.0.9" 248 | }, 249 | "simplegeneric": { 250 | "hashes": [ 251 | "sha256:dc972e06094b9af5b855b3df4a646395e43d1c9d0d39ed345b7393560d0b9173" 252 | ], 253 | "version": "==0.8.1" 254 | }, 255 | "six": { 256 | "hashes": [ 257 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 258 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 259 | ], 260 | "version": "==1.15.0" 261 | }, 262 | "strict-rfc3339": { 263 | "hashes": [ 264 | "sha256:5cad17bedfc3af57b399db0fed32771f18fc54bbd917e85546088607ac5e1277" 265 | ], 266 | "version": "==0.7" 267 | }, 268 | "timestamp": { 269 | "hashes": [ 270 | "sha256:b5c7a4539f0d8742b7d6c78febd241ab24903c73c38045690725220ca7eae4ac" 271 | ], 272 | "version": "==0.0.1" 273 | }, 274 | "toml": { 275 | "hashes": [ 276 | "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", 277 | "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" 278 | ], 279 | "version": "==0.10.2" 280 | }, 281 | "traitlets": { 282 | "hashes": [ 283 | "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44", 284 | "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7" 285 | ], 286 | "version": "==4.3.3" 287 | }, 288 | "wcwidth": { 289 | "hashes": [ 290 | "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784", 291 | "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83" 292 | ], 293 | "version": "==0.2.5" 294 | } 295 | } 296 | } 297 | --------------------------------------------------------------------------------