1
0
mirror of https://github.com/httpie/cli.git synced 2025-08-10 22:42:05 +02:00

Single binary executables (#1330)

* Single binary executables / DEB packages.

* Attach single binary executables to the releases
This commit is contained in:
Batuhan Taskaya
2022-04-14 18:11:12 +03:00
committed by GitHub
parent 278dfc487d
commit dd2c9513f3
26 changed files with 510 additions and 179 deletions

View File

@@ -12,7 +12,10 @@ cookiejar.DefaultCookiePolicy = HTTPieCookiePolicy
is_windows = 'win32' in str(sys.platform).lower()
is_frozen = getattr(sys, 'frozen', False)
MIN_SUPPORTED_PY_VERSION = (3, 7)
MAX_SUPPORTED_PY_VERSION = (3, 11)
try:
from functools import cached_property

69
httpie/manager/compat.py Normal file
View File

@@ -0,0 +1,69 @@
import sys
import shutil
import subprocess
from contextlib import suppress
from typing import List, Optional
from httpie.compat import is_frozen
class PipError(Exception):
"""An exception that occurs when pip exits with an error status code."""
def __init__(self, stdout, stderr):
self.stdout = stdout
self.stderr = stderr
def _discover_system_pip() -> List[str]:
# When we are running inside of a frozen binary, we need the system
# pip to install plugins since there is no way for us to execute any
# code outside of the HTTPie.
#
# We explicitly depend on system pip, so the SystemError should not
# be executed (except for broken installations).
def _check_pip_version(pip_location: Optional[str]) -> bool:
if not pip_location:
return False
with suppress(subprocess.CalledProcessError):
stdout = subprocess.check_output([pip_location, "--version"], text=True)
return "python 3" in stdout
targets = [
"pip",
"pip3"
]
for target in targets:
pip_location = shutil.which(target)
if _check_pip_version(pip_location):
return pip_location
raise SystemError("Couldn't find 'pip' executable. Please ensure that pip in your system is available.")
def _run_pip_subprocess(pip_executable: List[str], args: List[str]) -> bytes:
import subprocess
cmd = [*pip_executable, *args]
try:
process = subprocess.run(
cmd,
check=True,
shell=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
except subprocess.CalledProcessError as error:
raise PipError(error.stdout, error.stderr) from error
else:
return process.stdout
def run_pip(args: List[str]) -> bytes:
if is_frozen:
pip_executable = [_discover_system_pip()]
else:
pip_executable = [sys.executable, '-m', 'pip']
return _run_pip_subprocess(pip_executable, args)

View File

@@ -1,20 +1,19 @@
import argparse
import os
import textwrap
import re
import shutil
import subprocess
import sys
import textwrap
from collections import defaultdict
from contextlib import suppress
from pathlib import Path
from typing import List, Optional, Tuple
from httpie.manager.compat import PipError, run_pip
from httpie.manager.cli import parser, missing_subcommand
from httpie.compat import get_dist_name, importlib_metadata
from httpie.context import Environment
from httpie.manager.cli import missing_subcommand, parser
from httpie.status import ExitStatus
from httpie.utils import as_site
from httpie.utils import get_site_paths
PEP_503 = re.compile(r"[-_.]+")
@@ -58,46 +57,37 @@ class PluginInstaller:
self.env.stderr.write(message + '\n')
return ExitStatus.ERROR
def pip(self, *args, **kwargs) -> subprocess.CompletedProcess:
options = {
'check': True,
'shell': False,
'stdout': self.env.stdout,
'stderr': subprocess.PIPE,
}
options.update(kwargs)
cmd = [sys.executable, '-m', 'pip', *args]
return subprocess.run(
cmd,
**options
)
def _install(self, targets: List[str], mode='install', **process_options) -> Tuple[
Optional[bytes], ExitStatus
def _install(self, targets: List[str], mode='install') -> Tuple[
bytes, ExitStatus
]:
pip_args = [
'install',
'--prefer-binary',
f'--prefix={self.dir}',
'--no-warn-script-location',
]
if mode == 'upgrade':
pip_args.append('--upgrade')
pip_args.extend(targets)
try:
process = self.pip(
*pip_args,
*targets,
**process_options,
)
except subprocess.CalledProcessError as error:
stdout = run_pip(pip_args)
except PipError as pip_error:
error = pip_error
stdout = pip_error.stdout
else:
error = None
self.env.stdout.write(stdout.decode())
if error:
reason = None
if error.stderr:
stderr = error.stderr.decode()
if self.debug:
self.env.stderr.write('Command failed: ')
self.env.stderr.write(' '.join(error.cmd) + '\n')
self.env.stderr.write('pip ' + ' '.join(pip_args) + '\n')
self.env.stderr.write(textwrap.indent(' ', stderr))
last_line = stderr.strip().splitlines()[-1]
@@ -108,7 +98,6 @@ class PluginInstaller:
stdout = error.stdout
exit_status = self.fail(mode, ', '.join(targets), reason)
else:
stdout = process.stdout
exit_status = ExitStatus.SUCCESS
return stdout, exit_status
@@ -124,10 +113,11 @@ class PluginInstaller:
# existing metadata for old versions manually.
# [0]: https://github.com/pypa/pip/issues/10727
result_deps = defaultdict(list)
for child in as_site(self.dir).iterdir():
if child.suffix in {'.dist-info', '.egg-info'}:
name, _, version = child.stem.rpartition('-')
result_deps[name].append((version, child))
for site_dir in get_site_paths(self.dir):
for child in site_dir.iterdir():
if child.suffix in {'.dist-info', '.egg-info'}:
name, _, version = child.stem.rpartition('-')
result_deps[name].append((version, child))
for target in targets:
name, _, version = target.rpartition('-')
@@ -145,15 +135,12 @@ class PluginInstaller:
raw_stdout, exit_status = self._install(
targets,
mode='upgrade',
stdout=subprocess.PIPE
mode='upgrade'
)
if not raw_stdout:
return exit_status
stdout = raw_stdout.decode()
self.env.stdout.write(stdout)
installation_line = stdout.splitlines()[-1]
if installation_line.startswith('Successfully installed'):
self._clear_metadata(installation_line.split()[2:])

View File

@@ -4,13 +4,13 @@ import warnings
from itertools import groupby
from operator import attrgetter
from typing import Dict, List, Type, Iterator, Optional, ContextManager
from typing import Dict, List, Type, Iterator, Iterable, Optional, ContextManager
from pathlib import Path
from contextlib import contextmanager, nullcontext
from ..compat import importlib_metadata, find_entry_points, get_dist_name
from ..utils import repr_dict, as_site
from ..utils import repr_dict, get_site_paths
from . import AuthPlugin, ConverterPlugin, FormatterPlugin, TransportPlugin
from .base import BasePlugin
@@ -25,20 +25,24 @@ ENTRY_POINT_NAMES = list(ENTRY_POINT_CLASSES.keys())
@contextmanager
def _load_directory(plugins_dir: Path) -> Iterator[None]:
plugins_path = os.fspath(plugins_dir)
sys.path.insert(0, plugins_path)
def _load_directories(site_dirs: Iterable[Path]) -> Iterator[None]:
plugin_dirs = [
os.fspath(site_dir)
for site_dir in site_dirs
]
sys.path.extend(plugin_dirs)
try:
yield
finally:
sys.path.remove(plugins_path)
for plugin_dir in plugin_dirs:
sys.path.remove(plugin_dir)
def enable_plugins(plugins_dir: Optional[Path]) -> ContextManager[None]:
if plugins_dir is None:
return nullcontext()
else:
return _load_directory(as_site(plugins_dir))
return _load_directories(get_site_paths(plugins_dir))
class PluginManager(list):

View File

@@ -214,14 +214,33 @@ def parse_content_type_header(header):
return content_type, params_dict
def as_site(path: Path) -> Path:
def as_site(path: Path, **extra_vars) -> Path:
site_packages_path = sysconfig.get_path(
'purelib',
vars={'base': str(path)}
vars={'base': str(path), **extra_vars}
)
return Path(site_packages_path)
def get_site_paths(path: Path) -> Iterable[Path]:
from httpie.compat import (
MIN_SUPPORTED_PY_VERSION,
MAX_SUPPORTED_PY_VERSION,
is_frozen
)
if is_frozen:
[major, min_minor] = MIN_SUPPORTED_PY_VERSION
[major, max_minor] = MAX_SUPPORTED_PY_VERSION
for minor in range(min_minor, max_minor + 1):
yield as_site(
path,
py_version_short=f'{major}.{minor}'
)
else:
yield as_site(path)
def split(iterable: Iterable[T], key: Callable[[T], bool]) -> Tuple[List[T], List[T]]:
left, right = [], []
for item in iterable: