Skip to content

Commit

Permalink
Created --autoimport parameter to autoimport indicated dependencies.
Browse files Browse the repository at this point in the history
  • Loading branch information
facundobatista committed Apr 18, 2020
1 parent 57f77b8 commit 2190a83
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 21 deletions.
15 changes: 15 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,21 @@ version)::
import bs4 # fades beautifulsoup4 == 4.2


What if no script is given to execute?
--------------------------------------

If no script or program is passed to execute, *fades* will provide a virtualenv
with all the indicated dependencies, and then open an interactive interpreter
in the context of that virtualenv.

Here is where it comes very handy the ``-i/--ipython`` option, if that REPL
is preferred over the standard one.

In the case of using an interactive interpreter, it's also very useful to
make *fades* to automatically import all the indicated dependencies,
passing the ``--autoimport`` parameter.


Other ways to specify dependencies
----------------------------------

Expand Down
83 changes: 76 additions & 7 deletions fades/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,19 @@
import sys
import shlex
import subprocess
import tempfile

import fades

from fades import FadesError
from fades import parsing, logger as fades_logger, cache, helpers, envbuilder, file_options
from fades import (
FadesError,
cache,
envbuilder,
file_options,
helpers,
logger as fades_logger,
parsing,
pkgnamesdb,
)

# the signals to redirect to the child process (note: only these are
# allowed in Windows, see 'signal' doc).
Expand All @@ -42,7 +50,7 @@
signal.SIGTERM,
]

help_epilog = """
HELP_EPILOG = """
The "child program" is the script that fades will execute. It's an
optional parameter, it will be the first thing received by fades that
is not a parameter. If no child program is indicated, a Python
Expand All @@ -52,6 +60,58 @@
parameters passed as is to the child program.
"""

AUTOIMPORT_HEADER = """
import sys
print("Python {} on {}".format(sys.version, sys.platform))
print('Type "help", "copyright", "credits" or "license" for more information.')
"""

AUTOIMPORT_MOD_IMPORTER = """
try:
import {module}
except ImportError:
print("::fades:: FAILED to autoimport {module!r}")
else:
print("::fades:: automatically imported {module!r}")
"""

AUTOIMPORT_MOD_SKIPPING = (
"""print("::fades:: autoimport skipped because not a PyPI package: {dependency!r}")\n""")


def get_autoimport_scriptname(dependencies, is_ipython):
"""Return the path of script that will import dependencies for interactive mode.
The script has:
- a safe import of the dependencies, taking in consideration that the module may be named
differently than the package, and printing a message accordingly
- if regular Python, also print the normal interactive interpreter first information lines,
that are not shown when starting it with `-i` (but IPython shows them anyway).
"""
fd, tempfilepath = tempfile.mkstemp(prefix='fadesinit-', suffix='.py')
fh = os.fdopen(fd, 'wt', encoding='utf8')

if not is_ipython:
fh.write(AUTOIMPORT_HEADER)

for repo, dependencies in dependencies.items():
for dependency in dependencies:
if repo == fades.REPO_PYPI:
package = dependency.name
if is_ipython and package == 'ipython':
# Ignore this artificially added dependency.
continue

module = pkgnamesdb.PACKAGE_TO_MODULE.get(package, package)
fh.write(AUTOIMPORT_MOD_IMPORTER.format(module=module))
else:
fh.write(AUTOIMPORT_MOD_SKIPPING.format(dependency=dependency))

fh.close()
return tempfilepath


def consolidate_dependencies(needs_ipython, child_program,
requirement_files, manual_dependencies):
Expand Down Expand Up @@ -171,7 +231,7 @@ def _get_normalized_args(parser):

def go():
"""Make the magic happen."""
parser = argparse.ArgumentParser(prog='PROG', epilog=help_epilog,
parser = argparse.ArgumentParser(prog='PROG', epilog=HELP_EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('-V', '--version', action='store_true',
help="show version and info about the system, and exit")
Expand Down Expand Up @@ -223,6 +283,9 @@ def go():
parser.add_argument('--get-venv-dir', action='store_true',
help=("Show the virtualenv base directory (which includes the "
"virtualenv UUID) and quit."))
parser.add_argument('-a', '--autoimport', action='store_true',
help=("Automatically import the specified dependencies in the "
"interactive mode (ignored otherwise)."))
parser.add_argument('child_program', nargs='?', default=None)
parser.add_argument('child_options', nargs=argparse.REMAINDER)

Expand Down Expand Up @@ -356,11 +419,17 @@ def go():

# store usage information
usage_manager.store_usage_stat(venv_data, venvscache)

if child_program is None:
interactive = True
logger.debug(
"Calling the interactive Python interpreter with arguments %r", python_options)
cmd = [python_exe] + python_options

# get possible extra python options and environement for auto import
if indicated_deps and args.autoimport:
temp_scriptpath = get_autoimport_scriptname(indicated_deps, args.ipython)
cmd += ['-i', temp_scriptpath]

logger.debug("Calling the interactive Python interpreter: %s", cmd)
p = subprocess.Popen(cmd)
else:
interactive = False
Expand Down
18 changes: 11 additions & 7 deletions fades/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from pkg_resources import parse_requirements

from fades import REPO_PYPI, REPO_VCS
from fades.pkgnamesdb import PKG_NAMES_DB
from fades.pkgnamesdb import MODULE_TO_PACKAGE

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -130,7 +130,7 @@ def _parse_content(fh):
if import_part.startswith('#'):
continue

# get module
# Get the module.
import_tokens = import_part.split()
if import_tokens[0] == 'import':
module_path = import_tokens[1]
Expand All @@ -140,17 +140,21 @@ def _parse_content(fh):
logger.debug("Not understood import info: %s", import_tokens)
continue
module = module_path.split(".")[0]
# If fades know the real name of the pkg. Replace it!
if module in PKG_NAMES_DB:
module = PKG_NAMES_DB[module]

# The package has the same name (most of the times! if fades knows the conversion, use it).
if module in MODULE_TO_PACKAGE:
package = MODULE_TO_PACKAGE[module]
else:
package = module

# To match the "safe" name that pkg_resources creates:
module = module.replace('_', '-')
package = package.replace('_', '-')

# get the fades info after 'fades' mark, if any
if len(fades_part) == 5 or fades_part[5:].strip()[0] in "<>=!":
# just the 'fades' mark, and maybe a version specification, the requirement is what
# was imported (maybe with that version comparison)
requirement = module + fades_part[5:]
requirement = package + fades_part[5:]
elif fades_part[5] != " ":
# starts with fades but it's part of a longer weird word
logger.warning("Not understood fades info: %r", fades_part)
Expand Down
13 changes: 9 additions & 4 deletions fades/pkgnamesdb.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2015 Facundo Batista, Nicolás Demarchi
# Copyright 2015-2020 Facundo Batista, Nicolás Demarchi
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 3, as published
Expand All @@ -14,14 +14,19 @@
#
# For further info, check https://github.com/PyAr/fades

"""A list of packages containing names which don't match with the PyPi distrbution's name."""
"""A module to package and viceversa conversion DB.
PKG_NAMES_DB = {
This is needed for names which don't match with the distrbution's name.
"""

MODULE_TO_PACKAGE = {
'bs4': 'beautifulsoup4',
'github3': 'github3.py',
'uritemplate': 'uritemplate.py',
'postgresql': 'py-postgresql',
'yaml': 'pyyaml',
'PIL': 'Pillow',
'PIL': 'pillow',
'Crypto': 'pycrypto',
}

PACKAGE_TO_MODULE = {v: k for k, v in MODULE_TO_PACKAGE.items()}
6 changes: 5 additions & 1 deletion man/fades.1
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ fades - A system that automatically handles the virtualenvs in the cases normall

The first non-option parameter (if any) would be then the child program to execute, and any other parameters after that are passed as is to that child script.

\fBfades\fR can also be executed without passing a child script to execute: in this mode it will open a Python interactive interpreter inside the created/reused virtualenv (taking dependencies from \fI--dependency\fR or \fI--requirement\fR options).
\fBfades\fR can also be executed without passing a child script to execute: in this mode it will open a Python interactive interpreter inside the created/reused virtualenv (taking dependencies from \fI--dependency\fR or \fI--requirement\fR options). If \fI--autoimport\fR is given, it will automatically import all the installed dependencies.

If the \fIchild_program\fR parameter is really an URL, the script will be automatically downloaded from there (supporting also the most common pastebins URLs: pastebin.com, linkode.org, gist, etc.).

Expand Down Expand Up @@ -126,6 +126,10 @@ Show the virtualenv base directory (which includes the virtualenv UUID) and quit
.BR --no-precheck-availability
Don't check if the packages exists in PyPI before actually try to install them.

.TP
.BR -a ", " --autoimport
Automatically import the dependencies when in interactive interpreter mode (ignored otherwise).


.SH EXAMPLES

Expand Down
2 changes: 1 addition & 1 deletion testdev
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ else
TARGET_TESTS=""
fi

./bin/fades -r requirements.txt -x pytest $TARGET_TESTS
./bin/fades -r requirements.txt -x pytest -s $TARGET_TESTS
80 changes: 79 additions & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

from pkg_resources import Requirement

from fades import VERSION, FadesError, __version__, main, parsing
from fades import VERSION, FadesError, __version__, main, parsing, REPO_PYPI, REPO_VCS
from tests import create_tempfile


Expand Down Expand Up @@ -217,3 +217,81 @@ def test_indicated_with_executable_flag_in_path(self):
"""Absolute paths not allowed when using --exec."""
with self.assertRaises(FadesError):
main.decide_child_program(True, os.path.join("path", "foobar.py"))


# ---------------------------------------
# autoimport tests

def _autoimport_safe_call(*args, **kwargs):
"""Call the tested function and always remove the tempfile after the test."""
fpath = main.get_autoimport_scriptname(*args, **kwargs)

with open(fpath, "rt", encoding='utf8') as fh:
content = fh.read()
os.unlink(fpath)

return content


def test_autoimport_simple():
"""Simplest autoimport call."""
dependencies = {
REPO_PYPI: {Requirement.parse('mymod')},
}
content = _autoimport_safe_call(dependencies, is_ipython=False)

assert content.startswith(main.AUTOIMPORT_HEADER)
assert main.AUTOIMPORT_MOD_IMPORTER.format(module='mymod') in content


def test_autoimport_several_dependencies():
"""Indicate several dependencies."""
dependencies = {
REPO_PYPI: {Requirement.parse('mymod1'), Requirement.parse('mymod2')},
}
content = _autoimport_safe_call(dependencies, is_ipython=False)

assert content.startswith(main.AUTOIMPORT_HEADER)
assert main.AUTOIMPORT_MOD_IMPORTER.format(module='mymod1') in content
assert main.AUTOIMPORT_MOD_IMPORTER.format(module='mymod2') in content


def test_autoimport_including_ipython():
"""Call with ipython modifier."""
dependencies = {
REPO_PYPI: {
Requirement.parse('mymod'),
Requirement.parse('ipython'), # this one is automatically added
},
}
content = _autoimport_safe_call(dependencies, is_ipython=True)

assert main.AUTOIMPORT_HEADER not in content
assert main.AUTOIMPORT_MOD_IMPORTER.format(module='mymod') in content
assert 'ipython' not in content


def test_autoimport_no_pypi_dep():
"""Case with no pypi dependencies."""
dependencies = {
REPO_PYPI: {Requirement.parse('my_pypi_mod')},
REPO_VCS: {'my_vcs_dependency'},
}
content = _autoimport_safe_call(dependencies, is_ipython=False)

assert main.AUTOIMPORT_MOD_IMPORTER.format(module='my_pypi_mod') in content
assert main.AUTOIMPORT_MOD_SKIPPING.format(dependency='my_vcs_dependency') in content


def test_autoimport_importer_mod_ok(capsys):
"""Check the generated code to import a module when works fine."""
code = main.AUTOIMPORT_MOD_IMPORTER.format(module='time') # something from stdlib, always ok
exec(code)
assert capsys.readouterr().out == "::fades:: automatically imported 'time'\n"


def test_autoimport_importer_mod_fail(capsys):
"""Check the generated code to import a module when works fine."""
code = main.AUTOIMPORT_MOD_IMPORTER.format(module='not_there_should_explode')
exec(code)
assert capsys.readouterr().out == "::fades:: FAILED to autoimport 'not_there_should_explode'\n"
24 changes: 24 additions & 0 deletions tests/test_pkgnamesdb.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright 2020 Facundo Batista, Nicolás Demarchi
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License version 3, as published
# by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranties of
# MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR
# PURPOSE. See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#
# For further info, check https://github.com/PyAr/fades

"""Tests for the package names DB."""

from fades import pkgnamesdb


def test_db_consistency():
"""Ensure multiple DB entrypoints are consistent between them."""
assert len(pkgnamesdb.MODULE_TO_PACKAGE) == len(pkgnamesdb.PACKAGE_TO_MODULE)

0 comments on commit 2190a83

Please sign in to comment.