diff --git a/README.rst b/README.rst
index 5bf5425..0000958 100644
--- a/README.rst
+++ b/README.rst
@@ -48,6 +48,8 @@ From the client computer, type this::
## Download and Install hosted packages.
pip install --extra-index-url http://localhost:8080/simple/ ...
+ ## Search hosted packages
+ pip search --index http://localhost:8080/simple/ ...
See also `Client-side configurations`_ for avoiding tedious typing.
diff --git a/pypiserver/_app.py b/pypiserver/_app.py
index 98954ac..4bcbf6b 100644
--- a/pypiserver/_app.py
+++ b/pypiserver/_app.py
@@ -3,13 +3,17 @@ import logging
import mimetypes
import os
import re
-import sys
import zipfile
+import xml.dom.minidom
from . import __version__
from . import core
from .bottle import static_file, redirect, request, response, HTTPError, Bottle, template
+try:
+ import xmlrpc.client as xmlrpclib # py3
+except ImportError:
+ import xmlrpclib # py2
try:
from io import BytesIO
@@ -195,6 +199,32 @@ def pep_503_redirects(prefix=None):
return redirect(request.fullpath + "/", 301)
+@app.post('/RPC2')
+@auth("list")
+def handle_rpc():
+ """Handle pip-style RPC2 search requests"""
+ parser = xml.dom.minidom.parse(request.body)
+ methodname = parser.getElementsByTagName(
+ "methodName")[0].childNodes[0].wholeText.strip()
+ log.info("Processing RPC2 request for '%s'", methodname)
+ if methodname == 'search':
+ value = parser.getElementsByTagName(
+ "string")[0].childNodes[0].wholeText.strip()
+ response = []
+ ordering = 0
+ for p in packages():
+ if p.pkgname.count(value) > 0:
+ # We do not presently have any description/summary, returning
+ # version instead
+ d = {'_pypi_ordering': ordering, 'version': p.version,
+ 'name': p.pkgname, 'summary': p.version}
+ response.append(d)
+ ordering += 1
+ call_string = xmlrpclib.dumps((response,), 'search',
+ methodresponse=True)
+ return call_string
+
+
@app.route("/simple/")
@auth("list")
def simpleindex():
@@ -303,3 +333,4 @@ def bad_url(prefix):
p += "/simple/%s/" % prefix
return redirect(p)
+
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_app.py b/tests/test_app.py
index d53d3df..a488b9e 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -1,22 +1,27 @@
#! /usr/bin/env py.test
-import contextlib
-import glob
+# Builtin imports
import logging
-import os
-from pypiserver import __main__, bottle
-import subprocess
-
-import pytest
-import webtest
-
-import test_core
try:
from html.parser import HTMLParser
except ImportError:
from HTMLParser import HTMLParser
+try:
+ import xmlrpc.client as xmlrpclib
+except ImportError:
+ import xmlrpclib # legacy Python
+
+# Third party imports
+import pytest
+import webtest
+
+
+# Local Imports
+from pypiserver import __main__, bottle
+import tests.test_core as test_core
+
# Enable logging to detect any problems with it
##
@@ -37,11 +42,13 @@ def app(tmpdir):
@pytest.fixture
def testapp(app):
+ """Return a webtest TestApp initiated with pypiserver app"""
return webtest.TestApp(app)
@pytest.fixture
def root(tmpdir):
+ """Return a pytest temporary directory"""
return tmpdir
@@ -57,11 +64,24 @@ def testpriv(priv):
return webtest.TestApp(priv)
-@pytest.fixture(params=[" ", # Mustcontain test below fails when string is empty.
- "Hey there!",
- "
Hey there!",
- ])
+@pytest.fixture
+def search_xml():
+ """Return an xml dom suitable for passing to search"""
+ xml = 'searchtest'
+ return xml
+
+
+@pytest.fixture(params=[
+ " ", # Mustcontain test below fails when string is empty.
+ "Hey there!",
+ "Hey there!",
+])
def welcome_file_no_vars(request, root):
+ """Welcome file fixture
+
+ :param request: pytest builtin fixture
+ :param root: root temporary directory
+ """
wfile = root.join("testwelcome.html")
wfile.write(request.param)
@@ -84,6 +104,11 @@ def welcome_file_all_vars(request, root):
def test_root_count(root, testapp):
+ """Test that the welcome page count updates with added packages
+
+ :param root: root temporary directory fixture
+ :param testapp: webtest TestApp
+ """
resp = testapp.get("/")
resp.mustcontain("PyPI compatible package index serving 0 packages")
root.join("Twisted-11.0.0.tar.bz2").write("")
@@ -343,16 +368,19 @@ def test_cache_control_set(root):
assert "Cache-Control" in resp.headers
assert resp.headers["Cache-Control"] == 'public, max-age=%s' % AGE
+
def test_upload_noAction(root, testapp):
resp = testapp.post("/", expect_errors=1)
assert resp.status == '400 Bad Request'
assert "Missing ':action' field!" in hp.unescape(resp.text)
+
def test_upload_badAction(root, testapp):
resp = testapp.post("/", params={':action': 'BAD'}, expect_errors=1)
assert resp.status == '400 Bad Request'
assert "Unsupported ':action' field: BAD" in hp.unescape(resp.text)
+
@pytest.mark.parametrize(("package"), [f[0]
for f in test_core.files
if f[1] and '/' not in f[0]])
@@ -364,6 +392,7 @@ def test_upload(package, root, testapp):
assert len(uploaded_pkgs) == 1
assert uploaded_pkgs[0].lower() == package.lower()
+
@pytest.mark.parametrize(("package"), [f[0]
for f in test_core.files
if f[1] and '/' not in f[0]])
@@ -378,6 +407,7 @@ def test_upload_with_signature(package, root, testapp):
assert package.lower() in uploaded_pkgs
assert '%s.asc' % package.lower() in uploaded_pkgs
+
@pytest.mark.parametrize(("package"), [
f[0] for f in test_core.files
if f[1] is None])
@@ -407,6 +437,7 @@ def test_remove_pkg_missingNaveVersion(name, version, root, testapp):
assert resp.status == '400 Bad Request'
assert msg %(name, version) in hp.unescape(resp.text)
+
def test_remove_pkg_notFound(root, testapp):
resp = testapp.post("/", expect_errors=1,
params={
@@ -417,6 +448,62 @@ def test_remove_pkg_notFound(root, testapp):
assert resp.status == '404 Not Found'
assert "foo (123) not found" in hp.unescape(resp.text)
+
+@pytest.mark.parametrize('pkgs,matches', [
+ ([], []),
+ (['test-1.0.tar.gz'], [('test', '1.0')]),
+ (['test-1.0.tar.gz', 'test-test-2.0.1.tar.gz'],
+ [('test', '1.0'), ('test-test', '2.0.1')]),
+ (['test-1.0.tar.gz', 'other-2.0.tar.gz'], [('test', '1.0')]),
+ (['test-2.0-py2.py3-none-any.whl'], [('test', '2.0')]),
+ (['other-2.0.tar.gz'], [])
+])
+def test_search(root, testapp, search_xml, pkgs, matches):
+ """Test the search functionality at the RPC2 endpoint
+
+ Calls the handle_rpc function by posting to the WebTest server with
+ the string returned by the ``search_xml`` fixture as the request
+ body. The result is parsed for convenience by the xmlrpc.client
+ (xmlrpclib in Python 2.x). The parsed result is a 2-tuple. The
+ second item is the method called, in this case always "search". The
+ first item is a 1-tuple which contains a list of match information
+ as dicts, e.g:
+
+ ``(([{'version': '2.0', '_pypi_ordering': 0,
+ 'name': 'test', 'summary': '2.0'}],), 'search')``
+
+ The pkgs parameter is a list of items to write to the packages
+ directory, which should then be available for the subsequent
+ search. The matches parameter is a list of 2-tuples of the form
+ (``name``, ``version``), where ``name`` and ``version`` are the
+ expected name and version matches for a search for the "test"
+ package as specified by the search_xml fixture.
+
+ :param root: root temporry directory fixture; used as packages dir
+ for testapp
+ :param testapp: webtest TestApp
+ :param str search_xml: XML string roughly equivalent to a pip search
+ for "test"
+ :param pkgs: package file names to be written into packages
+ directory
+ :param matches: a list of 2-tuples containing expected (name,
+ version) matches for the "test" query
+ """
+ for pkg in pkgs:
+ root.join(pkg).write('')
+ resp = testapp.post('/RPC2', search_xml)
+ parsed = xmlrpclib.loads(resp.text)
+ assert len(parsed) == 2 and parsed[1] == 'search'
+ if not matches:
+ assert len(parsed[0]) == 1 and not parsed[0][0]
+ else:
+ assert len(parsed[0][0]) == len(matches) and parsed[0][0]
+ for returned in parsed[0][0]:
+ print(returned)
+ assert returned['name'] in [match[0] for match in matches]
+ assert returned['version'] in [match[1] for match in matches]
+
+
@pytest.mark.xfail()
def test_remove_pkg(root, testapp):
assert 0