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

Housekeeping #27

Merged
merged 12 commits into from
May 7, 2024
6 changes: 4 additions & 2 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up sources
uses: actions/checkout@v2
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Publish to PyPI
env:
Expand Down
21 changes: 15 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,36 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up sources
uses: actions/checkout@v2
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Run ruff
run: pipx run tox -e lint
env:
RUFF_OUTPUT_FORMAT: github

test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
os: [ubuntu-latest]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
include:
- os: macos-latest
python-version: "3.12"
- os: windows-latest
python-version: "3.12"

runs-on: ${{ matrix.os }}
steps:
- name: Set up sources
uses: actions/checkout@v2
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

Expand All @@ -44,4 +53,4 @@ jobs:
if: matrix.os == 'macos-latest'

- name: Run pytest
run: pipx run tox -e py3
run: pipx run tox -e test
36 changes: 29 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "httpie-credential-store"
version = "3.1.0"
Expand All @@ -14,19 +18,37 @@ python = "^3.8"
httpie = "^3.1"
keyring = ">= 23.5"

[tool.poetry.dev-dependencies]
[tool.poetry.group.lint]
optional = true

[tool.poetry.group.lint.dependencies]
ruff = "^0.4.2"

[tool.poetry.group.test]
optional = true

[tool.poetry.group.test.dependencies]
pytest = "^7.1"
responses = "^0.20"
mock = "^4.0"
pytest-github-actions-annotate-failures = "*"

[tool.poetry.plugins."httpie.plugins.auth.v1"]
credential-store = "httpie_credential_store:CredentialStoreAuthPlugin"
creds = "httpie_credential_store:CredsAuthPlugin"

[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

[tool.ruff]
line-length = 88
line-length = 100
target-version = "py38"

[tool.ruff.lint]
select = ["ALL"]
ignore = ["ANN", "D", "PTH", "PLR", "PT005", "ISC001", "INP001", "S603", "S607", "COM812"]

[tool.ruff.lint.per-file-ignores]
"src/httpie_credential_store/_keychain.py" = ["S602"]
"tests/*" = ["S101", "INP001"]

[tool.ruff.lint.isort]
known-first-party = ["httpie_credential_store"]
lines-after-imports = 2
lines-between-types = 1
24 changes: 12 additions & 12 deletions src/httpie_credential_store/_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class HTTPBasicAuth(requests.auth.HTTPBasicAuth, AuthProvider):
name = "basic"

def __init__(self, *, username, password):
super(HTTPBasicAuth, self).__init__(username, get_secret(password))
super().__init__(username, get_secret(password))


class HTTPDigestAuth(requests.auth.HTTPDigestAuth, AuthProvider):
Expand All @@ -54,7 +54,7 @@ class HTTPDigestAuth(requests.auth.HTTPDigestAuth, AuthProvider):
name = "digest"

def __init__(self, *, username, password):
super(HTTPDigestAuth, self).__init__(username, get_secret(password))
super().__init__(username, get_secret(password))


class HTTPHeaderAuth(requests.auth.AuthBase, AuthProvider):
Expand All @@ -67,18 +67,20 @@ def __init__(self, *, name, value):
self._value = get_secret(value)

if not is_legal_header_name(self._name):
raise ValueError(
error_message = (
f"HTTP header authentication provider received invalid "
f"header name: {self._name!r}. Please remove illegal "
f"characters and try again."
)
raise ValueError(error_message)

if is_illegal_header_value(self._value):
raise ValueError(
error_message = (
f"HTTP header authentication provider received invalid "
f"header value: {self._value!r}. Please remove illegal "
f"characters and try again."
)
raise ValueError(error_message)

def __call__(self, request):
request.headers[self._name] = self._value
Expand All @@ -95,18 +97,20 @@ def __init__(self, *, token, scheme="Bearer"):
self._token = get_secret(token)

if is_illegal_header_value(self._scheme):
raise ValueError(
error_message = (
f"HTTP token authentication provider received scheme that "
f"contains illegal characters: {self._scheme!r}. Please "
f"remove these characters and try again."
)
raise ValueError(error_message)

if is_illegal_header_value(self._token):
raise ValueError(
error_message = (
f"HTTP token authentication provider received token that "
f"contains illegal characters: {self._token!r}. Please "
f"remove these characters and try again."
)
raise ValueError(error_message)

def __call__(self, request):
request.headers["Authorization"] = f"{self._scheme} {self._token}"
Expand All @@ -119,19 +123,15 @@ class HTTPMultipleAuth(requests.auth.AuthBase, AuthProvider):
name = "multiple"

def __init__(self, *, providers):
self._providers = [
get_auth(provider.pop("provider"), **provider) for provider in providers
]
self._providers = [get_auth(provider.pop("provider"), **provider) for provider in providers]

def __call__(self, request):
for provider in self._providers:
request = provider(request)
return request


_PROVIDERS = {
provider_cls.name: provider_cls for provider_cls in AuthProvider.__subclasses__()
}
_PROVIDERS = {provider_cls.name: provider_cls for provider_cls in AuthProvider.__subclasses__()}


def get_auth(provider, **kwargs):
Expand Down
20 changes: 10 additions & 10 deletions src/httpie_credential_store/_keychain.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ class ShellKeychain(KeychainProvider):

def get(self, *, command):
try:
return subprocess.check_output(command, shell=True).decode("UTF-8")
return subprocess.check_output(command, shell=True, text=True)
except subprocess.CalledProcessError as exc:
raise LookupError(f"No secret found: {exc}")
error_message = f"No secret found: {exc}"
raise LookupError(error_message) from exc


class PasswordStoreKeychain(ShellKeychain):
Expand All @@ -40,10 +41,11 @@ def get(self, *, name):
try:
# password-store may store securely extra information along with a
# password. Nevertheless, a password is always a first line.
text = super(PasswordStoreKeychain, self).get(command=f"pass {name}")
text = subprocess.check_output(["pass", name], text=True)
return text.splitlines()[0]
except LookupError:
raise LookupError(f"password-store: no secret found: '{name}'")
except subprocess.CalledProcessError as exc:
error_message = f"password-store: no secret found: '{name}'"
raise LookupError(error_message) from exc


class SystemKeychain(KeychainProvider):
Expand All @@ -57,17 +59,15 @@ def __init__(self):
def get(self, *, service, username):
secret = self._keyring.get_password(service, username)
if not secret:
raise LookupError(
error_message = (
f"No secret found for '{service}' service and '{username}' "
f"username in '{self.name}' keychain."
)
raise LookupError(error_message)
return secret


_PROVIDERS = {
provider_cls.name: provider_cls
for provider_cls in KeychainProvider.__subclasses__()
}
_PROVIDERS = {provider_cls.name: provider_cls for provider_cls in KeychainProvider.__subclasses__()}


def get_keychain(provider):
Expand Down
4 changes: 3 additions & 1 deletion src/httpie_credential_store/_plugin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""HTTPie Credential Store Auth Plugin."""

import requests
import httpie.plugins
import requests

from ._store import get_credential_store

Expand All @@ -23,6 +23,8 @@ class CredentialStoreAuthPlugin(httpie.plugins.AuthPlugin):
auth_parse = False # do not parse '-a' content

def get_auth(self, username=None, password=None):
_ = username
_ = password
credential_id = self.raw_auth

class CredentialStoreAuth(requests.auth.AuthBase):
Expand Down
14 changes: 8 additions & 6 deletions src/httpie_credential_store/_store.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"""Credentials are managed here."""

import io
import json
import os
import re
Expand All @@ -12,7 +11,7 @@
from ._auth import get_auth


class CredentialStore(object):
class CredentialStore:
"""Credential store, manages your credentials."""

def __init__(self, credentials):
Expand Down Expand Up @@ -42,10 +41,11 @@ def get_credential_store(name, directory=httpie.config.DEFAULT_CONFIG_DIR):
credential_file = os.path.join(directory, name)

if not os.path.exists(credential_file):
raise FileNotFoundError(
error_message = (
f"Credentials file '{credential_file}' is not found; "
f"please create one and try again."
)
raise FileNotFoundError(error_message)

mode = stat.S_IMODE(os.stat(credential_file).st_mode)

Expand All @@ -56,20 +56,22 @@ def get_credential_store(name, directory=httpie.config.DEFAULT_CONFIG_DIR):
# ignore this platform for a while.
if sys.platform != "win32":
if mode & 0o077 > 0o000:
raise PermissionError(
error_message = (
f"Permissions '{mode:04o}' for '{credential_file}' are too "
f"open; please ensure your credentials file is NOT accessible "
f"by others."
)
raise PermissionError(error_message)

if mode & 0o400 != 0o400:
raise PermissionError(
error_message = (
f"Permissions '{mode:04o}' for '{credential_file}' are too "
f"close; please ensure your credentials file CAN be read by "
f"you."
)
raise PermissionError(error_message)

with io.open(credential_file, encoding="UTF-8") as f:
with open(credential_file, encoding="UTF-8") as f:
credentials = json.load(f)

return CredentialStore(credentials)
15 changes: 6 additions & 9 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,20 @@
import os
import tempfile

import mock
import pytest


@pytest.fixture(scope="session", autouse=True)
def _httpie_config_dir():
def _httpie_config_dir(tmp_path_factory: pytest.TempPathFactory):
"""Set path to HTTPie configuration directory."""

# HTTPie can optionally read a path to configuration directory from
# environment variable. In order to avoid messing with user's local
# configuration, HTTPIE_CONFIG_DIR environment variable is patched to point
# to a temporary directory instead. But here's the thing, HTTPie is not ran
# in subprocess in these tests, and so the environment variable is read
# only once on first package import. That's why it must be set before
# just once on first package import. That's why it must be set before
# HTTPie package is imported and that's why the very same value must be
# used for all tests (session scope). Otherwise, tests may fail because
# they will look for credentials file in different directory.
with tempfile.TemporaryDirectory() as tmpdir:
with mock.patch.dict(os.environ, {"HTTPIE_CONFIG_DIR": tmpdir}):
yield tmpdir
with pytest.MonkeyPatch.context() as monkeypatch:
tmp_path = tmp_path_factory.mktemp(".httpie")
monkeypatch.setenv("HTTPIE_CONFIG_DIR", str(tmp_path))
yield tmp_path
Loading