Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Features/dependencies on files on pyton path #903

Draft
wants to merge 25 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
15c14e9
Fixes installing lokal plugins with extras_require (for testing depen…
dwt Feb 11, 2021
36de0bb
Fix pre-commit checks
dwt Feb 11, 2021
ef57c4b
Backport python 3.5 compatibility by not expecting os.* functions to …
dwt Feb 11, 2021
be6e764
Switch to dependency package that still installs on python3.5
dwt Feb 11, 2021
d994d95
Fix test fallout from changing the package.
dwt Feb 11, 2021
580db90
Update lektor/packages.py
dwt Feb 12, 2021
dc36fdf
Update lektor/packages.py
dwt Feb 12, 2021
d1ace2f
Switch to pytest tmp_path fixture as it already does what I need.
dwt Feb 12, 2021
76d8ec7
Extract requirements-generation into its own function to make the mai…
dwt Feb 18, 2021
c9af5b5
Fix installation without dependencies and simplify pattern.
dwt Feb 18, 2021
c148b79
Typo
dwt Feb 19, 2021
05ad402
Add documentation
dwt Feb 19, 2021
a73520c
Use `pkg_resources.split_sections()` to remove parsing code.
dwt Feb 20, 2021
81a857e
Add test that verifies rare requires syntax also works.
dwt Feb 20, 2021
f69d1a6
Better test documentation
dwt Feb 20, 2021
be369e5
Try parsing requirements with `pkg_resources.split_sections()`
dwt Feb 20, 2021
3cfc89f
Document upstream fix that conditions when we can drastically simplif…
dwt Mar 4, 2021
dc4426a
Mark test as expected failure until the pip fix is released and adapted.
dwt Mar 4, 2021
5bbacd0
Remove out of date comment
dwt Mar 4, 2021
5c27188
Remove download_and_install_pacakge(package_root, package, version, r…
dwt Mar 4, 2021
93bc41d
Fix lint warning
dwt Mar 4, 2021
f978075
Fix linter warning
dwt Mar 4, 2021
ecab6c1
Rename argument as per @dairiki's comment
dwt Mar 7, 2021
3963248
Extract if for readability as per @dairiki's comment
dwt Mar 7, 2021
053b647
Discussion starter for dependencies on the python-path.
dwt Mar 19, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 13 additions & 1 deletion lektor/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,7 @@ def operation(con):

seen = set()
rows = []

for source in chain(self.sources, dependencies or ()):
source = self.build_state.to_source_filename(source)
if source in seen:
Expand Down Expand Up @@ -1014,6 +1015,11 @@ def to_source_filename(self, filename):
filename = filename[len(folder) :].lstrip(os.path.sep)
if os.path.altsep:
filename = filename.lstrip(os.path.altsep)
elif self._is_on_python_path(filename):
# can't shorten the filename, as it is not below self.env.root_path
# but also need to oaccept it, or else all files with dependencies on
# this file will be removed by the prune stage of the build
pass
else:
raise ValueError(
"The given value (%r) is not below the "
Expand All @@ -1023,6 +1029,12 @@ def to_source_filename(self, filename):
self.source_filename_cache[key] = rv
return rv

def _is_on_python_path(self, filename):
for path in sys.path:
if filename.startswith(path):
return True
return False

def get_file_info(self, filename):
"""Returns the file info for a given file. This will be cached
on the generator for the lifetime of it. This means that further
Expand All @@ -1031,7 +1043,7 @@ def get_file_info(self, filename):
files have been performed on the outside.

Generally this function can be used to acquire the file info for
any file on the file system but it should onl be used for source
any file on the file system but it should only be used for source
files or carefully for other things.

The filename given can be a source filename.
Expand Down
70 changes: 56 additions & 14 deletions lektor/packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,8 @@ def remove_package_from_project(project, name):
return None


def download_and_install_package(
package_root, package=None, version=None, requirements_file=None
):
"""This downloads and installs a specific version of a package."""
def download_and_install_packages(package_root, requirement_specifiers):
"""This downloads and installs a specific version of packages."""
# XXX: windows
env = dict(os.environ)

Expand All @@ -91,18 +89,41 @@ def download_and_install_package(
package_root,
]

if package is not None:
args.append("%s%s%s" % (package, version and "==" or "", version or ""))
if requirements_file is not None:
args.extend(("-r", requirements_file))

args.extend(requirement_specifiers)
rv = portable_popen(args, env=env).wait()
if rv != 0:
raise RuntimeError("Failed to install dependency package.")


def install_local_package(package_root, path):
"""This installs a local dependency of a package."""

# Because of these bugs:
# - pip https://github.com/pypa/pip/issues/4390
# - setuptools https://github.com/pypa/setuptools/issues/392
# we cannot just call `pip install --target $folder --editable $package`.
# Hence the workaround of first installing only the package and then it's dependencies
# TODO Once https://github.com/pypa/pip/pull/9636 is released, this can all go away
# and the normal install will just work

# Because pip can resolve dependencies differently when it is called with them individually,
# it is important to call it with all of them together. Also

# requires.txt is specified here:
# https://setuptools.readthedocs.io/en/latest/deprecated/python_eggs.html#requires-txt
# requirementx.txt is specified here:
# https://pip.pypa.io/en/stable/reference/pip_install/#requirements-file-format
# What can be part of the dependencies is specified here:
# https://setuptools.readthedocs.io/en/latest/userguide/dependency_management.html#id4

editable_install_without_dependencies(package_root, path)

requirements = requirements_from_unfinished_editable_install_at_path(path)
if requirements is not None:
download_and_install_packages(package_root, requirements)


def editable_install_without_dependencies(package_root, path):
# XXX: windows
env = dict(os.environ)
env["PYTHONPATH"] = package_root
Expand All @@ -124,6 +145,8 @@ def install_local_package(package_root, path):
if rv != 0:
raise RuntimeError("Failed to install local package")


def requirements_from_unfinished_editable_install_at_path(path):
# Step 2: generate the egg info into a temp folder to find the
# requirements.
tmp = tempfile.mkdtemp()
Expand All @@ -143,14 +166,31 @@ def install_local_package(package_root, path):
dirs = os.listdir(tmp)
if rv != 0 or len(dirs) != 1:
raise RuntimeError("Failed to create egg info for local package.")
requires = os.path.join(tmp, dirs[0], "requires.txt")
requires_path = os.path.join(tmp, dirs[0], "requires.txt")

# We have dependencies, install them!
if os.path.isfile(requires):
download_and_install_package(package_root, requirements_file=requires)
if os.path.isfile(requires_path):
# We have dependencies, install them!
return requirements_from_requires_file(requires_path)
finally:
shutil.rmtree(tmp)

# no dependencies to install
return None


def requirements_from_requires_file(requires_path):
"""Create a sanitized copy of `requires.txt`."""
# requires.txt can contain [extras_require] sections wich pip doesn't understand
with open(requires_path, "r") as requires:
section_name, extracted_requirements = next(
pkg_resources.split_sections(requires)
)
if section_name is None:
return extracted_requirements

# no dependencies to install
return None


def get_package_info(path):
"""Returns the name of a package at a path."""
Expand Down Expand Up @@ -292,7 +332,9 @@ def update_cache(package_root, remote_packages, local_package_path, refresh=Fals
package_root, os.path.join(local_package_path, package[1:])
)
else:
download_and_install_package(package_root, package, version)
requirement_spec = f"{package}=={version}" if version else package
download_and_install_packages(package_root, [requirement_spec])

write_manifest(manifest_file, all_packages)


Expand Down
6 changes: 6 additions & 0 deletions tests/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,9 @@ def test_slug_contains_slash(pad, builder):
prog, _ = builder.build(record)
(artifact,) = prog.artifacts
assert artifact.artifact_name == "extra/long/path/index.html"


def test_path_cache_allows_paths_on_pythonpath(env):
# cache = PathCache(env)
# FIXME no idea yet how to test this
pass
151 changes: 151 additions & 0 deletions tests/test_packages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import textwrap

import pytest

from lektor import packages


def create_plugin(package_dir, plugin_name, setup):
plugin_dir = package_dir / plugin_name
plugin_dir.mkdir(parents=True)
setup_py = plugin_dir / "setup.py"
setup_py.write_text(textwrap.dedent(setup))
return plugin_dir


def test_install_local_package_without_dependency(tmp_path):
packages_dir = tmp_path / "packages"
plugin_dir = create_plugin(
packages_dir,
"dependency",
setup="""
from setuptools import setup

setup(
name='dependency',
)
""",
)

install_dir = tmp_path / "target"
install_dir.mkdir()

packages.install_local_package(install_dir.as_posix(), plugin_dir.as_posix())

assert (install_dir / "dependency.egg-link").is_file()


def test_install_local_package_with_dependency(tmp_path):
packages_dir = tmp_path / "packages"
plugin_dir = create_plugin(
packages_dir,
"dependency",
setup="""
from setuptools import setup

setup(
name='dependency',
install_requires=['watching_testrunner']
)
""",
)

install_dir = tmp_path / "target"
install_dir.mkdir()

packages.install_local_package(install_dir.as_posix(), plugin_dir.as_posix())

assert (install_dir / "dependency.egg-link").is_file()
assert (install_dir / "watching_testrunner.py").is_file()


def test_install_local_package_with_dependency_and_extras_require(tmp_path):
packages_dir = tmp_path / "packages"
plugin_dir = create_plugin(
packages_dir,
"dependency",
setup="""
from setuptools import setup

setup(
name='dependency',
install_requires=['watching_testrunner'],
extras_require={
'test': ['pyexpect']
}
)
""",
)

install_dir = tmp_path / "target"
install_dir.mkdir()

packages.install_local_package(install_dir.as_posix(), plugin_dir.as_posix())

assert (install_dir / "dependency.egg-link").is_file()
assert (install_dir / "watching_testrunner.py").is_file()
assert not (install_dir / "pyexpect").is_dir()


def test_install_local_package_with_only_extras_require(tmp_path):
packages_dir = tmp_path / "packages"
plugin_dir = create_plugin(
packages_dir,
"extras_require",
setup="""
from setuptools import setup

setup(
name='extras_require',
extras_require={
'test': ['pyexpect']
}
)
""",
)

install_dir = tmp_path / "target"
install_dir.mkdir()

packages.install_local_package(install_dir.as_posix(), plugin_dir.as_posix())

assert (install_dir / "extras-require.egg-link").is_file()
assert not (install_dir / "pyexpect").is_dir()


@pytest.mark.xfail
def test_install_local_package_with_environment_markers_in_requires(tmp_path):
# See https://www.python.org/dev/peps/pep-0508/#environment-markers
# This test is bad news, as it seems that setuptools internally transforms the more exotic specifiers to
# [:python_version >= "3.6"]
# watching_testrunner
# Which is then of course incompatible with a pip requirements.txt
# This seems contrary to what
# https://setuptools.readthedocs.io/en/latest/pkg_resources.html?highlight=parse_requirements#requirements-parsing
# specifies - not sure what is going on here
# This will start to work as soon as we can remove the workarounds that where neccessary until
# https://github.com/pypa/pip/pull/9636 is released

packages_dir = tmp_path / "packages"
plugin_dir = create_plugin(
packages_dir,
"environment_markers",
setup="""
from setuptools import setup

setup(
name='environment_markers',
install_requires=[
'watching_testrunner;python_version>="3.6"',
]
)
""",
)

install_dir = tmp_path / "target"
install_dir.mkdir()

packages.install_local_package(install_dir.as_posix(), plugin_dir.as_posix())

assert (install_dir / "extras-require.egg-link").is_file()
assert (install_dir / "watching_testrunner.py").is_file()