Skip to content

Commit

Permalink
Merge branch 'keyring'
Browse files Browse the repository at this point in the history
  • Loading branch information
hoefling committed May 23, 2019
2 parents e8e3150 + 4347692 commit 8663727
Show file tree
Hide file tree
Showing 13 changed files with 377 additions and 78 deletions.
4 changes: 3 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ before_install:
fi
install:
- pip install poetry tox tox-travis --upgrade
# workaround for poetry missing setuptools-scm support
- pip install toml setuptools-scm --upgrade
- python travis/setversion.py
script:
- unbuffer tox -vv
- unbuffer tox
after_success:
- rm -rf dist/
- poetry build -vvv --no-interaction --format=wheel
deploy:
- provider: releases
Expand Down
25 changes: 23 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ devpi-client-extensions

Some useful stuff around `devpi client`_. Although this package is proudly named *extensions*,
currently there is only one thing implemented ready to be used: a hook that uses passwords from
``.pypirc`` on login to devpi server so you don't have to enter your password if you store it for upload anyway.
``.pypirc`` or `keyring`_ on login to devpi server so you don't have to enter your password
if you store it for upload anyway.

Install
-------
Expand All @@ -23,6 +24,24 @@ Just use the ``devpi login`` command as usual:
Using hoefling credentials from .pypirc
logged in 'hoefling', credentials valid for 10.00 hours
Keyring Support
---------------

Since version 0.3, reading credentials using `keyring`_ is supported. Install the package with `keyring` extras:

.. code-block:: sh
$ pip install devpi-client-extensions[keyring]
Example with storing the password in keyring:

.. code-block:: sh
$ keyring set https://my.devpi.url/ hoefling
$ devpi login hoefling
Using hoefling credentials from keyring
logged in 'hoefling', credentials valid for 10.00 hours
Stats
-----

Expand Down Expand Up @@ -55,4 +74,6 @@ Stats
.. |black| image:: https://img.shields.io/badge/code%20style-black-000000.svg
:target: https://github.com/ambv/black

.. _devpi client: https://github.com/devpi/devpi
.. _devpi client: https://pypi.org/project/devpi-client/

.. _keyring: https://pypi.org/project/keyring/
191 changes: 186 additions & 5 deletions poetry.lock

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,22 @@ packages = [
]

[tool.poetry.plugins.'devpi_client']
'devpi-client-ext-login' = 'devpi_ext.login:_pypirc'
'devpi-client-ext-login-pypirc' = 'devpi_ext.login:_pypirc_plugin'
'devpi-client-ext-login-keyring' = 'devpi_ext.login:_keyring_plugin [keyring]'

[tool.poetry.dependencies]
python = '~2.7 || ^3.4'
devpi-client = '>=3.0.0'
keyring = { version = '*', optional = true }

# workaround for https://github.com/sdispater/poetry/issues/1121
jeepney = { version = '*', optional = true, python = '>=3.5' }

[tool.poetry.extras]
keyring = ['keyring', 'jeepney']

[tool.poetry.dev-dependencies]
#black = {version = '>=19.3b0', allows-prereleases = true}
black = { version = '>=19.3b0', allows-prereleases = true, python = '^3.6' }
flake8 = '^3.7'
pytest = '^4.0'
pytest-cov = '^2.7'
Expand Down
20 changes: 19 additions & 1 deletion src/devpi_ext/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@
import ConfigParser as configparser
import os

try:
from keyring import get_password
except ImportError:

def get_password(service_name, username):
return None


import devpi.main


Expand Down Expand Up @@ -57,4 +65,14 @@ def _find_password(fp, url, username):
)


_pypirc = PypircPlugin()
class KeyringPlugin:
@devpi.main.hookimpl(tryfirst=True)
def devpiclient_get_password(self, url, username):
password = get_password(url, username)
if password:
print('Using {} credentials from keyring'.format(username))
return password


_pypirc_plugin = PypircPlugin()
_keyring_plugin = KeyringPlugin()
5 changes: 4 additions & 1 deletion src/devpi_ext/login.pyi
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from typing import Optional, Text, TextIO
from typing import Optional, Text, TextIO, Tuple

class PypircPlugin:
def devpiclient_get_password(self, url: Text, username: Text) -> Optional[Text]: ...

def _find_password(fp: TextIO, url: Text, username: Text) -> Optional[Text]: ...

class KeyringPlugin:
def devpiclient_get_password(self, url: Text, username: Text) -> Optional[Text]: ...
39 changes: 39 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import sys
import pytest


try:
import configparser
except ImportError: # pragma: no cover
# python2 compat
import ConfigParser as configparser


@pytest.fixture
def unload_imports():
for mod in ('devpi_ext', 'devpi_ext.login'):
try:
del sys.modules[mod]
except KeyError:
continue


@pytest.fixture
def pypirc(monkeypatch, tmp_path):
parser = configparser.ConfigParser()
parser.read_dict(
{
'distutils': {'index-servers': '\nfizz'},
'fizz': {
'repository': 'http://fizz',
'username': 'fizz',
'password': 'fizz',
},
}
)
pypirc = tmp_path / '.pypirc'
with pypirc.open('w', encoding='utf-8') as fp:
parser.write(fp)
with monkeypatch.context() as m:
m.setattr('os.path.expanduser', lambda *args: str(tmp_path))
yield
46 changes: 14 additions & 32 deletions tests/test_hook_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,42 @@
import pytest
from _pytest import pathlib

try:
import configparser
except ImportError: # pragma: no cover
# python2 compat
import ConfigParser as configparser


@pytest.fixture(scope='session')
def plugin_name():
@pytest.fixture(scope='session', params=('_pypirc_plugin', '_keyring_plugin'))
def plugin_name(request):
pyproj = pathlib.Path(__file__, '..', '..', 'pyproject.toml').resolve()
conf = toml.load(str(pyproj)) # python < 3.6 compat
return next(
k
for k, v in conf['tool']['poetry']['plugins']['devpi_client'].items()
if v == login.__name__ + ':_pypirc'
if v.startswith('{}:{}'.format(login.__name__, request.param))
)


@pytest.fixture
def pypirc(monkeypatch, tmp_path):
parser = configparser.ConfigParser()
parser.read_dict(
{
'distutils': {'index-servers': '\nfizz'},
'fizz': {
'repository': 'http://fizz',
'username': 'fizz',
'password': 'fizz',
},
}
)
pypirc = tmp_path / '.pypirc'
with pypirc.open('w', encoding='utf-8') as fp:
parser.write(fp)
with monkeypatch.context() as m:
m.setattr('os.path.expanduser', lambda *args: str(tmp_path))
yield


def test_devpi_ext_plugin_registered(plugin_name):
pm = get_pluginmanager()
assert pm.is_registered(pm.get_plugin(plugin_name))


def test_get_password_pypirc_hookimpl_is_tryfirst(plugin_name):
def test_hookimpl_is_tryfirst(plugin_name):
pm = get_pluginmanager()
impls = pm.hook.devpiclient_get_password.get_hookimpls()
print(impls)
impl = next(i for i in impls if i.plugin_name == plugin_name)
assert impl.tryfirst is True


@pytest.mark.usefixtures('pypirc')
def test_get_password_pypirc_hook_called(monkeypatch):
@pytest.mark.parametrize('plugin', ('_pypirc_plugin', '_keyring_plugin'))
def test_devpi_ext_plugin_hook_called(monkeypatch, plugin):
monkeypatch.setattr(login, 'get_password', lambda service, user: 'fizz')
monkeypatch.delattr('devpi.login.devpiclient_get_password')
plugin_cls = login.__dict__[plugin].__class__
monkeypatch.delattr(
'{}.{}.devpiclient_get_password'.format(
plugin_cls.__module__, plugin_cls.__name__
)
)
pm = get_pluginmanager()
pw = pm.hook.devpiclient_get_password(url='http://fizz', username='fizz')
assert pw == 'fizz'
45 changes: 45 additions & 0 deletions tests/test_keyring_password_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
try:
import builtins
except ImportError:
import __builtin__ as builtins
import pytest


@pytest.fixture
def no_keyring_installed(monkeypatch):
import_orig = builtins.__import__

def mocked_import(name, *args):
if name == 'keyring':
print('xoxoxo')
raise ImportError()
return import_orig(name, *args)

with monkeypatch.context() as m:
m.setattr(builtins, '__import__', mocked_import)
yield


@pytest.mark.usefixtures('unload_imports', 'no_keyring_installed')
def test_password_is_none_when_keyring_not_importable():
from devpi_ext import login

assert login.KeyringPlugin().devpiclient_get_password('http://fizz', 'buzz') is None


@pytest.mark.usefixtures('unload_imports')
def test_password_is_none_when_keyring_misses_credentials(monkeypatch):
monkeypatch.setattr('keyring.get_password', lambda service, user: None)
from devpi_ext import login

assert login.KeyringPlugin().devpiclient_get_password('http://fizz', 'buzz') is None


@pytest.mark.usefixtures('unload_imports')
def test_password_is_found_when_keyring_stores_credentials(monkeypatch):
monkeypatch.setattr('keyring.get_password', lambda service, user: 'fuzz')
from devpi_ext import login

assert (
login.KeyringPlugin().devpiclient_get_password('http://fizz', 'buzz') == 'fuzz'
)
47 changes: 25 additions & 22 deletions tests/test_pypirc_password_hook.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import pytest
from devpi_ext import login

try:
from StringIO import StringIO # python2
import __builtin__ as builtins
except ImportError:
from io import StringIO # python3

from devpi_ext import login
import builtins


section = ['[foo]', 'repository: http://foo', 'username: bar', 'password: baz']
Expand Down Expand Up @@ -35,30 +38,30 @@ def test_password_is_none_when_password_missing():
assert login._find_password(fp, 'http://foo', 'bar') is None


def test_password_is_none_when_pypirc_missing(mocker):
m = mocker.patch('os.path.isfile')
m.return_value = False
assert login.PypircPlugin().devpiclient_get_password('http://foo', 'bar') is None
def test_password_is_none_when_pypirc_missing(monkeypatch, tmp_path):
monkeypatch.setattr('os.path.expanduser', lambda *args: str(tmp_path))
assert (tmp_path / '.pypirc').is_file() is False
assert login.PypircPlugin().devpiclient_get_password('http://fizz', 'fizz') is None


def test_password_is_none_when_pypirc_not_readable(mocker):
m = mocker.mock_open()
m.side_effect = IOError
mocker.patch('devpi_ext.login.open', m, create=True)
assert login.PypircPlugin().devpiclient_get_password('http://foo', 'bar') is None
def test_password_is_none_when_pypirc_not_readable(monkeypatch):
def open_(*args, **kwargs):
raise IOError()

monkeypatch.setattr(builtins, 'open', open_)
assert login.PypircPlugin().devpiclient_get_password('http://fizz', 'fizz') is None

def test_password_is_none_when_pypirc_misses_credentials(mocker):
m = mocker.mock_open(read_data='\n'.join(section))
m.return_value.__iter__ = lambda self: iter(self.readline, '')
m.return_value.__next__ = lambda self: next(iter(self.readline, ''))
mocker.patch('devpi_ext.login.open', m, create=True)
assert login.PypircPlugin().devpiclient_get_password('http://foo', 'fizz') is None

@pytest.mark.usefixtures('pypirc')
def test_password_is_none_when_pypirc_misses_credentials():
assert (
login.PypircPlugin().devpiclient_get_password('http://missing', 'missing')
is None
)


@pytest.mark.usefixtures('pypirc')
def test_password_is_found_when_pypirc_present_and_readable(mocker):
m = mocker.mock_open(read_data='\n'.join(section))
m.return_value.__iter__ = lambda self: iter(self.readline, '')
m.return_value.__next__ = lambda self: next(iter(self.readline, ''))
mocker.patch('devpi_ext.login.open', m, create=True)
assert login.PypircPlugin().devpiclient_get_password('http://foo', 'bar') == 'baz'
assert (
login.PypircPlugin().devpiclient_get_password('http://fizz', 'fizz') == 'fizz'
)
10 changes: 0 additions & 10 deletions tests/test_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import sys
import pytest
from pkg_resources import DistributionNotFound

Expand All @@ -7,15 +6,6 @@ class DistMock:
version = '1.2.3.dev123'


@pytest.fixture
def unload_imports():
for mod in ('devpi_ext', 'devpi_ext.login'):
try:
del sys.modules[mod]
except KeyError:
continue


@pytest.mark.usefixtures('unload_imports')
def test_version_when_package_installed(monkeypatch):
# happy testing
Expand Down
6 changes: 4 additions & 2 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ whitelist_externals =
py37: codecov
skip_install = true
commands =
poetry install -v
poetry install -v --extras "keyring"
poetry build -v
# poetry run pip install --force-reinstall devpi-client-extensions[keyring] --pre --find-links=dist/
poetry run pytest -v --cov-branch --cov-report term --cov-report xml:coverage.xml --cov=devpi_ext --junitxml=unittests.xml
py37: codecov

Expand All @@ -32,7 +34,7 @@ deps =
pyre-check
unify
commands =
poetry install -v
poetry install -v --extras "keyring"
poetry run flake8 --max-line-length=88 src/ tests/
poetry run black --check src tests
poetry run unify --quote "'" --check-only --recursive .
Expand Down
Loading

0 comments on commit 8663727

Please sign in to comment.