Repository: pypa/pipenv

Issue:
Pipenv now raises when attempting to install extras that don't exist
### Issue description

Calling `pipenv lock` on a Pipfile that specifies extras that don't exist results in a `TypeError` (using ac05a48f5b89d2e88a5f4808449b7eb74ed041c5).

### Expected result

Previously, Pipenv silently ignored extras that didn't exist.

### Actual result

```
Locking [packages] dependencies…
✘ Locking Failed! 
Traceback (most recent call last):
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/resolver.py", line 126, in <module>
    main()
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/resolver.py", line 119, in main
    parsed.requirements_dir, parsed.packages)
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/resolver.py", line 85, in _main
    requirements_dir=requirements_dir,
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/resolver.py", line 69, in resolve
    req_dir=requirements_dir
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/utils.py", line 707, in resolve_deps
    req_dir=req_dir,
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/utils.py", line 475, in actually_resolve_deps
    deps, index_lookup, markers_lookup, project, sources,
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/utils.py", line 237, in get_resolver_metadata
    req = Requirement.from_line(dep)
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/requirements.py", line 1092, in from_line
    r = FileRequirement.from_line(line_with_prefix, extras=extras)
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/requirements.py", line 548, in from_line
    return cls.create(**arg_dict)
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/requirements.py", line 477, in create
    setup_info = SetupInfo.from_ireq(ireq)
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/setup_info.py", line 431, in from_ireq
    created.get_info()
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/setup_info.py", line 349, in get_info
    self.get_egg_metadata()
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/setup_info.py", line 325, in get_egg_metadata
    extras = ensure_reqs(extras)
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/setup_info.py", line 74, in ensure_reqs
    for req in reqs:
TypeError: 'NoneType' object is not iterable
File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/resolver.py", line 126, in <module>
    main()
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/resolver.py", line 119, in main
    parsed.requirements_dir, parsed.packages)
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/resolver.py", line 85, in _main
    requirements_dir=requirements_dir,
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/resolver.py", line 69, in resolve
    req_dir=requirements_dir
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/utils.py", line 707, in resolve_deps
    req_dir=req_dir,
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/utils.py", line 475, in actually_resolve_deps
    deps, index_lookup, markers_lookup, project, sources,
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/utils.py", line 237, in get_resolver_metadata
    req = Requirement.from_line(dep)
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/requirements.py", line 1092, in from_line
    r = FileRequirement.from_line(line_with_prefix, extras=extras)
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/requirements.py", line 548, in from_line
    return cls.create(**arg_dict)
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/requirements.py", line 477, in create
    setup_info = SetupInfo.from_ireq(ireq)
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/setup_info.py", line 431, in from_ireq
    created.get_info()
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/setup_info.py", line 349, in get_info
    self.get_egg_metadata()
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/setup_info.py", line 325, in get_egg_metadata
    extras = ensure_reqs(extras)
  File "/Users/greysteil/code/dependabot-core/helpers/python/src/pipenv/pipenv/vendor/requirementslib/models/setup_info.py", line 74, in ensure_reqs
    for req in reqs:
TypeError: 'NoneType' object is not iterable
```

### Steps to replicate

`pyenv exec pipenv lock --verbose` on the following

Pipfile:
```
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true

[dev-packages]
pytest = "==3.4.0"

[packages]
requests = "==2.18.0"
"e1839a8" = {path = ".", editable = true, extras=["socks"]}
```

setup.py
```
from setuptools import setup

setup(
  name="sanitized-package",
  version="0.0.1",
  install_requires=["raven==5.32.0"],
  extras_require={
    'PDF': ["flask"]
  }
)
```

-------------------------------------------------------------------------------

<details><summary>$ pipenv --support</summary>

Pipenv version: `'2018.10.14.dev0'`

Pipenv location: `'/usr/local/lib/python3.6/site-packages/pipenv-2018.10.14.dev0-py3.6.egg/pipenv'`

Python location: `'/usr/local/opt/python/bin/python3.6'`

Python installations found:

  - `3.7.0`: `/Users/greysteil/.pyenv/versions/3.7.0/bin/python3`
  - `3.7.0`: `/Users/greysteil/.pyenv/versions/3.7.0/bin/python3.7m`
  - `3.6.7`: `/Users/greysteil/.pyenv/versions/3.6.7/bin/python3`
  - `3.6.7`: `/Users/greysteil/.pyenv/versions/3.6.7/bin/python3.6m`
  - `3.6.6`: `/Users/greysteil/.pyenv/versions/3.6.6/bin/python3`
  - `3.6.6`: `/Users/greysteil/.pyenv/versions/3.6.6/bin/python3.6m`
  - `3.6.5`: `/Users/greysteil/.pyenv/versions/3.6.5/bin/python3`
  - `3.6.5`: `/Users/greysteil/.pyenv/versions/3.6.5/bin/python3.6m`
  - `3.6.5`: `/usr/local/bin/python3`
  - `3.6.5`: `/usr/local/bin/python3.6m`
  - `3.5.0`: `/Users/greysteil/.pyenv/versions/3.5.0/bin/python3`
  - `3.5.0`: `/Users/greysteil/.pyenv/versions/3.5.0/bin/python3.5m`
  - `2.7.15`: `/Users/greysteil/.pyenv/versions/2.7.15/bin/python`
  - `2.7.14`: `/Users/greysteil/.pyenv/versions/2.7.14/bin/python`
  - `2.7.10`: `/usr/bin/python`
  - `2.7.10`: `/usr/bin/pythonw`
  - `2.7.10`: `/usr/bin/python2.7`

PEP 508 Information:

```
{'implementation_name': 'cpython',
 'implementation_version': '3.6.5',
 'os_name': 'posix',
 'platform_machine': 'x86_64',
 'platform_python_implementation': 'CPython',
 'platform_release': '17.7.0',
 'platform_system': 'Darwin',
 'platform_version': 'Darwin Kernel Version 17.7.0: Wed Oct 10 23:06:14 PDT '
                     '2018; root:xnu-4570.71.13~1/RELEASE_X86_64',
 'python_full_version': '3.6.5',
 'python_version': '3.6',
 'sys_platform': 'darwin'}
```

System environment variables:

  - `TERM_PROGRAM`
  - `PYENV_ROOT`
  - `TERM`
  - `SHELL`
  - `CLICOLOR`
  - `TMPDIR`
  - `Apple_PubSub_Socket_Render`
  - `TERM_PROGRAM_VERSION`
  - `TERM_SESSION_ID`
  - `USER`
  - `SSH_AUTH_SOCK`
  - `LSCOLORS`
  - `PATH`
  - `PWD`
  - `EDITOR`
  - `LANG`
  - `XPC_FLAGS`
  - `RBENV_SHELL`
  - `XPC_SERVICE_NAME`
  - `HOME`
  - `SHLVL`
  - `LOGNAME`
  - `SECURITYSESSIONID`
  - `OLDPWD`
  - `_`
  - `__CF_USER_TEXT_ENCODING`
  - `PIP_DISABLE_PIP_VERSION_CHECK`
  - `PYTHONDONTWRITEBYTECODE`
  - `PIP_SHIMS_BASE_MODULE`
  - `PIP_PYTHON_PATH`
  - `PYTHONFINDER_IGNORE_UNSUPPORTED`

Pipenv–specific environment variables:


Debug–specific environment variables:

  - `PATH`: `/Users/greysteil/.poetry/bin:/Users/greysteil/.config/yarn/global/node_modules/.bin:/Users/greysteil/.poetry/bin::/Users/greysteil/go/bin::/usr/local/opt/erlang@20/bin:/Users/greysteil/.pyenv/bin:/Users/greysteil/.cargo/bin:/usr/local/heroku/bin:/Users/greysteil/.rbenv/shims:/usr/local/bin:./node_modules/.bin:.bundle/binstubs:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/Library/TeX/texbin:/usr/local/go/bin:/Library/Frameworks/Mono.framework/Versions/Current/Commands:/usr/local/git/bin:/usr/local/sbin`
  - `SHELL`: `/bin/bash`
  - `EDITOR`: `subl -w`
  - `LANG`: `en_GB.UTF-8`
  - `PWD`: `/Users/greysteil/code/python-test`


---------------------------

Contents of `Pipfile` ('/Users/greysteil/code/python-test/Pipfile'):

```toml
[[source]]
url = "https://pypi.python.org/simple"
verify_ssl = true

[dev-packages]
pytest = "==3.4.0"

[packages]
requests = "==2.18.0"
"e1839a8" = {path = ".", editable = true, extras=["socks"]}

```

</details>



Discussion:

Resolving patch:
diff --git a/pipenv/vendor/requirementslib/models/requirements.py b/pipenv/vendor/requirementslib/models/requirements.py
--- a/pipenv/vendor/requirementslib/models/requirements.py
+++ b/pipenv/vendor/requirementslib/models/requirements.py
@@ -290,7 +290,7 @@ def get_uri(self):
         if self.path and not self.uri:
             self._uri_scheme = "path"
             return pip_shims.shims.path_to_url(os.path.abspath(self.path))
-        elif self.req and getattr(self.req, "url"):
+        elif getattr(self, "req", None) and getattr(self.req, "url"):
             return self.req.url
 
     @name.default
@@ -321,7 +321,7 @@ def get_name(self):
                 if self.extras:
                     line = "{0}[{1}]".format(line, ",".join(self.extras))
                 _ireq = pip_shims.shims.install_req_from_line(line)
-            if self.req:
+            if getattr(self, "req", None):
                 _ireq.req = copy.deepcopy(self.req)
             else:
                 if self.extras:
@@ -710,7 +710,7 @@ def get_link(self):
     def get_name(self):
         return (
             self.link.egg_fragment or self.req.name
-            if self.req
+            if getattr(self, "req", None)
             else super(VCSRequirement, self).get_name()
         )
 
diff --git a/pipenv/vendor/requirementslib/models/setup_info.py b/pipenv/vendor/requirementslib/models/setup_info.py
--- a/pipenv/vendor/requirementslib/models/setup_info.py
+++ b/pipenv/vendor/requirementslib/models/setup_info.py
@@ -17,7 +17,7 @@
 from appdirs import user_cache_dir
 from six.moves import configparser
 from six.moves.urllib.parse import unquote
-from vistir.compat import Path
+from vistir.compat import Path, Iterable
 from vistir.contextmanagers import cd
 from vistir.misc import run
 from vistir.path import create_tracked_tempdir, ensure_mkdir_p, mkdir_p
@@ -70,6 +70,8 @@ def _get_src_dir():
 
 def ensure_reqs(reqs):
     import pkg_resources
+    if not isinstance(reqs, Iterable):
+        raise TypeError("Expecting an Iterable, got %r" % reqs)
     new_reqs = []
     for req in reqs:
         if not req:
@@ -321,12 +323,13 @@ def get_egg_metadata(self):
                 )
                 if getattr(self.ireq, "extras", None):
                     for extra in self.ireq.extras:
-                        extras = metadata.get("extras", {}).get(extra)
-                        extras = ensure_reqs(extras)
-                        self.extras[extra] = set(extras)
-                        self.requires.update(
-                            {req.key: req for req in extras if req is not None}
-                        )
+                        extras = metadata.get("extras", {}).get(extra, [])
+                        if extras:
+                            extras = ensure_reqs(extras)
+                            self.extras[extra] = set(extras)
+                            self.requires.update(
+                                {req.key: req for req in extras if req is not None}
+                            )
 
     def run_pyproject(self):
         if self.pyproject and self.pyproject.exists():


Test patch:
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -12,7 +12,7 @@
 from pipenv.vendor import requests
 from pipenv.vendor import toml
 from pipenv.vendor import tomlkit
-from pytest_pypi.app import prepare_packages as prepare_pypi_packages
+from pytest_pypi.app import prepare_packages as prepare_pypi_packages, prepare_fixtures
 from vistir.compat import ResourceWarning, fs_str
 from vistir.path import mkdir_p
 
@@ -71,6 +71,7 @@ def check_for_mercurial():
 PYPI_VENDOR_DIR = os.path.join(TESTS_ROOT, 'pypi')
 WE_HAVE_HG = check_for_mercurial()
 prepare_pypi_packages(PYPI_VENDOR_DIR)
+prepare_fixtures(os.path.join(PYPI_VENDOR_DIR, "fixtures"))
 
 
 def pytest_runtest_setup(item):
@@ -144,6 +145,9 @@ def install(self, package, value, dev=False):
             self.document[section][package] = value
         self.write()
 
+    def add(self, package, value, dev=False):
+        self.install(package, value, dev=dev)
+
     def loads(self):
         self.document = tomlkit.loads(self.path.read_text())
 
@@ -162,6 +166,25 @@ def write(self):
     def get_fixture_path(cls, path):
         return Path(__file__).absolute().parent.parent / "test_artifacts" / path
 
+    @classmethod
+    def get_url(cls, pkg=None, filename=None):
+        pypi = os.environ.get("PIPENV_PYPI_URL")
+        if not pkg and not filename:
+            return pypi if pypi else "https://pypi.org/"
+        file_path = filename
+        if pkg and filename:
+            file_path = os.path.join(pkg, filename)
+        if filename and not pkg:
+            pkg = os.path.basename(filename)
+        if pypi:
+            if pkg and not filename:
+                url = "{0}/artifacts/{1}".format(pypi, pkg)
+            else:
+                url = "{0}/artifacts/{1}/{2}".format(pypi, pkg, filename)
+            return url
+        if pkg and not filename:
+            return cls.get_fixture_path(file_path).as_uri()
+
 
 class _PipenvInstance(object):
     """An instance of a Pipenv Project..."""
@@ -189,6 +212,7 @@ def __init__(self, pypi=None, pipfile=True, chdir=False, path=None, home_dir=Non
         self.chdir = chdir
 
         if self.pypi:
+            os.environ['PIPENV_PYPI_URL'] = fs_str('{0}'.format(self.pypi.url))
             os.environ['PIPENV_TEST_INDEX'] = fs_str('{0}/simple'.format(self.pypi.url))
 
         if pipfile:
diff --git a/tests/integration/test_install_twists.py b/tests/integration/test_install_twists.py
--- a/tests/integration/test_install_twists.py
+++ b/tests/integration/test_install_twists.py
@@ -2,7 +2,7 @@
 import shutil
 from pipenv.project import Project
 from pipenv._compat import Path
-
+from pipenv.vendor import delegator
 from pipenv.utils import mkdir_p, temp_environ
 
 import pytest
@@ -338,7 +338,7 @@ def test_install_local_uri_special_character(PipenvInstance, testsroot):
 @pytest.mark.files
 @pytest.mark.install
 @pytest.mark.run
-def test_multiple_editable_packages_should_not_race(PipenvInstance, pypi, tmpdir, testsroot):
+def test_multiple_editable_packages_should_not_race(PipenvInstance, pypi, testsroot):
     """Test for a race condition that can occur when installing multiple 'editable' packages at
     once, and which causes some of them to not be importable.
 
@@ -347,40 +347,27 @@ def test_multiple_editable_packages_should_not_race(PipenvInstance, pypi, tmpdir
     So this test locally installs packages from tarballs that have already been committed in
     the local `pypi` dir to avoid using VCS packages.
     """
-    pkgs = {
-        "requests-2.19.1": "requests/requests-2.19.1.tar.gz",
-        "Flask-0.12.2": "flask/Flask-0.12.2.tar.gz",
-        "six-1.11.0": "six/six-1.11.0.tar.gz",
-        "Jinja2-2.10": "jinja2/Jinja2-2.10.tar.gz",
-    }
-
-    pipfile_string="""
+    pkgs = ["requests", "flask", "six", "jinja2"]
+
+    pipfile_string = """
+[dev-packages]
+
 [packages]
 """
-    # Unzip tarballs to known location, and update Pipfile template.
-    for pkg_name, file_name in pkgs.items():
-        source_path = str(Path(testsroot, "pypi", file_name))
-        unzip_path = str(Path(tmpdir.strpath, pkg_name))
 
-        import tarfile
-
-        with tarfile.open(source_path, "r:gz") as tgz:
-            tgz.extractall(path=tmpdir.strpath)
+    with PipenvInstance(pypi=pypi, chdir=True) as p:
+        for pkg_name in pkgs:
+            source_path = p._pipfile.get_fixture_path("git/{0}/".format(pkg_name)).as_posix()
+            c = delegator.run("git clone {0} ./{1}".format(source_path, pkg_name))
+            assert c.return_code == 0
 
-        pipfile_string += "'{0}' = {{path = '{1}', editable = true}}\n".format(pkg_name, unzip_path)
+            pipfile_string += '"{0}" = {{path = "./{0}", editable = true}}\n'.format(pkg_name)
 
-    with PipenvInstance(pypi=pypi, chdir=True) as p:
         with open(p.pipfile_path, 'w') as f:
             f.write(pipfile_string.strip())
 
         c = p.pipenv('install')
         assert c.return_code == 0
 
-        c = p.pipenv('run python -c "import requests"')
-        assert c.return_code == 0
-        c = p.pipenv('run python -c "import flask"')
-        assert c.return_code == 0
-        c = p.pipenv('run python -c "import six"')
-        assert c.return_code == 0
-        c = p.pipenv('run python -c "import jinja2"')
-        assert c.return_code == 0
+        c = p.pipenv('run python -c "import requests, flask, six, jinja2"')
+        assert c.return_code == 0, c.err
diff --git a/tests/integration/test_install_uri.py b/tests/integration/test_install_uri.py
--- a/tests/integration/test_install_uri.py
+++ b/tests/integration/test_install_uri.py
@@ -63,9 +63,10 @@ def test_ssh_vcs_install(PipenvInstance, pip_src_dir, pypi):
 @pytest.mark.needs_internet
 @flaky
 def test_urls_work(PipenvInstance, pypi, pip_src_dir):
-    with PipenvInstance(pypi=pypi) as p:
+    with PipenvInstance(pypi=pypi, chdir=True) as p:
+        path = p._pipfile.get_url("django", "3.4.x.zip")
         c = p.pipenv(
-            "install https://github.com/divio/django-cms/archive/release/3.4.x.zip"
+            "install {0}".format(path)
         )
         assert c.return_code == 0
 
@@ -187,27 +188,15 @@ def test_install_named_index_alias(PipenvInstance):
 @pytest.mark.needs_internet
 def test_install_local_vcs_not_in_lockfile(PipenvInstance, pip_src_dir):
     with PipenvInstance(chdir=True) as p:
-        six_path = os.path.join(p.path, "six")
-        c = delegator.run(
-            "git clone https://github.com/benjaminp/six.git {0}".format(six_path)
-        )
+        # six_path = os.path.join(p.path, "six")
+        six_path = p._pipfile.get_fixture_path("git/six/").as_posix()
+        c = delegator.run("git clone {0} ./six".format(six_path))
         assert c.return_code == 0
-        c = p.pipenv("install -e ./six")
+        c = p.pipenv("install -e ./six".format(six_path))
         assert c.return_code == 0
         six_key = list(p.pipfile["packages"].keys())[0]
-        c = p.pipenv(
-            "install -e git+https://github.com/requests/requests.git#egg=requests"
-        )
-        assert c.return_code == 0
-        c = p.pipenv("lock")
-        assert c.return_code == 0
-        assert "requests" in p.pipfile["packages"]
-        assert "requests" in p.lockfile["default"]
-        # This is the hash of ./six
-        assert six_key in p.pipfile["packages"]
-        assert six_key in p.lockfile["default"]
-        # The hash isn't a hash anymore, its actually the name of the package (we now resolve this)
-        assert "six" in p.pipfile["packages"]
+        # we don't need the rest of the test anymore, this just works on its own
+        assert six_key == "six"
 
 
 @pytest.mark.vcs
@@ -247,6 +236,7 @@ def test_vcs_entry_supersedes_non_vcs(PipenvInstance, pip_src_dir):
     the resolution graph of non-editable vcs dependencies.
     """
     with PipenvInstance(chdir=True) as p:
+        pyinstaller_path = p._pipfile.get_fixture_path("git/pyinstaller")
         with open(p.pipfile_path, "w") as f:
             f.write(
                 """
@@ -257,8 +247,8 @@ def test_vcs_entry_supersedes_non_vcs(PipenvInstance, pip_src_dir):
 
 [packages]
 PyUpdater = "*"
-PyInstaller = {ref = "develop", git = "https://github.com/pyinstaller/pyinstaller.git"}
-            """.strip()
+PyInstaller = {{ref = "develop", git = "{0}"}}
+            """.format(pyinstaller_path.as_uri()).strip()
             )
         p.pipenv("install")
         installed_packages = ["PyUpdater", "PyInstaller"]
@@ -268,7 +258,7 @@ def test_vcs_entry_supersedes_non_vcs(PipenvInstance, pip_src_dir):
         assert p.lockfile["default"]["pyinstaller"].get("ref") is not None
         assert (
             p.lockfile["default"]["pyinstaller"]["git"]
-            == "https://github.com/pyinstaller/pyinstaller.git"
+            == pyinstaller_path.as_uri()
         )
 
 
diff --git a/tests/pypi/fixtures/django/3.4.x.zip b/tests/pypi/fixtures/django/3.4.x.zip
new file mode 100644
Binary files /dev/null and b/tests/pypi/fixtures/django/3.4.x.zip differ
diff --git a/tests/pytest-pypi/pytest_pypi/app.py b/tests/pytest-pypi/pytest_pypi/app.py
--- a/tests/pytest-pypi/pytest_pypi/app.py
+++ b/tests/pytest-pypi/pytest_pypi/app.py
@@ -1,5 +1,6 @@
 import os
 import json
+import sys
 
 import requests
 from flask import Flask, redirect, abort, render_template, send_file, jsonify
@@ -8,7 +9,7 @@
 session = requests.Session()
 
 packages = {}
-
+ARTIFACTS = {}
 
 class Package(object):
     """Package represents a collection of releases from one or more directories"""
@@ -38,6 +39,37 @@ def add_release(self, path_to_binary):
         self._package_dirs.add(path)
 
 
+class Artifact(object):
+    """Represents an artifact for download"""
+
+    def __init__(self, name):
+        super(Artifact, self).__init__()
+        self.name = name
+        self.files = {}
+        self._artifact_dirs = set()
+
+    def __repr__(self):
+        return "<Artifact name={0!r} files={1!r}".format(self.name, len(self.files))
+
+    def add_dir(self, path):
+        path = os.path.abspath(path)
+        base_path, fn = os.path.split(path)
+        self.files[fn] = path
+        self._artifact_dirs.add(base_path)
+
+
+def prepare_fixtures(path):
+    path = os.path.abspath(path)
+    if not (os.path.exists(path) and os.path.isdir(path)):
+        raise ValueError("{} is not a directory!".format(path))
+    for root, dirs, files in os.walk(path):
+        for file in files:
+            package_name = os.path.basename(root)
+            if package_name not in ARTIFACTS:
+                ARTIFACTS[package_name] = Artifact(package_name)
+            ARTIFACTS[package_name].add_dir(os.path.join(root, file))
+
+
 def prepare_packages(path):
     """Add packages in path to the registry."""
     path = os.path.abspath(path)
@@ -47,7 +79,9 @@ def prepare_packages(path):
         for file in files:
             if not file.startswith('.') and not file.endswith('.json'):
                 package_name = os.path.basename(root)
-
+                if package_name and package_name == "fixtures":
+                    prepare_fixtures(root)
+                    continue
                 if package_name not in packages:
                     packages[package_name] = Package(package_name)
 
@@ -64,6 +98,11 @@ def simple():
     return render_template('simple.html', packages=packages.values())
 
 
+@app.route('/artifacts')
+def artifacts():
+    return render_template('artifacts.html', artifacts=ARTIFACTS.values())
+
+
 @app.route('/simple/<package>/')
 def simple_package(package):
     if package in packages:
@@ -72,6 +111,14 @@ def simple_package(package):
         abort(404)
 
 
+@app.route('/artifacts/<artifact>/')
+def simple_artifact(artifact):
+    if artifact in ARTIFACTS:
+        return render_template('artifact.html', artifact=ARTIFACTS[artifact])
+    else:
+        abort(404)
+
+
 @app.route('/<package>/<release>')
 def serve_package(package, release):
     if package in packages:
@@ -83,6 +130,15 @@ def serve_package(package, release):
     abort(404)
 
 
+@app.route('/artifacts/<artifact>/<fn>')
+def serve_artifact(artifact, fn):
+    if artifact in ARTIFACTS:
+        artifact = ARTIFACTS[artifact]
+        if fn in artifact.files:
+            return send_file(artifact.files[fn])
+    abort(404)
+
+
 @app.route('/pypi/<package>/json')
 def json_for_package(package):
     try:
@@ -98,5 +154,6 @@ def json_for_package(package):
     PYPI_VENDOR_DIR = os.environ.get('PYPI_VENDOR_DIR', './pypi')
     PYPI_VENDOR_DIR = os.path.abspath(PYPI_VENDOR_DIR)
     prepare_packages(PYPI_VENDOR_DIR)
+    prepare_fixtures(os.path.join(PYPI_VENDOR_DIR, "fixtures"))
 
     app.run()
diff --git a/tests/pytest-pypi/pytest_pypi/templates/artifact.html b/tests/pytest-pypi/pytest_pypi/templates/artifact.html
new file mode 100644
--- /dev/null
+++ b/tests/pytest-pypi/pytest_pypi/templates/artifact.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="UTF-8">
+	<title>Links for {{ artifact.name }}</title>
+</head>
+<body>
+	<h1>Links for {{ artifact.name }}</h1>
+	{% for fn in artifact.files %}
+		<a href="/{{ artifact.name }}/{{ fn }}">{{ fn }}</a>
+		<br>
+	{% endfor %}
+</body>
+</html>
diff --git a/tests/pytest-pypi/pytest_pypi/templates/artifacts.html b/tests/pytest-pypi/pytest_pypi/templates/artifacts.html
new file mode 100644
--- /dev/null
+++ b/tests/pytest-pypi/pytest_pypi/templates/artifacts.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+	<meta charset="UTF-8">
+	<title>Artifact Index</title>
+</head>
+<body>
+	{% for artifact in artifacts %}
+		<a href="/artifacts/{{ artifact.name }}/" rel="internal">{{ artifact.name }}</a>
+		<br>
+	{% endfor %}
+</body>
+</html>
diff --git a/tests/test_artifacts/django/3.4.x.zip b/tests/test_artifacts/django/3.4.x.zip
new file mode 100644
Binary files /dev/null and b/tests/test_artifacts/django/3.4.x.zip differ
diff --git a/tests/test_artifacts/git/flask b/tests/test_artifacts/git/flask
new file mode 160000
--- /dev/null
+++ b/tests/test_artifacts/git/flask
@@ -0,0 +1 @@
+Subproject commit dcc02d6e7d4bf486e64aa5b6e55a75501c2ba2e5
diff --git a/tests/test_artifacts/git/jinja2 b/tests/test_artifacts/git/jinja2
new file mode 160000
--- /dev/null
+++ b/tests/test_artifacts/git/jinja2
@@ -0,0 +1 @@
+Subproject commit a7f1f528f5e77d5401a96aa326885508245f7c6f
diff --git a/tests/test_artifacts/git/pyinstaller b/tests/test_artifacts/git/pyinstaller
new file mode 160000
--- /dev/null
+++ b/tests/test_artifacts/git/pyinstaller
@@ -0,0 +1 @@
+Subproject commit 19d8a378987d6115c0acb72ef954bbf3bca3c61b