diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..e1647d6 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,7 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/blob/v0.202.3/containers/python-3/.devcontainer/base.Dockerfile +# [Choice] Python version (use -bullseye variants on local arm64/Apple Silicon): 3, 3.9, 3.8, 3.7, 3.6, 3-bullseye, 3.9-bullseye, 3.8-bullseye, 3.7-bullseye, 3.6-bullseye, 3-buster, 3.9-buster, 3.8-buster, 3.7-buster, 3.6-buster +ARG PYTHON_VARIANT=3-bullseye +FROM mcr.microsoft.com/vscode/devcontainers/python:${PYTHON_VARIANT} +# install poetry +ARG POETRY_VERSION="none" +RUN if [ "${POETRY_VERSION}" != "none" ]; then su vscode -c "umask 0002 && pip3 install poetry==${POETRY_VERSION}"; fi diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..cecd267 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,42 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: +// https://github.com/microsoft/vscode-dev-containers/tree/v0.194.3/containers/vue +{ + "name": "hashids-devcontainer", + "dockerComposeFile": "docker-compose.yml", + "service": "devcontainer", + "workspaceFolder": "/workspace", + // Set *default* container specific settings.json values on container create. + "customizations": { + "vscode": { + "terminal.integrated.profiles.linux": { + "bash": { + "path": "/bin/bash" + } + }, + "python.pythonPath": "/usr/local/bin/python", + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.languageServer": "Default", + "python.linting.enabled": true, + "python.linting.pylintEnabled": true, + "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", + "python.formatting.blackPath": "/usr/local/py-utils/bin/black", + "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", + "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", + "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", + "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", + "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", + "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", + "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-python.vscode-pylance", + "ms-python.python", + "charliermarsh.ruff" + ] + } + }, + // Use 'postCreateCommand' to run commands after the container is created. + "postCreateCommand": "poetry config virtualenvs.create false && poetry update && poetry install", + // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. + "remoteUser": "vscode" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..0b4dc2a --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + devcontainer: + container_name: hashids + build: + context: . + dockerfile: Dockerfile + args: + PYTHON_VARIANT: 3.11-bullseye + POETRY_VERSION: 1.1.14 + volumes: + - ..:/workspace:cached + init: true + # Overrides default command so things don't shut down after the process ends. + command: sleep infinity diff --git a/.gitignore b/.gitignore index 96504ea..068726e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ MANIFEST dist/ hashids.egg-info/ +poetry.lock +__pycache__ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..85cb24e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,16 @@ +{ + // Verwendet IntelliSense zum Ermitteln möglicher Attribute. + // Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen. + // Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: Current File", + "type": "python", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": true + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ef75aba --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,20 @@ +{ + "python.linting.enabled": true, + "python.linting.mypyEnabled": true, + "python.linting.pylintEnabled": false, + "python.linting.mypyArgs": [ + "--ignore-missing-imports", + "--follow-imports=silent", + "--strict", + "--show-column-numbers", + "--allow-untyped-decorators" + ], + "python.formatting.provider": "black", + "python.testing.pytestArgs": [ + "test" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "editor.formatOnSave": true, + "files.autoSave": "off" + } \ No newline at end of file diff --git a/README.rst b/README.rst index 5fc76a5..2c41ce2 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ ========================== -hashids for Python 2.7 & 3 +hashids for Python 3 ========================== A python port of the JavaScript *hashids* implementation. It generates YouTube-like hashes from one or many numbers. Use hashids when you do not want to expose your database ids to the user. Website: http://www.hashids.org/ @@ -7,7 +7,7 @@ A python port of the JavaScript *hashids* implementation. It generates YouTube-l Compatibility ============= -hashids is tested with python 2.7 and 3.5–3.8. PyPy and PyPy 3 work as well. +hashids is tested with python 3.5–3.11. PyPy and PyPy 3 work as well. .. image:: https://travis-ci.org/davidaurelio/hashids-python.svg?branch=master :target: https://travis-ci.org/davidaurelio/hashids-python diff --git a/hashids.py b/hashids.py index 35d20d0..b99289c 100644 --- a/hashids.py +++ b/hashids.py @@ -1,48 +1,47 @@ -"""Implements the hashids algorithm in python. For more information, visit http://hashids.org/""" +""" +Implements the hashids algorithm in python. +For more information, visit http://hashids.org/ +""" import warnings from functools import wraps from math import ceil +from typing import Generator, Tuple, Union, Callable, Any -__version__ = '1.3.1' +__version__ = "1.4.0" RATIO_SEPARATORS = 3.5 RATIO_GUARDS = 12 -try: - StrType = basestring -except NameError: - StrType = str - -def _is_str(candidate): +def _is_str(candidate: Union[int, str]) -> bool: """Returns whether a value is a string.""" - return isinstance(candidate, StrType) + return isinstance(candidate, str) -def _is_uint(number): +def _is_uint(number: Union[int, str]) -> bool: """Returns whether a value is an unsigned integer.""" try: - return number == int(number) and number >= 0 + return number == int(number) and int(number) >= 0 except ValueError: return False -def _split(string, splitters): +def _split(string: str, splitters: str) -> Generator[str, None, None]: """Splits a string into parts at multiple characters""" - part = '' + part = "" for character in string: if character in splitters: yield part - part = '' + part = "" else: part += character yield part -def _hash(number, alphabet): +def _hash(number: int, alphabet: str) -> str: """Hashes `number` using the given `alphabet` sequence.""" - hashed = '' + hashed = "" len_alphabet = len(alphabet) while True: hashed = alphabet[number % len_alphabet] + hashed @@ -51,8 +50,8 @@ def _hash(number, alphabet): return hashed -def _unhash(hashed, alphabet): - """Restores a number tuple from hashed using the given `alphabet` index.""" +def _unhash(hashed: str, alphabet: str) -> int: + """Restores a number from hashed using the given `alphabet` index.""" number = 0 len_alphabet = len(alphabet) for character in hashed: @@ -62,12 +61,12 @@ def _unhash(hashed, alphabet): return number -def _reorder(string, salt): +def _reorder(rawstring: str, salt: str) -> str: """Reorders `string` according to `salt`.""" len_salt = len(salt) if len_salt != 0: - string = list(string) + string = list(rawstring) index, integer_sum = 0, 0 for i in range(len(string) - 1, 0, -1): integer = ord(salt[index]) @@ -75,17 +74,19 @@ def _reorder(string, salt): j = (integer + index + integer_sum) % i string[i], string[j] = string[j], string[i] index = (index + 1) % len_salt - string = ''.join(string) + rawstring = "".join(string) - return string + return rawstring -def _index_from_ratio(dividend, divisor): +def _index_from_ratio(dividend: float, divisor: float) -> int: """Returns the ceiled ratio of two numbers as int.""" return int(ceil(float(dividend) / divisor)) -def _ensure_length(encoded, min_length, alphabet, guards, values_hash): +def _ensure_length( + encoded: str, min_length: int, alphabet: str, guards: str, values_hash: int +) -> str: """Ensures the minimal hash length""" len_guards = len(guards) guard_index = (values_hash + ord(encoded[0])) % len_guards @@ -102,18 +103,25 @@ def _ensure_length(encoded, min_length, alphabet, guards, values_hash): excess = len(encoded) - min_length if excess > 0: from_index = excess // 2 - encoded = encoded[from_index:from_index+min_length] + encoded = encoded[from_index : from_index + min_length] return encoded -def _encode(values, salt, min_length, alphabet, separators, guards): +def _encode( + values: Tuple[int, ...], + salt: str, + min_length: int, + alphabet: str, + separators: str, + guards: str, +) -> str: """Helper function that does the hash building without argument checks.""" len_alphabet = len(alphabet) len_separators = len(separators) values_hash = sum(x % (i + 100) for i, x in enumerate(values)) - encoded = lottery = alphabet[values_hash % len(alphabet)] + encoded = lottery = alphabet[values_hash % len_alphabet] for i, value in enumerate(values): alphabet_salt = (lottery + salt + alphabet)[:len_alphabet] @@ -125,13 +133,19 @@ def _encode(values, salt, min_length, alphabet, separators, guards): encoded = encoded[:-1] # cut off last separator - return (encoded if len(encoded) >= min_length else - _ensure_length(encoded, min_length, alphabet, guards, values_hash)) + return ( + encoded + if len(encoded) >= min_length + else _ensure_length(encoded, min_length, alphabet, guards, values_hash) + ) -def _decode(hashid, salt, alphabet, separators, guards): - """Helper method that restores the values encoded in a hashid without - argument checks.""" +def _decode( + hashid: str, salt: str, alphabet: str, separators: str, guards: str +) -> Generator[int, None, None]: + """ + Helper method that restores the values encoded in a hashid without argument checks. + """ parts = tuple(_split(hashid, guards)) hashid = parts[1] if 2 <= len(parts) <= 3 else parts[0] @@ -143,30 +157,35 @@ def _decode(hashid, salt, alphabet, separators, guards): hash_parts = _split(hashid, separators) for part in hash_parts: - alphabet_salt = (lottery_char + salt + alphabet)[:len(alphabet)] + alphabet_salt = (lottery_char + salt + alphabet)[: len(alphabet)] alphabet = _reorder(alphabet, alphabet_salt) yield _unhash(part, alphabet) -def _deprecated(func, name): - """A decorator that warns about deprecation when the passed-in function is - invoked.""" +def _deprecated(func: Callable[..., Any], name: str) -> Callable[..., Any]: + """ + A decorator that warns about deprecation when the passed-in function is invoked. + """ + @wraps(func) - def with_warning(*args, **kwargs): + def with_warning(*args: Any, **kwargs: Any) -> Any: warnings.warn( - ('The %s method is deprecated and will be removed in v2.*.*' % - name), - DeprecationWarning + ("The %s method is deprecated and will be removed in v2.*.*" % name), + DeprecationWarning, ) return func(*args, **kwargs) + return with_warning -class Hashids(object): +class Hashids: """Hashes and restores values using the "hashids" algorithm.""" - ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890' - def __init__(self, salt='', min_length=0, alphabet=ALPHABET): + ALPHABET: str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" + + def __init__( + self, salt: str = "", min_length: int = 0, alphabet: str = ALPHABET + ) -> None: """ Initializes a Hashids object with salt, minimum length, and alphabet. @@ -174,17 +193,19 @@ def __init__(self, salt='', min_length=0, alphabet=ALPHABET): :param min_length: The minimum length for generated hashes :param alphabet: The characters to use for the generated hash ids. """ - self._min_length = max(int(min_length), 0) - self._salt = salt - - separators = ''.join(x for x in 'cfhistuCFHISTU' if x in alphabet) - alphabet = ''.join(x for i, x in enumerate(alphabet) - if alphabet.index(x) == i and x not in separators) + self._min_length: int = max(int(min_length), 0) + self._salt: str = salt + + separators: str = "".join(x for x in "cfhistuCFHISTU" if x in alphabet) + alphabet = "".join( + x + for i, x in enumerate(alphabet) + if alphabet.index(x) == i and x not in separators + ) len_alphabet, len_separators = len(alphabet), len(separators) if len_alphabet + len_separators < 16: - raise ValueError('Alphabet must contain at least 16 ' - 'unique characters.') + raise ValueError("Alphabet must contain at least 16 unique characters.") separators = _reorder(separators, salt) @@ -213,7 +234,7 @@ def __init__(self, salt='', min_length=0, alphabet=ALPHABET): self.decrypt = _deprecated(self.decode, "decrypt") self.encrypt = _deprecated(self.encode, "encrypt") - def encode(self, *values): + def encode(self, *values: int) -> str: """Builds a hash from the passed `values`. :param values The values to transform into a hashid @@ -223,12 +244,18 @@ def encode(self, *values): '1d6216i30h53elk3' """ if not (values and all(_is_uint(x) for x in values)): - return '' - - return _encode(values, self._salt, self._min_length, self._alphabet, - self._separators, self._guards) + return "" + + return _encode( + values, + self._salt, + self._min_length, + self._alphabet, + self._separators, + self._guards, + ) - def decode(self, hashid): + def decode(self, hashid: str) -> Tuple[int, ...]: """Restore a tuple of numbers from the passed `hashid`. :param hashid The hashid to decode @@ -240,14 +267,17 @@ def decode(self, hashid): if not hashid or not _is_str(hashid): return () try: - numbers = tuple(_decode(hashid, self._salt, self._alphabet, - self._separators, self._guards)) + numbers = tuple( + _decode( + hashid, self._salt, self._alphabet, self._separators, self._guards + ) + ) return numbers if hashid == self.encode(*numbers) else () except ValueError: return () - def encode_hex(self, hex_str): + def encode_hex(self, hex_str: str) -> str: """Converts a hexadecimal string (e.g. a MongoDB id) to a hashid. :param hex_str The hexadecimal string to encodes @@ -255,14 +285,15 @@ def encode_hex(self, hex_str): >>> Hashids.encode_hex('507f1f77bcf86cd799439011') 'y42LW46J9luq3Xq9XMly' """ - numbers = (int('1' + hex_str[i:i+12], 16) - for i in range(0, len(hex_str), 12)) + numbers = ( + int("1" + hex_str[i : i + 12], 16) for i in range(0, len(hex_str), 12) + ) try: return self.encode(*numbers) except ValueError: - return '' + return "" - def decode_hex(self, hashid): + def decode_hex(self, hashid: str) -> str: """Restores a hexadecimal string (e.g. a MongoDB id) from a hashid. :param hashid The hashid to decode @@ -270,4 +301,4 @@ def decode_hex(self, hashid): >>> Hashids.decode_hex('y42LW46J9luq3Xq9XMly') '507f1f77bcf86cd799439011' """ - return ''.join(('%x' % x)[1:] for x in self.decode(hashid)) + return "".join(("%x" % x)[1:] for x in self.decode(hashid)) diff --git a/pyproject.toml b/pyproject.toml index d6c7f5d..b073d04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ classifiers = [ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", ] -requires-python = ">=2.7" [tool.flit.metadata.requires-extra] test = [ @@ -35,3 +34,17 @@ include = [ "test/*.py", ] exclude = [ ".*" ] + +[tool.poetry] +name = "hashids" +version = "1.4.0" +description = "Implements the hashids algorithm in python." +authors = ["LindezaGrey <39629455+LindezaGrey@users.noreply.github.com>", "David Aurelio"] + +[tool.poetry.dependencies] +python = "^3.11" + +[tool.poetry.dev-dependencies] +black = "^23.1.0" +ruff = "^0.0.256" +pytest = "^7.4.0"