diff --git a/README.rst b/README.rst index 2b9f987..51771e0 100644 --- a/README.rst +++ b/README.rst @@ -61,6 +61,14 @@ You can specify a length of generated password by adding the "length" parameter https://passgen.zakharov.cc/api/v1/passwords?length=6 +Exclude punctuation marks +------------------------- + +You can generate password without punctuation marks by adding the "exclude_punctuation" parameter to your request. Valid values for the parameter: true, on, ok, y, yes, 1:: + + https://passgen.zakharov.cc/api/v1/passwords?exclude_punctuation=true + + Settings ========== @@ -88,4 +96,4 @@ Docker Use docker container:: - docker run -d -p 8080:8080 --restart always toolen/passgen:2.0.1 + docker run -d -p 8080:8080 --restart=always --cap-drop=ALL toolen/passgen:2.1.0 diff --git a/passgen/passwords/constants.py b/passgen/passwords/constants.py index 111fe55..5b84a9e 100644 --- a/passgen/passwords/constants.py +++ b/passgen/passwords/constants.py @@ -10,9 +10,15 @@ DIGITS = string.digits PUNCTUATION = string.punctuation ALPHABET = ASCII_LETTERS + DIGITS + PUNCTUATION +ALPHABET_WO_PUNCTUATION = ASCII_LETTERS + DIGITS REQUIRED_SEQUENCES = ( ASCII_LOWERCASE, ASCII_UPPERCASE, DIGITS, PUNCTUATION, ) +REQUIRED_SEQUENCES_WO_PUNCTUATION = ( + ASCII_LOWERCASE, + ASCII_UPPERCASE, + DIGITS, +) diff --git a/passgen/passwords/services.py b/passgen/passwords/services.py index 9cb1156..fea95a7 100644 --- a/passgen/passwords/services.py +++ b/passgen/passwords/services.py @@ -1,7 +1,14 @@ """This file contains service methods to generate passwords.""" import secrets -from .constants import ALPHABET, MAX_LENGTH, MIN_LENGTH, REQUIRED_SEQUENCES +from .constants import ( + ALPHABET, + ALPHABET_WO_PUNCTUATION, + MAX_LENGTH, + MIN_LENGTH, + REQUIRED_SEQUENCES, + REQUIRED_SEQUENCES_WO_PUNCTUATION, +) def validate_length(length: int) -> None: @@ -25,22 +32,29 @@ def validate_length(length: int) -> None: raise AssertionError(f"Greater than the maximum length {MAX_LENGTH}") -def get_password(length: int) -> str: +def get_password(length: int, exclude_punctuation: bool = False) -> str: """ Return password. :param int length: password length + :param bool exclude_punctuation: generate password without special chars :return: password :rtype: str """ validate_length(length) + alphabet = ALPHABET_WO_PUNCTUATION if exclude_punctuation else ALPHABET + sequences = ( + REQUIRED_SEQUENCES_WO_PUNCTUATION if exclude_punctuation else REQUIRED_SEQUENCES + ) + password = [] for _ in range(0, length): - password.append(secrets.choice(ALPHABET)) + password.append(secrets.choice(alphabet)) idx_list = list([x for x in range(0, length)]) - for sequence in REQUIRED_SEQUENCES: + + for sequence in sequences: idx = secrets.choice(idx_list) idx_list.remove(idx) password[idx] = secrets.choice(sequence) diff --git a/passgen/passwords/views.py b/passgen/passwords/views.py index 5b5f04d..f66cc69 100644 --- a/passgen/passwords/views.py +++ b/passgen/passwords/views.py @@ -1,7 +1,12 @@ """This file contains application views.""" +from typing import Final + +import multidict from aiohttp import web from aiohttp.web_response import Response +from passgen.utils import BOOL_TRUE_STRINGS + from .constants import DEFAULT_LENGTH from .services import get_password @@ -15,9 +20,16 @@ async def passwords(request: web.Request) -> Response: :return: Response with generated password """ try: - length_str = request.rel_url.query.get("length", DEFAULT_LENGTH) - length = int(length_str) - password = get_password(length) + query_params: Final[multidict.MultiDict[str]] = request.rel_url.query + + raw_length: str = query_params.get("length", str(DEFAULT_LENGTH)) + length: int = int(raw_length) + + raw_exclude_punctuation: str = query_params.get("exclude_punctuation", "") + exclude_punctuation: bool = raw_exclude_punctuation in BOOL_TRUE_STRINGS + + password = get_password(length, exclude_punctuation) + return web.json_response({"password": password}) except ValueError: return web.json_response( diff --git a/passgen/utils.py b/passgen/utils.py index e1965dc..5e9d60e 100644 --- a/passgen/utils.py +++ b/passgen/utils.py @@ -2,6 +2,15 @@ import os from typing import Union +BOOL_TRUE_STRINGS = ( + "true", + "on", + "ok", + "y", + "yes", + "1", +) + def get_bool_env(key: str, default: bool = False) -> bool: """ @@ -12,17 +21,9 @@ def get_bool_env(key: str, default: bool = False) -> bool: :return: boolean value from environment variable :rtype: bool """ - bool_true_strings = ( - "true", - "on", - "ok", - "y", - "yes", - "1", - ) value: Union[str, None] = os.getenv(key) if value is not None: - return value.lower() in bool_true_strings + return value.lower() in BOOL_TRUE_STRINGS else: return bool(default) diff --git a/poetry.lock b/poetry.lock index 4b9b783..ac79e4c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -227,14 +227,14 @@ pyflakes = ">=2.3.0,<2.4.0" [[package]] name = "gitdb" -version = "4.0.5" +version = "4.0.7" description = "Git Object Database" category = "dev" optional = false python-versions = ">=3.4" [package.dependencies] -smmap = ">=3.0.1,<4" +smmap = ">=3.0.1,<5" [[package]] name = "gitpython" @@ -249,15 +249,15 @@ gitdb = ">=4.0.1,<5" [[package]] name = "gunicorn" -version = "20.0.4" +version = "20.1.0" description = "WSGI HTTP Server for UNIX" category = "main" optional = false -python-versions = ">=3.4" +python-versions = ">=3.5" [package.extras] -eventlet = ["eventlet (>=0.9.7)"] -gevent = ["gevent (>=0.13)"] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent (>=1.4.0)"] setproctitle = ["setproctitle"] tornado = ["tornado (>=0.2)"] @@ -279,7 +279,7 @@ python-versions = "*" [[package]] name = "isort" -version = "5.7.0" +version = "5.8.0" description = "A Python utility / library to sort Python imports." category = "dev" optional = false @@ -386,18 +386,18 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pydocstyle" -version = "5.1.1" +version = "6.0.0" description = "Python docstring style checker" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] snowballstemmer = "*" [[package]] name = "pyflakes" -version = "2.3.0" +version = "2.3.1" description = "passive checker of Python programs" category = "dev" optional = false @@ -525,11 +525,11 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "smmap" -version = "3.0.5" +version = "4.0.0" description = "A pure Python implementation of a sliding window memory map manager" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.5" [[package]] name = "snowballstemmer" @@ -610,7 +610,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "586b69e7b3c4a321ba2b9b95f9ed29325847b568f163cdc723a01a82f78b8e8d" +content-hash = "165d806327cd055e0787e13fc56baeb9f2e880f5e60c372446248a76a69ae865" [metadata.files] aiohttp = [ @@ -773,16 +773,15 @@ flake8 = [ {file = "flake8-3.9.0.tar.gz", hash = "sha256:78873e372b12b093da7b5e5ed302e8ad9e988b38b063b61ad937f26ca58fc5f0"}, ] gitdb = [ - {file = "gitdb-4.0.5-py3-none-any.whl", hash = "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac"}, - {file = "gitdb-4.0.5.tar.gz", hash = "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"}, + {file = "gitdb-4.0.7-py3-none-any.whl", hash = "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0"}, + {file = "gitdb-4.0.7.tar.gz", hash = "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"}, ] gitpython = [ {file = "GitPython-3.1.14-py3-none-any.whl", hash = "sha256:3283ae2fba31c913d857e12e5ba5f9a7772bbc064ae2bb09efafa71b0dd4939b"}, {file = "GitPython-3.1.14.tar.gz", hash = "sha256:be27633e7509e58391f10207cd32b2a6cf5b908f92d9cd30da2e514e1137af61"}, ] gunicorn = [ - {file = "gunicorn-20.0.4-py2.py3-none-any.whl", hash = "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"}, - {file = "gunicorn-20.0.4.tar.gz", hash = "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626"}, + {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, ] idna = [ {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, @@ -793,8 +792,8 @@ iniconfig = [ {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] isort = [ - {file = "isort-5.7.0-py3-none-any.whl", hash = "sha256:fff4f0c04e1825522ce6949973e83110a6e907750cd92d128b0d14aaaadbffdc"}, - {file = "isort-5.7.0.tar.gz", hash = "sha256:c729845434366216d320e936b8ad6f9d681aab72dc7cbc2d51bedc3582f3ad1e"}, + {file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"}, + {file = "isort-5.8.0.tar.gz", hash = "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, @@ -892,12 +891,12 @@ pycodestyle = [ {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] pydocstyle = [ - {file = "pydocstyle-5.1.1-py3-none-any.whl", hash = "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678"}, - {file = "pydocstyle-5.1.1.tar.gz", hash = "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325"}, + {file = "pydocstyle-6.0.0-py3-none-any.whl", hash = "sha256:d4449cf16d7e6709f63192146706933c7a334af7c0f083904799ccb851c50f6d"}, + {file = "pydocstyle-6.0.0.tar.gz", hash = "sha256:164befb520d851dbcf0e029681b91f4f599c62c5cd8933fd54b1bfbd50e89e1f"}, ] pyflakes = [ - {file = "pyflakes-2.3.0-py2.py3-none-any.whl", hash = "sha256:910208209dcea632721cb58363d0f72913d9e8cf64dc6f8ae2e02a3609aba40d"}, - {file = "pyflakes-2.3.0.tar.gz", hash = "sha256:e59fd8e750e588358f1b8885e5a4751203a0516e0ee6d34811089ac294c8806f"}, + {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, + {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pygments = [ {file = "Pygments-2.8.1-py3-none-any.whl", hash = "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"}, @@ -998,8 +997,8 @@ six = [ {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, ] smmap = [ - {file = "smmap-3.0.5-py2.py3-none-any.whl", hash = "sha256:7bfcf367828031dc893530a29cb35eb8c8f2d7c8f2d0989354d75d24c8573714"}, - {file = "smmap-3.0.5.tar.gz", hash = "sha256:84c2751ef3072d4f6b2785ec7ee40244c6f45eb934d9e543e2c51f1bd3d54c50"}, + {file = "smmap-4.0.0-py2.py3-none-any.whl", hash = "sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2"}, + {file = "smmap-4.0.0.tar.gz", hash = "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182"}, ] snowballstemmer = [ {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, diff --git a/pyproject.toml b/pyproject.toml index 309f9aa..d98e733 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "passgen" -version = "2.0.1" +version = "2.1.0" description = "A simple service for generating passwords with guaranteed presence of uppercase and lowercase letters, numbers and special characters." authors = ["Dmitrii Zakharov "] license = "MIT" @@ -9,7 +9,7 @@ license = "MIT" python = "^3.8" aiohttp = "3.7.4.post0" aiohttp_cors = "0.7.0" -gunicorn = "20.0.4" +gunicorn = "20.1.0" certifi = "2020.12.5" [tool.poetry.dev-dependencies] @@ -20,11 +20,11 @@ coveralls = "3.0.1" flake8 = "3.9.0" pytest = "6.2.2" black = "20.8b1" -isort = "5.7.0" +isort = "5.8.0" bandit = "1.7.0" safety = "1.10.3" mypy = "0.812" -pydocstyle = "5.1.1" +pydocstyle = "6.0.0" [tool.black] skip-string-normalization = 1 diff --git a/tests/test_passwords/test_services.py b/tests/test_passwords/test_services.py index e99e977..f1d9441 100644 --- a/tests/test_passwords/test_services.py +++ b/tests/test_passwords/test_services.py @@ -1,6 +1,11 @@ import pytest -from passgen.passwords.constants import MAX_LENGTH, MIN_LENGTH +from passgen.passwords.constants import ( + DEFAULT_LENGTH, + MAX_LENGTH, + MIN_LENGTH, + PUNCTUATION, +) from passgen.passwords.services import get_password @@ -41,3 +46,9 @@ def test_get_password_w_various_valid_length(): password = get_password(length) assert len(password) == length + + +def test_get_password_wo_punctuation(): + password = get_password(DEFAULT_LENGTH, True) + for char in PUNCTUATION: + assert char not in password diff --git a/tests/test_passwords/test_views.py b/tests/test_passwords/test_views.py index e8d7953..51ce523 100644 --- a/tests/test_passwords/test_views.py +++ b/tests/test_passwords/test_views.py @@ -1,6 +1,12 @@ import pytest -from passgen.passwords.constants import DEFAULT_LENGTH, MAX_LENGTH, MIN_LENGTH +from passgen.passwords.constants import ( + DEFAULT_LENGTH, + MAX_LENGTH, + MIN_LENGTH, + PUNCTUATION, +) +from passgen.utils import BOOL_TRUE_STRINGS async def test_get_password_wo_length_param(client): @@ -32,3 +38,24 @@ async def test_get_password_w_various_length(client, length, expected_status_cod result = await client.get(url) assert result.status == expected_status_code + + +@pytest.mark.parametrize( + "param_value", + BOOL_TRUE_STRINGS, +) +async def test_get_password_w_exclude_punctuation_param(client, param_value): + url = ( + client.app.router["passwords"] + .url_for() + .with_query({"exclude_punctuation": param_value}) + ) + result = await client.get(url) + + assert result.status == 200 + + data = await result.json() + password = data.get("password") + + for char in PUNCTUATION: + assert char not in password