Skip to content

Commit

Permalink
Add PEX_SCRIPT, -c/--script/--console-script support. Fixes #59.
Browse files Browse the repository at this point in the history
* Adds ``-m`` and ``--entry-point`` alias to the existing ``-e`` option for entry points in
  the pex tool to evoke the similarity to ``python -m``.

* Adds console script support via ``-c/--script/--console-script`` and ``PEX_SCRIPT``.  This allows
  you to reference the named entry point instead of the exact ``module:name`` pair.  Also supports
  scripts defined in the ``scripts`` section of setup.py.
  `#59 <https://github.com/pantsbuild/pex/issues/59>`_.
  • Loading branch information
wickman committed Apr 14, 2015
1 parent ed59d23 commit fe895b2
Show file tree
Hide file tree
Showing 10 changed files with 315 additions and 112 deletions.
12 changes: 12 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@
CHANGES
=======

----------
1.0.0.dev1
----------

* Adds ``-m`` and ``--entry-point`` alias to the existing ``-e`` option for entry points in
the pex tool to evoke the similarity to ``python -m``.

* Adds console script support via ``-c/--script/--console-script`` and ``PEX_SCRIPT``. This allows
you to reference the named entry point instead of the exact ``module:name`` pair. Also supports
scripts defined in the ``scripts`` section of setup.py.
`#59 <https://github.com/pantsbuild/pex/issues/59>`_.

----------
1.0.0.dev0
----------
Expand Down
62 changes: 40 additions & 22 deletions pex/bin/pex.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from pex.archiver import Archiver
from pex.base import maybe_requirement
from pex.common import safe_delete, safe_mkdir, safe_mkdtemp
from pex.common import die, safe_delete, safe_mkdir, safe_mkdtemp
from pex.crawler import Crawler
from pex.fetcher import Fetcher, PyPIFetcher
from pex.http import Context
Expand All @@ -32,15 +32,12 @@
from pex.resolver import CachingResolver, Resolver
from pex.resolver_options import ResolverOptionsBuilder
from pex.tracer import TRACER, TraceLogger
from pex.version import __setuptools_requirement, __version__, __wheel_requirement
from pex.version import SETUPTOOLS_REQUIREMENT, WHEEL_REQUIREMENT, __version__

CANNOT_DISTILL = 101
CANNOT_SETUP_INTERPRETER = 102


def die(msg, error_code=1):
print(msg, file=sys.stderr)
sys.exit(error_code)
INVALID_OPTIONS = 103
INVALID_ENTRY_POINT = 104


def log(msg, v=False):
Expand Down Expand Up @@ -247,6 +244,32 @@ def configure_clp_pex_environment(parser):
parser.add_option_group(group)


def configure_clp_pex_entry_points(parser):
group = OptionGroup(
parser,
'PEX entry point options',
'Specify what target/module the PEX should invoke if any.')

group.add_option(
'-m', '-e', '--entry-point',
dest='entry_point',
metavar='MODULE[:SYMBOL]',
default=None,
help='Set the entry point to module or module:symbol. If just specifying module, pex '
'behaves like python -m, e.g. python -m SimpleHTTPServer. If specifying '
'module:symbol, pex imports that symbol and invokes it as if it were main.')

group.add_option(
'-c', '--script', '--console-script',
dest='script',
default=None,
metavar='SCRIPT_NAME',
help='Set the entry point as to the script or console_script as defined by a any of the '
'distributions in the pex. For example: "pex -c fab fabric" or "pex -c mturk boto".')

parser.add_option_group(group)


def configure_clp():
usage = (
'%prog [-o OUTPUT.PEX] [options] [-- arg1 arg2 ...]\n\n'
Expand All @@ -258,6 +281,7 @@ def configure_clp():
configure_clp_pex_resolution(parser, resolver_options_builder)
configure_clp_pex_options(parser)
configure_clp_pex_environment(parser)
configure_clp_pex_entry_points(parser)

parser.add_option(
'-o', '--output-file',
Expand All @@ -266,14 +290,6 @@ def configure_clp():
help='The name of the generated .pex file: Omiting this will run PEX '
'immediately and not save it to a file.')

parser.add_option(
'-e', '--entry-point',
dest='entry_point',
default=None,
help='The entry point for this pex; Omiting this will enter the python '
'REPL with sources and requirements available for import. Can be '
'either a module or EntryPoint (module:function) format.')

parser.add_option(
'-r', '--requirement',
dest='requirement_files',
Expand Down Expand Up @@ -387,11 +403,11 @@ def interpreter_from_options(options):
resolve = functools.partial(resolve_interpreter, options.interpreter_cache_dir, options.repos)

# resolve setuptools
interpreter = resolve(interpreter, __setuptools_requirement)
interpreter = resolve(interpreter, SETUPTOOLS_REQUIREMENT)

# possibly resolve wheel
if interpreter and options.use_wheel:
interpreter = resolve(interpreter, __wheel_requirement)
interpreter = resolve(interpreter, WHEEL_REQUIREMENT)

return interpreter

Expand Down Expand Up @@ -440,11 +456,13 @@ def build_pex(args, options, resolver_option_builder):
pex_builder.add_distribution(dist)
pex_builder.add_requirement(dist.as_requirement())

if options.entry_point is not None:
log('Setting entry point to %s' % options.entry_point, v=options.verbosity)
pex_builder.info.entry_point = options.entry_point
else:
log('Creating environment PEX.', v=options.verbosity)
if options.entry_point and options.script:
die('Must specify at most one entry point or script.', INVALID_OPTIONS)

if options.entry_point:
pex_builder.set_entry_point(options.entry_point)
elif options.script:
pex_builder.set_script(options.script)

return pex_builder

Expand Down
5 changes: 5 additions & 0 deletions pex/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
from uuid import uuid4


def die(msg, exit_code=1):
print(msg, file=sys.stderr)
sys.exit(exit_code)


def safe_copy(source, dest, overwrite=False):
def do_copy():
temp_dest = dest + uuid4().hex
Expand Down
37 changes: 37 additions & 0 deletions pex/finders.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,40 @@ def unregister_finders():
_remove_finder(importlib_bootstrap.FileFinder, find_wheels_on_path)

__PREVIOUS_FINDER = None


def get_script_from_egg(name, dist):
"""Returns location, content of script in distribution or (None, None) if not there."""
if name in dist.metadata_listdir('scripts'):
return (
os.path.join(dist.egg_info, 'scripts', name),
dist.get_metadata('scripts/%s' % name).replace('\r\n', '\n').replace('\r', '\n'))
return None, None


def get_script_from_whl(name, dist):
# This is true as of at least wheel==0.24. Might need to take into account the
# metadata version bundled with the wheel.
wheel_scripts_dir = '%s-%s.data/scripts' % (dist.key, dist.version)
if dist.resource_isdir(wheel_scripts_dir) and name in dist.resource_listdir(wheel_scripts_dir):
script_path = os.path.join(wheel_scripts_dir, name)
return (
os.path.join(dist.egg_info, script_path),
dist.get_resource_string('', script_path).replace('\r\n', '\n').replace('\r', '\n'))
return None, None


def get_script_from_distribution(name, dist):
if isinstance(dist._provider, FixedEggMetadata):
return get_script_from_egg(name, dist)
elif isinstance(dist._provider, (WheelMetadata, pkg_resources.PathMetadata)):
return get_script_from_whl(name, dist)
return None, None


def get_script_from_distributions(name, dists):
for dist in dists:
script_path, script_content = get_script_from_distribution(name, dist)
if script_path:
return dist, script_path, script_content
return None, None, None
5 changes: 3 additions & 2 deletions pex/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from .common import safe_mkdtemp, safe_rmtree
from .interpreter import PythonInterpreter
from .tracer import TRACER
from .version import SETUPTOOLS_REQUIREMENT, WHEEL_REQUIREMENT

__all__ = (
'Installer',
Expand Down Expand Up @@ -232,8 +233,8 @@ class WheelInstaller(DistributionPackager):
Create a source distribution from an unpacked setup.py-based project.
"""
MIXINS = {
'setuptools': 'setuptools>=2',
'wheel': 'wheel>=0.17',
'setuptools': SETUPTOOLS_REQUIREMENT,
'wheel': WHEEL_REQUIREMENT,
}

def mixins(self):
Expand Down
112 changes: 70 additions & 42 deletions pex/pex.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
import pkg_resources
from pkg_resources import EntryPoint, find_distributions

from .common import safe_mkdir
from .common import die, safe_mkdir
from .compatibility import exec_function
from .environment import PEXEnvironment
from .finders import get_script_from_distributions
from .interpreter import PythonInterpreter
from .orderedset import OrderedSet
from .pex_info import PexInfo
Expand Down Expand Up @@ -59,27 +60,12 @@ def clean_environment(cls, forking=False):

def __init__(self, pex=sys.argv[0], interpreter=None):
self._pex = pex
self._pex_info = PexInfo.from_pex(self._pex)
self._env = PEXEnvironment(self._pex, self._pex_info)
self._interpreter = interpreter or PythonInterpreter.get()

@property
def info(self):
return self._pex_info

def entry(self):
"""Return the module spec of the entry point of this PEX.
:returns: The entry point for this environment as a string, otherwise
``None`` if there is no specific entry point.
"""
if 'PEX_MODULE' in os.environ:
TRACER.log('PEX_MODULE override detected: %s' % os.environ['PEX_MODULE'])
return os.environ['PEX_MODULE']
entry_point = self._pex_info.entry_point
if entry_point:
TRACER.log('Using prescribed entry point: %s' % entry_point)
return str(entry_point)
self._pex_info = PexInfo.from_pex(self._pex)
self._pex_info_overrides = PexInfo.from_env()
env_pex_info = self._pex_info.copy()
env_pex_info.update(self._pex_info_overrides)
self._env = PEXEnvironment(self._pex, env_pex_info)

@classmethod
def _extras_paths(cls):
Expand Down Expand Up @@ -248,15 +234,12 @@ def patch_all(path, path_importer_cache, modules):
finally:
patch_all(old_sys_path, old_sys_path_importer_cache, old_sys_modules)

def execute(self, args=()):
def execute(self):
"""Execute the PEX.
This function makes assumptions that it is the last function called by
the interpreter.
"""

entry_point = self.entry()

try:
with self.patch_sys():
working_set = self._env.activate()
Expand All @@ -267,10 +250,7 @@ def execute(self, args=()):
TRACER.log(' %c %s' % (' ' if os.path.exists(element) else '*', element))
TRACER.log(' * - paths that do not exist or will be imported via zipimport')
with self.patch_pkg_resources(working_set):
if entry_point and 'PEX_INTERPRETER' not in os.environ:
self.execute_entry(entry_point, args)
else:
self.execute_interpreter()
self._execute()
except Exception:
# Allow the current sys.excepthook to handle this app exception before we tear things down in
# finally, then reraise so that the exit status is reflected correctly.
Expand All @@ -285,6 +265,27 @@ def execute(self, args=()):
sys.stderr = DevNull()
sys.excepthook = lambda *a, **kw: None

def _execute(self):
if 'PEX_INTERPRETER' in os.environ:
return self.execute_interpreter()

if self._pex_info_overrides.script and self._pex_info_overrides.entry_point:
die('Cannot specify both script and entry_point for a PEX!')

if self._pex_info.script and self._pex_info.entry_point:
die('Cannot specify both script and entry_point for a PEX!')

if self._pex_info_overrides.script:
return self.execute_script(self._pex_info_overrides.script)
elif self._pex_info_overrides.entry_point:
return self.execute_entry(self._pex_info_overrides.entry_point)
elif self._pex_info.script:
return self.execute_script(self._pex_info.script)
elif self._pex_info.entry_point:
return self.execute_entry(self._pex_info.entry_point)
else:
return self.execute_interpreter()

@classmethod
def execute_interpreter(cls):
force_interpreter = 'PEX_INTERPRETER' in os.environ
Expand All @@ -295,26 +296,53 @@ def execute_interpreter(cls):
if sys.argv[1:]:
try:
with open(sys.argv[1]) as fp:
ast = compile(fp.read(), fp.name, 'exec', flags=0, dont_inherit=1)
name, content = sys.argv[1], fp.read()
except IOError as e:
print("Could not open %s in the environment [%s]: %s" % (sys.argv[1], sys.argv[0], e))
sys.exit(1)
die("Could not open %s in the environment [%s]: %s" % (sys.argv[1], sys.argv[0], e))
sys.argv = sys.argv[1:]
old_name = globals()['__name__']
try:
globals()['__name__'] = '__main__'
exec_function(ast, globals())
finally:
globals()['__name__'] = old_name
cls.execute_content(name, content)
else:
import code
code.interact()

def execute_script(self, script_name):
# TODO(wickman) PEXEnvironment should probably have a working_set property
# or possibly just __iter__.
dist, script_path, script_content = get_script_from_distributions(
script_name, self._env.activate())
if not dist:
raise self.NotFound('Could not find script %s in pex!' % script_name)
if not script_content.startswith('#!/usr/bin/env python'):
die('Cannot execute non-Python script within PEX environment!')
TRACER.log('Found script %s in %s' % (script_name, dist))
self.execute_content(script_path, script_content, argv0=script_name)

@classmethod
def execute_content(cls, name, content, argv0=None):
argv0 = argv0 or name
ast = compile(content, name, 'exec', flags=0, dont_inherit=1)
old_name, old_file = globals().get('__name__'), globals().get('__file__')
try:
old_argv0 = sys.argv[0]
sys.argv[0] = argv0
globals()['__name__'] = '__main__'
globals()['__file__'] = name
exec_function(ast, globals())
finally:
if old_name:
globals()['__name__'] = old_name
else:
globals().pop('__name__')
if old_file:
globals()['__file__'] = old_file
else:
globals().pop('__file__')
sys.argv[0] = old_argv0

# TODO(wickman) Find a way to make PEX_PROFILE work with all execute_*
@classmethod
def execute_entry(cls, entry_point, args=None):
if args:
sys.argv = args
runner = cls.execute_pkg_resources if ":" in entry_point else cls.execute_module
def execute_entry(cls, entry_point):
runner = cls.execute_pkg_resources if ':' in entry_point else cls.execute_module

if 'PEX_PROFILE' not in os.environ:
runner(entry_point)
Expand Down
Loading

0 comments on commit fe895b2

Please sign in to comment.