From 391625cc27ab22f110de0cdd1baa55857b255414 Mon Sep 17 00:00:00 2001 From: plasticuproject <19690649+plasticuproject@users.noreply.github.com> Date: Sat, 31 Aug 2024 23:03:14 -0400 Subject: [PATCH] Updated to the modern era --- .github/workflows/python-publish.yml | 103 +++++++++++++++++++++------ .github/workflows/tests.yml | 10 +-- README.md | 2 +- hibpwned/__init__.py | 66 ++++++++++------- pyproject.toml | 27 +++++-- requirements.txt | 1 - setup.py | 31 -------- test.py | 25 +++---- 8 files changed, 162 insertions(+), 103 deletions(-) delete mode 100644 requirements.txt delete mode 100644 setup.py diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 3e720c1..29dc052 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,28 +1,87 @@ -name: Upload Python Package - -on: - push: - branches: [ master ] - +# name: Publish Python 🐍 distribution 📦 to PyPI +name: Build Python distribution and Release + +on: push + jobs: - deploy: - + build: + name: Build distribution 📦 runs-on: ubuntu-latest - + steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install pypa/build + run: >- + python3 -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: >- + Publish Python 🐍 distribution 📦 to PyPI + if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes + needs: + - build + runs-on: ubuntu-latest + environment: + name: Pypi + url: https://pypi.org/p/hibpwned + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: >- + Sign the Python 🐍 distribution 📦 with Sigstore + and upload them to GitHub Release + if: startsWith(github.ref, 'refs/tags/') + needs: + - publish-to-pypi + runs-on: ubuntu-latest + + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 with: - python-version: '3.8' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python setup.py sdist - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + name: python-package-distributions + path: dist/ + - name: Sign the dists with Sigstore + uses: sigstore/gh-action-sigstore-python@v3.0.0 with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release create + '${{ github.ref_name }}' dist/** + --repo '${{ github.repository }}' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bdad9a3..508f17f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,15 +8,15 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.8 - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: "3.8" + python-version: "3.11" - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install requests pip install flake8 coverage>=6.3 mypy types-requests - name: Lint with flake8 run: | diff --git a/README.md b/README.md index 2973d25..5f329c9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![build](https://github.com/plasticuproject/hibpwned/actions/workflows/tests.yml/badge.svg)](https://github.com/plasticuproject/hibpwned/actions/workflows/tests.yml) -[![Python 3.8](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/release/python-380/) +[![Python 3.11](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/release/python-311/) [![License: LGPL v3](https://img.shields.io/badge/License-LGPL%20v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0) [![PyPI version](https://badge.fury.io/py/hibpwned.svg)](https://badge.fury.io/py/hibpwned) [![Downloads](https://pepy.tech/badge/hibpwned)](https://pepy.tech/project/hibpwned) diff --git a/hibpwned/__init__.py b/hibpwned/__init__.py index 9e03748..e61b904 100644 --- a/hibpwned/__init__.py +++ b/hibpwned/__init__.py @@ -13,9 +13,8 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. """ - +from __future__ import annotations import hashlib -from typing import Dict, List, Union, Optional import requests @@ -197,21 +196,23 @@ class Pwned: >>> foo = Pwned("test@example.com", "My_App", "My_API_Key") >>> data = foo.search_password("BadPassword") """ - ReturnAlias = (Union[int, List[Dict[str, Union[str, int, bool]]]]) - AltReturnAlias = (Union[int, List[Dict[str, Union[str, int, bool]]], - List[str]]) - DataAlias = (Union[List[Dict[str, Union[str, int, bool]]], - Dict[str, Union[str, int, bool]]]) - AltDataAlias = (Union[List[Dict[str, Union[str, int, bool]]], - Dict[str, Union[str, int, bool]], List[str]]) + ReturnAlias = (int | list[dict[str, str | int | bool]]) + + AltReturnAlias = (int | list[dict[str, str | int | bool]] | list[str]) + + DataAlias = (list[dict[str, str | int | bool]] + | dict[str, str | int | bool]) + AltDataAlias = (list[dict[str, str | int | bool]] + | dict[str, str | int | bool] | list[str]) + url: str resp: requests.models.Response truncate_string: str domain_string: str unverified_string: str - classes: List[str] + classes: list[str] hashes: str - hash_list: List[str] + hash_list: list[str] hexdig: str hsh: str pnum: str @@ -222,17 +223,17 @@ def __init__(self, account: str, agent: str, key: str) -> None: self.account = account self.agent = agent self.key = key - self.header: Dict[str, str] = { + self.header: dict[str, str] = { "User-Agent": self.agent, "hibp-api-key": self.key } + self.timeout = 300 # pylint: disable=undefined-variable - def search_all_breaches( - self, - truncate: Optional[bool] = False, - domain: Optional[str] = None, - unverified: Optional[bool] = False) -> AltReturnAlias: + def search_all_breaches(self, + truncate: bool | None = False, + domain: str | None = None, + unverified: bool | None = False) -> AltReturnAlias: """The most common use of the API is to return a list of all breaches a particular account has been involved in. @@ -285,6 +286,7 @@ def search_all_breaches( unverified_string = "" resp = requests.get(url + self.account + truncate_string + domain_string + unverified_string, + timeout=self.timeout, headers=self.header) _check(resp) if resp.status_code == 200: @@ -295,7 +297,7 @@ def search_all_breaches( return resp.status_code # pylint: disable=undefined-variable - def all_breaches(self, domain: Optional[str] = None) -> ReturnAlias: + def all_breaches(self, domain: str | None = None) -> ReturnAlias: """Retrieves all breached sites from the system. The result set can also be filtered by domain by passing the argument "domain='example.com'". This filters the result set to only @@ -315,7 +317,9 @@ def all_breaches(self, domain: Optional[str] = None) -> ReturnAlias: domain_string = "" else: domain_string = "?domain=" + domain - resp = requests.get(url + domain_string, headers=self.header) + resp = requests.get(url + domain_string, + headers=self.header, + timeout=self.timeout) _check(resp) if resp.status_code == 200: data = resp.json() @@ -337,7 +341,9 @@ def single_breach(self, name: str) -> ReturnAlias: >>> data = foo.single_breach("adobe") """ url = "https://haveibeenpwned.com/api/v3/breach/" - resp = requests.get(url + name, headers=self.header) + resp = requests.get(url + name, + headers=self.header, + timeout=self.timeout) _check(resp) if resp.status_code == 200: data = resp.json() @@ -346,7 +352,7 @@ def single_breach(self, name: str) -> ReturnAlias: # return data # Pretty sure will never hit return resp.status_code - def data_classes(self) -> Union[int, List[str]]: + def data_classes(self) -> int | list[str]: """Returns all data classes in the system. @@ -356,7 +362,7 @@ def data_classes(self) -> Union[int, List[str]]: >>> data = foo.data_classes() """ url = "https://haveibeenpwned.com/api/v3/dataclasses" - resp = requests.get(url, headers=self.header) + resp = requests.get(url, headers=self.header, timeout=self.timeout) _check(resp) if resp.status_code == 200: classes = resp.json() @@ -411,7 +417,9 @@ def search_pastes(self) -> ReturnAlias: >>> data = foo.search_pastes() """ url = "https://haveibeenpwned.com/api/v3/pasteaccount/" - resp = requests.get(url + self.account, headers=self.header) + resp = requests.get(url + self.account, + headers=self.header, + timeout=self.timeout) _check(resp) if resp.status_code == 200: data = resp.json() @@ -420,7 +428,7 @@ def search_pastes(self) -> ReturnAlias: return data return resp.status_code - def search_password(self, password: str) -> Union[int, str]: + def search_password(self, password: str) -> int | str: """Returns an integer of how many times the password appears in the Pwned Passwords repository, where each password is stored as a SHA-1 hash of a UTF-8 encoded password. When a password @@ -450,7 +458,9 @@ def search_password(self, password: str) -> Union[int, str]: hexdig = hexdig.upper() hsh = hexdig[:5] pnum = '0' - resp = requests.get(url + hsh, headers=self.header) + resp = requests.get(url + hsh, + headers=self.header, + timeout=self.timeout) _check(resp) if resp.status_code == 200: hash_list = resp.text.splitlines() @@ -460,7 +470,7 @@ def search_password(self, password: str) -> Union[int, str]: return pnum return resp.status_code - def search_hashes(self, hsh: str) -> Union[int, str]: + def search_hashes(self, hsh: str) -> int | str: """Returns a string of plaintext hashes which are suffixes to the first 5 characters of the searched hash argument. When a password hash with the same first 5 characters is found in the @@ -488,7 +498,9 @@ def search_hashes(self, hsh: str) -> Union[int, str]: """ url = "https://api.pwnedpasswords.com/range/" hsh = hsh[:5] - resp = requests.get(url + hsh, headers=self.header) + resp = requests.get(url + hsh, + headers=self.header, + timeout=self.timeout) _check(resp) if resp.status_code == 200: hashes = resp.text diff --git a/pyproject.toml b/pyproject.toml index 374b58c..07e8bd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,25 @@ [build-system] -requires = [ - "setuptools>=42", - "wheel" -] +requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" + +[project] +name = "hibpwned" +version = "1.3.8" +authors = [ + { name="plasticuproject", email="plasticuproject@pm.me" }, +] +description = "A human friendly Python API wrapper for haveibeenpwned.com" +readme = "README.md" +keywords = ["hibp", "haveibeenpwned", "api", "wrapper"] +requires-python = ">=3.11" +classifiers = [ + "Programming Language :: Python :: 3", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", + "Operating System :: OS Independent", + "Topic :: Utilities", "Topic :: Utilities" +] +dependencies = ["requests>=2.32.3"] + +[project.urls] +"Homepage" = "https://github.com/plasticuproject/hibpwned" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 2c24336..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -requests==2.31.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index c5f55ea..0000000 --- a/setup.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Setup file""" -from setuptools import setup # type: ignore - -VERSION = "1.3.7" - -with open("README.md", "r", encoding="utf-8") as fh: - LONG_DESCRIPTION = fh.read() - -setup(name="hibpwned", - packages=["hibpwned"], - version=VERSION, - description="A human friendly Python API wrapper for haveibeenpwned.com", - long_description=LONG_DESCRIPTION, - long_description_content_type="text/markdown", - author="plasticuproject", - author_email="plasticuproject@pm.me", - url="https://github.com/plasticuproject/hibpwned", - download_url="https://github.com/plasticuproject/hibpwned/archive/v" + - VERSION + ".tar.gz", - keywords=["hibp", "haveibeenpwned", "api", "wrapper"], - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "License :: OSI Approved :: GNU General Public License v3" + - " or later (GPLv3+)", "Programming Language :: Python :: 3", - "Topic :: Communications :: Chat", "Topic :: Utilities" - ], - license="GPLv3", - install_requires=["requests"], - zip_safe=False, - include_package_data=True) diff --git a/test.py b/test.py index e006db3..8f83553 100644 --- a/test.py +++ b/test.py @@ -13,11 +13,11 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. """ - +from __future__ import annotations import unittest import random from unittest import mock -from typing import Optional, Union, List, Dict, Any +from typing import Any import requests import hibpwned @@ -31,16 +31,14 @@ def mocked_requests_get(*args: Any, **kwargs: Any) -> Any: class MockResponse: # pylint: disable=too-few-public-methods """Mock API responses.""" - def __init__(self, - response_data: Optional[Union[List[Dict[str, str]], - List[str], Dict[str, str]]], - status_code: int) -> None: + def __init__(self, response_data: list[dict[str, str]] | list[str] + | dict[str, str] | None, status_code: int) -> None: self.response_data = response_data self.status_code = status_code def json( - self - ) -> Optional[Union[List[Dict[str, str]], List[str], Dict[str, str]]]: + self + ) -> list[dict[str, str]] | list[str] | dict[str, str] | None: """Returns mocked API response data.""" return self.response_data @@ -96,7 +94,8 @@ def test_single_breach(self) -> None: names = self.pwned.single_breach("adobe") if isinstance(names, list): name = names[0]["Name"] - self.assertEqual(name, "Adobe") + if name: + self.assertEqual(name, "Adobe") bad_name = self.pwned.single_breach("bullshit") self.assertEqual(bad_name, 404) @@ -105,11 +104,13 @@ def test_all_breaches(self) -> None: length_test = self.pwned.all_breaches() if isinstance(length_test, list): list_length = len(length_test) - self.assertTrue(list_length > 439) + if list_length: + self.assertTrue(list_length > 439) names = self.pwned.all_breaches(domain="adobe.com") if isinstance(names, list): domain_name = names[0] - self.assertEqual(domain_name["Name"], "Adobe") + if domain_name: + self.assertEqual(domain_name["Name"], "Adobe") def test_search_hashes(self) -> None: """Test search_hashes function.""" @@ -142,7 +143,7 @@ def test_search_pastes(self) -> None: def test_mock_error(self, mock_get: mock.MagicMock) -> None: """Test a non-recognized mock endpoint will return a status code 404.""" - bad_url = requests.get("https://www.fart.com") + bad_url = requests.get("https://www.fart.com", timeout=300) self.assertEqual(bad_url.status_code, 404) @mock.patch("hibpwned.requests.get", side_effect=mocked_requests_get)