Skip to content

Commit adb72f3

Browse files
authored
Publish an official package to PyPI (#40)
* Add mypy for checking types * Add flake8 and fix issues * Add interrogate and fix docstrings and type annotations * Add coveralls * Add complete test, lint, build and deploy pipeline to publish packages to PyPI * Add mypy stubs
1 parent a741f66 commit adb72f3

File tree

17 files changed

+375
-146
lines changed

17 files changed

+375
-146
lines changed

.flake8

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[flake8]
2+
max-line-length = 100
3+
per-file-ignores =
4+
django_saml2_auth/tests/test_saml.py: E501, F821
5+
exclude =
6+
django_saml2_auth.egg-info,
7+
dist,
8+
build,
9+
env,
10+
venv,
11+
.env,
12+
.venv,

.github/workflows/deploy.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
name: deploy
2+
3+
on: [push, pull_request]
4+
5+
jobs:
6+
test:
7+
name: Test and build django-saml2-auth
8+
runs-on: ubuntu-latest
9+
strategy:
10+
matrix:
11+
versions:
12+
- { "djangoVersion": "2.2.27", "pythonVersion": "3.7" }
13+
- { "djangoVersion": "2.2.27", "pythonVersion": "3.8" }
14+
- { "djangoVersion": "2.2.27", "pythonVersion": "3.9" }
15+
- { "djangoVersion": "2.2.27", "pythonVersion": "3.10" }
16+
- { "djangoVersion": "3.2.12", "pythonVersion": "3.7" }
17+
- { "djangoVersion": "3.2.12", "pythonVersion": "3.8" }
18+
- { "djangoVersion": "3.2.12", "pythonVersion": "3.9" }
19+
- { "djangoVersion": "3.2.12", "pythonVersion": "3.10" }
20+
- { "djangoVersion": "4.0.3", "pythonVersion": "3.8" }
21+
- { "djangoVersion": "4.0.3", "pythonVersion": "3.9" }
22+
- { "djangoVersion": "4.0.3", "pythonVersion": "3.10" }
23+
steps:
24+
- name: Checkout 🛎️
25+
uses: actions/checkout@v3
26+
- name: Set up Python 🐍
27+
uses: actions/setup-python@v3
28+
with:
29+
python-version: ${{ matrix.versions.pythonVersion }}
30+
- name: Install xmlsec1 📦
31+
run: sudo apt-get install xmlsec1
32+
- name: Install dependencies 📦
33+
run: python -m pip install -r requirements_test.txt && python -m pip install -e .
34+
- name: Install Django ${{ matrix.versions.djangoVersion }} 📦
35+
run: python -m pip install Django==${{ matrix.versions.djangoVersion }}
36+
- name: Check types and syntax 🦆
37+
run: |
38+
mypy .
39+
flake8 .
40+
interrogate --quiet --fail-under=95 .
41+
- name: Test Django ${{ matrix.versions.djangoVersion }} with coverage 🧪
42+
run: coverage run --source=django_saml2_auth -m pytest .
43+
- name: Submit coverage report to Coveralls 📈
44+
if: ${{ success() }}
45+
run: coveralls
46+
env:
47+
COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }}
48+
- name: Install build dependencies 📦
49+
run: python -m pip install build --user
50+
- name: Build a binary wheel and a source tarball 🏗️
51+
run: python -m build --sdist --wheel .
52+
- name: Publish package to PyPI 🎉
53+
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
54+
uses: pypa/gh-action-pypi-publish@release/v1
55+
with:
56+
user: __token__
57+
password: ${{ secrets.PYPI_API_TOKEN }}
58+
skip_existing: true

.github/workflows/test.yml

Lines changed: 0 additions & 37 deletions
This file was deleted.

django_saml2_auth/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
django-saml2-auth is a Django app that provides a SAML2 authentication backend.
3+
"""

django_saml2_auth/errors.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@
2929
NO_JWT_PRIVATE_KEY = 1126
3030
NO_JWT_PUBLIC_KEY = 1127
3131
INVALID_JWT_ALGORITHM = 1128
32+
NO_USER_ID = 1129
33+
INVALID_TOKEN = 1130
34+
INVALID_NEXT_URL = 1131

django_saml2_auth/exceptions.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
"""Custom exception class for handling extra arguments."""
22

33

4+
from typing import Any, Dict, Optional
5+
6+
47
class SAMLAuthError(Exception):
5-
extra = None
8+
"""Custom exception class for handling extra arguments."""
9+
10+
extra: Optional[Dict[str, Any]] = None
11+
12+
def __init__(self, msg: str, extra: Optional[Dict[str, Any]] = None):
13+
"""Initialize exception class.
614
7-
def __init__(self, msg, extra=None):
15+
Args:
16+
msg (str): Exception message.
17+
extra (Optional[Dict[str, Any]], optional): Extra arguments.
18+
Defaults to None.
19+
"""
820
self.message = msg
921
self.extra = extra

django_saml2_auth/saml.py

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,22 @@
33

44
from typing import Any, Callable, Dict, Mapping, Optional, Union
55

6-
from dictor import dictor
6+
from dictor import dictor # type: ignore
77
from django.conf import settings
88
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
99
from django.urls import NoReverseMatch
10-
from django_saml2_auth.errors import *
10+
from django_saml2_auth.errors import (ERROR_CREATING_SAML_CONFIG_OR_CLIENT,
11+
INVALID_METADATA_URL,
12+
NO_ISSUER_IN_SAML_RESPONSE,
13+
NO_METADATA_URL_ASSOCIATED,
14+
NO_METADATA_URL_OR_FILE,
15+
NO_NAME_ID_IN_SAML_RESPONSE,
16+
NO_SAML_CLIENT,
17+
NO_SAML_RESPONSE_FROM_CLIENT,
18+
NO_SAML_RESPONSE_FROM_IDP,
19+
NO_TOKEN_SPECIFIED,
20+
NO_USER_IDENTITY_IN_SAML_RESPONSE,
21+
NO_USERNAME_OR_EMAIL_SPECIFIED)
1122
from django_saml2_auth.exceptions import SAMLAuthError
1223
from django_saml2_auth.utils import get_reverse, run_hook
1324
from saml2 import BINDING_HTTP_POST, BINDING_HTTP_REDIRECT, entity
@@ -67,7 +78,7 @@ def validate_metadata_url(url: str) -> bool:
6778
http_client = HTTPBase()
6879
metadata = MetaDataExtern(None, url=url, http=http_client)
6980
metadata.load()
70-
except:
81+
except Exception:
7182
return False
7283

7384
return True
@@ -94,7 +105,7 @@ def get_metadata(user_id: Optional[str] = None) -> Mapping[str, Any]:
94105
saml2_auth_settings = settings.SAML2_AUTH
95106
get_metadata_trigger = dictor(saml2_auth_settings, "TRIGGER.GET_METADATA_AUTO_CONF_URLS")
96107
if get_metadata_trigger:
97-
metadata_urls = run_hook(get_metadata_trigger, user_id)
108+
metadata_urls = run_hook(get_metadata_trigger, user_id) # type: ignore
98109
if metadata_urls:
99110
# Filter invalid metadata URLs
100111
filtered_metadata_urls = list(
@@ -127,21 +138,25 @@ def get_metadata(user_id: Optional[str] = None) -> Mapping[str, Any]:
127138

128139
def get_saml_client(domain: str,
129140
acs: Callable[..., HttpResponse],
130-
user_id: str = None) -> Optional[Saml2Client]:
141+
user_id: Optional[str] = None) -> Optional[Saml2Client]:
131142
"""Create a new Saml2Config object with the given config and return an initialized Saml2Client
132143
using the config object. The settings are read from django settings key: SAML2_AUTH.
133144
134145
Args:
135146
domain (str): Domain name to get SAML config for
136147
acs (Callable[..., HttpResponse]): The acs endpoint
148+
user_id (str, optional): If passed, it will be further processed by the
149+
GET_METADATA_AUTO_CONF_URLS trigger, which will return the metadata URL corresponding
150+
to the given user identifier, either email or username. Defaults to None.
137151
138152
Raises:
139153
SAMLAuthError: Re-raise any exception raised by Saml2Config or Saml2Client
140154
141155
Returns:
142156
Optional[Saml2Client]: A Saml2Client or None
143157
"""
144-
acs_url = domain + get_reverse([acs, "acs", "django_saml2_auth:acs"])
158+
# get_reverse raises an exception if the view is not found, so we can safely ignore type errors
159+
acs_url = domain + get_reverse([acs, "acs", "django_saml2_auth:acs"]) # type: ignore
145160
metadata = get_metadata(user_id)
146161
if (("local" in metadata and not metadata["local"]) or
147162
("remote" in metadata and not metadata["remote"])):
@@ -154,7 +169,7 @@ def get_saml_client(domain: str,
154169

155170
saml2_auth_settings = settings.SAML2_AUTH
156171

157-
saml_settings = {
172+
saml_settings: Dict[str, Any] = {
158173
"metadata": metadata,
159174
"allow_unknown_attributes": True,
160175
"debug": saml2_auth_settings.get("DEBUG", False),
@@ -208,7 +223,8 @@ def get_saml_client(domain: str,
208223

209224
def decode_saml_response(
210225
request: HttpRequest,
211-
acs: Callable[..., HttpResponse]) -> Union[HttpResponseRedirect, Optional[AuthnResponse]]:
226+
acs: Callable[..., HttpResponse]) -> Union[
227+
HttpResponseRedirect, Optional[AuthnResponse], None]:
212228
"""Given a request, the authentication response inside the SAML response body is parsed,
213229
decoded and returned. If there are any issues parsing the request, the identity or the issuer,
214230
an exception is raised.
@@ -225,8 +241,8 @@ def decode_saml_response(
225241
SAMLAuthError: No user identity in SAML response.
226242
227243
Returns:
228-
Union[HttpResponseRedirect, Optional[AuthnResponse]]: Returns an AuthnResponse object for
229-
extracting user identity from.
244+
Union[HttpResponseRedirect, Optional[AuthnResponse], None]: Returns an AuthnResponse
245+
object for extracting user identity from.
230246
"""
231247
saml_client = get_saml_client(get_assertion_url(request), acs)
232248
if not saml_client:
@@ -313,8 +329,8 @@ def extract_user_identity(user_identity: Dict[str, Any]) -> Dict[str, Optional[A
313329
user["first_name"] = dictor(user_identity, f"{firstname_field}/0", pathsep="/")
314330
user["last_name"] = dictor(user_identity, f"{lastname_field}/0", pathsep="/")
315331

316-
TOKEN_REQUIRED = dictor(saml2_auth_settings, "TOKEN_REQUIRED", default=True)
317-
if TOKEN_REQUIRED:
332+
token_required = dictor(saml2_auth_settings, "TOKEN_REQUIRED", default=True)
333+
if token_required:
318334
token_field = dictor(saml2_auth_settings, "ATTRIBUTES_MAP.token", default="token")
319335
user["token"] = dictor(user_identity, f"{token_field}.0")
320336

@@ -334,7 +350,7 @@ def extract_user_identity(user_identity: Dict[str, Any]) -> Dict[str, Optional[A
334350
"status_code": 422
335351
})
336352

337-
if TOKEN_REQUIRED and not user.get("token"):
353+
if token_required and not user.get("token"):
338354
raise SAMLAuthError("No token specified.", extra={
339355
"exc_type": ValueError,
340356
"error_code": NO_TOKEN_SPECIFIED,
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""
2+
Tests for django_saml2_auth.
3+
"""

django_saml2_auth/tests/settings.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1+
"""
2+
Django settings for tests.
3+
"""
4+
15
import os
6+
from typing import List
27

38
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
49
SECRET_KEY = "SECRET"
510
DEBUG = True
6-
ALLOWED_HOSTS = []
11+
ALLOWED_HOSTS: List[str] = []
712
INSTALLED_APPS = [
813
"django.contrib.admin",
914
"django.contrib.auth",
@@ -84,7 +89,8 @@
8489
},
8590
"TRIGGER": {
8691
"BEFORE_LOGIN": "django_saml2_auth.tests.test_user.saml_user_setup",
87-
"GET_METADATA_AUTO_CONF_URLS": "django_saml2_auth.tests.test_saml.get_metadata_auto_conf_urls"
92+
"GET_METADATA_AUTO_CONF_URLS":
93+
"django_saml2_auth.tests.test_saml.get_metadata_auto_conf_urls"
8894
},
8995
"ASSERTION_URL": "https://api.example.com",
9096
"ENTITY_ID": "https://api.example.com/sso/acs/",

django_saml2_auth/tests/test_saml.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
"""
2+
Tests for saml.py
3+
"""
4+
15
from typing import Optional, List, Mapping
26

37
import pytest
@@ -109,21 +113,25 @@ def get_user_identity() -> Mapping[str, List[str]]:
109113

110114

111115
def mock_parse_authn_request_response(
112-
self: Saml2Client, response: AuthnResponse, binding: str) -> "MockAuthnResponse":
116+
self: Saml2Client, response: AuthnResponse, binding: str
117+
) -> "MockAuthnResponse": # type: ignore
113118
"""Mock function to return an mocked instance of AuthnResponse.
114119
115120
Returns:
116121
MockAuthnResponse: A mocked instance of AuthnResponse
117122
"""
118123
class MockAuthnRequest:
124+
"""Mock class for AuthnRequest."""
119125
name_id = "Username"
120126

121127
@staticmethod
122128
def issuer():
129+
"""Mock function for AuthnRequest.issuer()."""
123130
return METADATA_URL1
124131

125132
@staticmethod
126133
def get_identity():
134+
"""Mock function for AuthnRequest.get_identity()."""
127135
return get_user_identity()
128136

129137
return MockAuthnRequest()
@@ -168,6 +176,7 @@ def test_get_default_next_url_no_default_next_url(settings: SettingsWrapper):
168176

169177
# This doesn't happen on a real instance, unless you don't have "admin:index" route
170178
assert str(exc_info.value) == "We got a URL reverse issue: ['admin:index']"
179+
assert exc_info.value.extra is not None
171180
assert issubclass(exc_info.value.extra["exc_type"], NoReverseMatch)
172181

173182

@@ -332,11 +341,13 @@ def test_get_saml_client_failure_with_invalid_file(settings: SettingsWrapper):
332341
get_saml_client("example.com", acs)
333342

334343
assert str(exc_info.value) == "[Errno 2] No such file or directory: '/invalid/metadata.xml'"
344+
assert exc_info.value.extra is not None
335345
assert isinstance(exc_info.value.extra["exc"], FileNotFoundError)
336346

337347

338348
@responses.activate
339-
def test_decode_saml_response_success(settings: SettingsWrapper, monkeypatch: "MonkeyPatch"):
349+
def test_decode_saml_response_success(
350+
settings: SettingsWrapper, monkeypatch: "MonkeyPatch"): # type: ignore
340351
"""Test decode_saml_response function to verify if it correctly decodes the SAML response.
341352
342353
Args:
@@ -352,13 +363,13 @@ def test_decode_saml_response_success(settings: SettingsWrapper, monkeypatch: "M
352363
"parse_authn_request_response",
353364
mock_parse_authn_request_response)
354365
result = decode_saml_response(post_request, acs)
355-
assert len(result.get_identity()) > 0
366+
assert len(result.get_identity()) > 0 # type: ignore
356367

357368

358369
def test_extract_user_identity_success():
359370
"""Test extract_user_identity function to verify if it correctly extracts user identity
360371
information from a (pysaml2) parsed SAML response."""
361-
result = extract_user_identity(get_user_identity())
372+
result = extract_user_identity(get_user_identity()) # type: ignore
362373
assert len(result) == 6
363374
assert result["username"] == result["email"] == "[email protected]"
364375
assert result["first_name"] == "John"
@@ -372,6 +383,6 @@ def test_extract_user_identity_token_not_required(settings: SettingsWrapper):
372383
information from a (pysaml2) parsed SAML response when token is not required."""
373384
settings.SAML2_AUTH["TOKEN_REQUIRED"] = False
374385

375-
result = extract_user_identity(get_user_identity())
386+
result = extract_user_identity(get_user_identity()) # type: ignore
376387
assert len(result) == 5
377388
assert "token" not in result

0 commit comments

Comments
 (0)