diff --git a/.gitignore b/.gitignore index cbfc98e..c65e799 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ cover/ *.c *.so venv/ +.venv/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2e13fed..0000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -language: python - -python: - - "2.7" - - "3.5" - - "3.6" - - "pypy" - -install: - - if [[ $TRAVIS_PYTHON_VERSION != 'pypy' ]]; then pip install -q Pillow wheezy.captcha; fi - -script: - - python setup.py -q nosetests - -after_success: - - pip install coveralls - - coverage run --source=captcha setup.py -q nosetests - - coveralls - -notifications: - email: false diff --git a/MANIFEST.in b/MANIFEST.in index 90e80fe..613fd93 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,4 +1,4 @@ include LICENSE include README.rst -recursive-include captcha/data *.wav -recursive-include captcha/data *.ttf +recursive-include src/captcha/data *.wav +recursive-include src/captcha/data *.ttf diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index 0738db0..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,16 +0,0 @@ -build: false - -environment: - matrix: - - PYTHON: "C:\\Python27-x64" - - PYTHON: "C:\\Python36-x64" - -init: - - SET PATH=%PYTHON%;%PATH% - - python -c "import sys;sys.stdout.write(sys.version)" - -install: - - python -m pip install nose Pillow wheezy.captcha - -test_script: - - python -m nose -s diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7819b99 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,70 @@ +[project] +name = "captcha" +description = "A captcha library that generates audio and image CAPTCHAs." +authors = [{name = "Hsiaoming Yang", email="me@lepture.com"}] +dependencies = [ + "Pillow", +] +license = {text = "BSD-3-Clause"} +requires-python = ">=3.8" +dynamic = ["version"] +readme = "README.rst" +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Environment :: Web Environment", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Security", +] + +[project.urls] +Documentation = "https://captcha.lepture.com/" +Source = "https://github.com/lepture/captcha" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.dynamic] +version = {attr = "captcha.__version__"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +captcha = ["py.typed"] + +[tool.pytest.ini_options] +pythonpath = ["src", "."] +testpaths = ["tests"] +filterwarnings = ["error"] + +[tool.coverage.run] +branch = true +source = ["captcha"] + +[tool.coverage.paths] +source = ["src"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "@(abc\\.)?abstractmethod", + "@overload", +] + +[tool.mypy] +python_version = "3.8" +files = ["src/captcha"] +show_error_codes = true +pretty = true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..026ef54 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Pillow +types-Pillow +pytest +flake8 +mypy diff --git a/setup.py b/setup.py index 7c26321..6068493 100644 --- a/setup.py +++ b/setup.py @@ -1,61 +1,3 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- +from setuptools import setup -try: - from setuptools import setup -except ImportError: - from distutils.core import setup - -import sys -import captcha -from email.utils import parseaddr - -kwargs = {} -if not hasattr(sys, 'pypy_version_info'): - kwargs['install_requires'] = ['Pillow'] - -author, author_email = parseaddr(captcha.__author__) - - -def fopen(filename): - with open(filename) as f: - return f.read() - - -setup( - name='captcha', - version=captcha.__version__, - author=author, - author_email=author_email, - url=captcha.__homepage__, - packages=['captcha'], - description='A captcha library that generates audio and image CAPTCHAs.', - long_description=fopen('README.rst'), - license='BSD', - zip_safe=False, - include_package_data=True, - tests_require=['nose'], - test_suite='nose.collector', - classifiers=[ - 'Development Status :: 4 - Beta', - 'Environment :: Console', - 'Intended Audience :: Developers', - 'License :: OSI Approved', - 'License :: OSI Approved :: BSD License', - 'Operating System :: MacOS', - 'Operating System :: POSIX', - 'Operating System :: POSIX :: Linux', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: Implementation', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy', - ], - **kwargs -) +setup() diff --git a/captcha/__init__.py b/src/captcha/__init__.py similarity index 93% rename from captcha/__init__.py rename to src/captcha/__init__.py index 4fe24bd..49927e0 100644 --- a/captcha/__init__.py +++ b/src/captcha/__init__.py @@ -10,6 +10,6 @@ :license: BSD, see LICENSE for more details. """ -__version__ = '0.4' +__version__ = '0.5.0' __author__ = 'Hsiaoming Yang ' __homepage__ = 'https://github.com/lepture/captcha' diff --git a/captcha/audio.py b/src/captcha/audio.py similarity index 80% rename from captcha/audio.py rename to src/captcha/audio.py index 577f52c..858b2ca 100644 --- a/captcha/audio.py +++ b/src/captcha/audio.py @@ -8,17 +8,14 @@ This module is totally inspired by https://github.com/dchest/captcha """ +import typing as t import os import copy import wave import struct import random import operator - -import sys -if sys.version_info[0] != 2: - import functools - reduce = functools.reduce +from functools import reduce __all__ = ['AudioCaptcha'] @@ -32,14 +29,14 @@ WAVE_HEADER_LENGTH = len(WAVE_HEADER) - 4 DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data') -def _read_wave_file(filepath): +def _read_wave_file(filepath: str) -> bytearray: w = wave.open(filepath) data = w.readframes(-1) w.close() return bytearray(data) -def change_speed(body, speed=1): +def change_speed(body: bytearray, speed: float = 1) -> bytearray: """Change the voice speed of the wave body.""" if speed == 1: return body @@ -47,7 +44,7 @@ def change_speed(body, speed=1): length = int(len(body) * speed) rv = bytearray(length) - step = 0 + step: float = 0 for v in body: i = int(step) while i < int(step + speed) and i < length: @@ -57,7 +54,7 @@ def change_speed(body, speed=1): return rv -def patch_wave_header(body): +def patch_wave_header(body: bytearray) -> bytearray: """Patch header to the given wave body. :param body: the wave content body, it should be bytearray. @@ -81,7 +78,7 @@ def patch_wave_header(body): return data -def create_noise(length, level=4): +def create_noise(length: int, level: int = 4) -> bytearray: """Create white noise for background""" noise = bytearray(length) adjust = 128 - int(level / 2) @@ -93,7 +90,7 @@ def create_noise(length, level=4): return noise -def create_silence(length): +def create_silence(length: int) -> bytearray: """Create a piece of silence.""" data = bytearray(length) i = 0 @@ -103,25 +100,25 @@ def create_silence(length): return data -def change_sound(body, level=1): +def change_sound(body: bytearray, level: float = 1) -> bytearray: if level == 1: return body body = copy.copy(body) for i, v in enumerate(body): if v > 128: - v = (v - 128) * level + 128 - v = max(int(v), 128) + v = int((v - 128) * level + 128) + v = max(v, 128) v = min(v, 255) elif v < 128: - v = 128 - (128 - v) * level - v = min(int(v), 128) + v = int(128 - (128 - v) * level) + v = min(v, 128) v = max(v, 0) body[i] = v return body -def mix_wave(src, dst): +def mix_wave(src: bytearray, dst: bytearray) -> bytearray: """Mix two wave body into one.""" if len(src) > len(dst): # output should be longer @@ -141,7 +138,7 @@ END_BEEP = change_speed(BEEP, 1.4) SILENCE = create_silence(int(WAVE_SAMPLE_RATE / 5)) -class AudioCaptcha(object): +class AudioCaptcha: """Create an audio CAPTCHA. Create an instance of AudioCaptcha is pretty simple:: @@ -166,16 +163,16 @@ class AudioCaptcha(object): captcha = AudioCaptcha(voicedir='/path/to/voices') """ - def __init__(self, voicedir=None): + def __init__(self, voicedir: t.Optional[str] = None): if voicedir is None: voicedir = DATA_DIR self._voicedir = voicedir - self._cache = {} - self._choices = [] + self._cache: t.Dict[str, t.List[bytearray]] = {} + self._choices: t.List[str] = [] @property - def choices(self): + def choices(self) -> t.List[str]: """Available choices for characters to be generated.""" if self._choices: return self._choices @@ -184,7 +181,7 @@ class AudioCaptcha(object): self._choices.append(n) return self._choices - def random(self, length=6): + def random(self, length: int = 6) -> t.List[str]: """Generate a random string with the given length. :param length: the return string length. @@ -196,16 +193,16 @@ class AudioCaptcha(object): for name in self.choices: self._load_data(name) - def _load_data(self, name): + def _load_data(self, name: str): dirname = os.path.join(self._voicedir, name) - data = [] + data: t.List[bytearray] = [] for f in os.listdir(dirname): filepath = os.path.join(dirname, f) if f.endswith('.wav') and os.path.isfile(filepath): data.append(_read_wave_file(filepath)) self._cache[name] = data - def _twist_pick(self, key): + def _twist_pick(self, key: str) -> bytearray: voice = random.choice(self._cache[key]) # random change speed @@ -217,7 +214,7 @@ class AudioCaptcha(object): voice = change_sound(voice, level) return voice - def _noise_pick(self): + def _noise_pick(self) -> bytearray: key = random.choice(self.choices) voice = random.choice(self._cache[key]) voice = copy.copy(voice) @@ -230,7 +227,7 @@ class AudioCaptcha(object): voice = change_sound(voice, level) return voice - def create_background_noise(self, length, chars): + def create_background_noise(self, length: int, chars: str): noise = create_noise(length, 4) pos = 0 while pos < length: @@ -240,20 +237,20 @@ class AudioCaptcha(object): pos = end + random.randint(0, int(WAVE_SAMPLE_RATE / 10)) return noise - def create_wave_body(self, chars): - voices = [] - inters = [] - for key in chars: - voices.append(self._twist_pick(key)) - v = random.randint(WAVE_SAMPLE_RATE, WAVE_SAMPLE_RATE * 3) - inters.append(v) + def create_wave_body(self, chars: str) -> bytearray: + voices: t.List[bytearray] = [] + inters: t.List[int] = [] + for c in chars: + voices.append(self._twist_pick(c)) + i = random.randint(WAVE_SAMPLE_RATE, WAVE_SAMPLE_RATE * 3) + inters.append(i) durations = map(lambda a: len(a), voices) length = max(durations) * len(chars) + reduce(operator.add, inters) bg = self.create_background_noise(length, chars) # begin - pos = inters[0] + pos: int = inters[0] for i, v in enumerate(voices): end = pos + len(v) + 1 bg[pos:end] = mix_wave(v, bg[pos:end]) @@ -261,7 +258,7 @@ class AudioCaptcha(object): return BEEP + SILENCE + BEEP + SILENCE + BEEP + bg + END_BEEP - def generate(self, chars): + def generate(self, chars: str) -> bytearray: """Generate audio CAPTCHA data. The return data is a bytearray. :param chars: text to be generated. @@ -271,7 +268,7 @@ class AudioCaptcha(object): body = self.create_wave_body(chars) return patch_wave_header(body) - def write(self, chars, output): + def write(self, chars: str, output: str): """Generate and write audio CAPTCHA data to the output. :param chars: text to be generated. diff --git a/captcha/data/0/default.wav b/src/captcha/data/0/default.wav similarity index 100% rename from captcha/data/0/default.wav rename to src/captcha/data/0/default.wav diff --git a/captcha/data/1/default.wav b/src/captcha/data/1/default.wav similarity index 100% rename from captcha/data/1/default.wav rename to src/captcha/data/1/default.wav diff --git a/captcha/data/2/default.wav b/src/captcha/data/2/default.wav similarity index 100% rename from captcha/data/2/default.wav rename to src/captcha/data/2/default.wav diff --git a/captcha/data/3/default.wav b/src/captcha/data/3/default.wav similarity index 100% rename from captcha/data/3/default.wav rename to src/captcha/data/3/default.wav diff --git a/captcha/data/4/default.wav b/src/captcha/data/4/default.wav similarity index 100% rename from captcha/data/4/default.wav rename to src/captcha/data/4/default.wav diff --git a/captcha/data/5/default.wav b/src/captcha/data/5/default.wav similarity index 100% rename from captcha/data/5/default.wav rename to src/captcha/data/5/default.wav diff --git a/captcha/data/6/default.wav b/src/captcha/data/6/default.wav similarity index 100% rename from captcha/data/6/default.wav rename to src/captcha/data/6/default.wav diff --git a/captcha/data/7/default.wav b/src/captcha/data/7/default.wav similarity index 100% rename from captcha/data/7/default.wav rename to src/captcha/data/7/default.wav diff --git a/captcha/data/8/default.wav b/src/captcha/data/8/default.wav similarity index 100% rename from captcha/data/8/default.wav rename to src/captcha/data/8/default.wav diff --git a/captcha/data/9/default.wav b/src/captcha/data/9/default.wav similarity index 100% rename from captcha/data/9/default.wav rename to src/captcha/data/9/default.wav diff --git a/captcha/data/DroidSansMono.ttf b/src/captcha/data/DroidSansMono.ttf similarity index 100% rename from captcha/data/DroidSansMono.ttf rename to src/captcha/data/DroidSansMono.ttf diff --git a/captcha/data/beep.wav b/src/captcha/data/beep.wav similarity index 100% rename from captcha/data/beep.wav rename to src/captcha/data/beep.wav diff --git a/captcha/image.py b/src/captcha/image.py similarity index 61% rename from captcha/image.py rename to src/captcha/image.py index 4c5a4de..49c73a2 100644 --- a/captcha/image.py +++ b/src/captcha/image.py @@ -8,94 +8,28 @@ import os import random -from PIL import Image -from PIL import ImageFilter +import typing as t +from PIL.Image import new as createImage, Image, QUAD, BILINEAR from PIL.ImageDraw import Draw -from PIL.ImageFont import truetype -try: - from cStringIO import StringIO as BytesIO -except ImportError: - from io import BytesIO -try: - from wheezy.captcha import image as wheezy_captcha -except ImportError: - wheezy_captcha = None +from PIL.ImageFilter import SMOOTH +from PIL.ImageFont import FreeTypeFont, truetype +from io import BytesIO + +__all__ = ['ImageCaptcha'] + + +ColorTuple = t.Union[t.Tuple[int, int, int], t.Tuple[int, int, int, int]] DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data') DEFAULT_FONTS = [os.path.join(DATA_DIR, 'DroidSansMono.ttf')] -if wheezy_captcha: - __all__ = ['ImageCaptcha', 'WheezyCaptcha'] -else: - __all__ = ['ImageCaptcha'] -try: - _QUAD = Image.Transform.QUAD -except AttributeError: - _QUAD = Image.QUAD - -try: - _BILINEAR = Image.Resampling.BILINEAR -except AttributeError: - _BILINEAR = Image.BILINEAR - -table = [] -for i in range( 256 ): - table.append( int(i * 1.97) ) +table: t.List[int] = [] +for i in range(256): + table.append(int(i * 1.97)) -class _Captcha(object): - def generate(self, chars, format='png'): - """Generate an Image Captcha of the given characters. - - :param chars: text to be generated. - :param format: image file format - """ - im = self.generate_image(chars) - out = BytesIO() - im.save(out, format=format) - out.seek(0) - return out - - def write(self, chars, output, format='png'): - """Generate and write an image CAPTCHA data to the output. - - :param chars: text to be generated. - :param output: output destination. - :param format: image file format - """ - im = self.generate_image(chars) - return im.save(output, format=format) - - -class WheezyCaptcha(_Captcha): - """Create an image CAPTCHA with wheezy.captcha.""" - def __init__(self, width=200, height=75, fonts=None): - self._width = width - self._height = height - self._fonts = fonts or DEFAULT_FONTS - - def generate_image(self, chars): - text_drawings = [ - wheezy_captcha.warp(), - wheezy_captcha.rotate(), - wheezy_captcha.offset(), - ] - fn = wheezy_captcha.captcha( - drawings=[ - wheezy_captcha.background(), - wheezy_captcha.text(fonts=self._fonts, drawings=text_drawings), - wheezy_captcha.curve(), - wheezy_captcha.noise(), - wheezy_captcha.smooth(), - ], - width=self._width, - height=self._height, - ) - return fn(chars) - - -class ImageCaptcha(_Captcha): +class ImageCaptcha: """Create an image CAPTCHA. Many of the codes are borrowed from wheezy.captcha, with a modification @@ -115,26 +49,31 @@ class ImageCaptcha(_Captcha): :param fonts: Fonts to be used to generate CAPTCHA images. :param font_sizes: Random choose a font size from this parameters. """ - def __init__(self, width=160, height=60, fonts=None, font_sizes=None): + def __init__( + self, + width: int = 160, + height: int = 60, + fonts: t.Optional[t.List[str]] = None, + font_sizes: t.Optional[t.Tuple[int]] = None): self._width = width self._height = height self._fonts = fonts or DEFAULT_FONTS self._font_sizes = font_sizes or (42, 50, 56) - self._truefonts = [] + self._truefonts: t.List[FreeTypeFont] = [] @property - def truefonts(self): + def truefonts(self) -> t.List[FreeTypeFont]: if self._truefonts: return self._truefonts - self._truefonts = tuple([ + self._truefonts = [ truetype(n, s) for n in self._fonts for s in self._font_sizes - ]) + ] return self._truefonts @staticmethod - def create_noise_curve(image, color): + def create_noise_curve(image: Image, color: ColorTuple): w, h = image.size x1 = random.randint(0, int(w / 5)) x2 = random.randint(w - int(w / 5), w) @@ -147,7 +86,11 @@ class ImageCaptcha(_Captcha): return image @staticmethod - def create_noise_dots(image, color, width=3, number=30): + def create_noise_dots( + image: Image, + color: ColorTuple, + width: int = 3, + number: int = 30) -> Image: draw = Draw(image) w, h = image.size while number: @@ -157,7 +100,11 @@ class ImageCaptcha(_Captcha): number -= 1 return image - def create_captcha_image(self, chars, color, background): + def create_captcha_image( + self, + chars: str, + color: ColorTuple, + background: ColorTuple) -> Image: """Create the CAPTCHA image itself. :param chars: text to be generated. @@ -166,32 +113,29 @@ class ImageCaptcha(_Captcha): The color should be a tuple of 3 numbers, such as (0, 255, 255). """ - image = Image.new('RGB', (self._width, self._height), background) + image = createImage('RGB', (self._width, self._height), background) draw = Draw(image) - def _draw_character(c): + def _draw_character(c: str): font = random.choice(self.truefonts) - try: - _, _, w, h = draw.textbbox((1, 1), c, font=font) - except AttributeError: - w, h = draw.textsize(c, font=font) + _, _, w, h = draw.textbbox((1, 1), c, font=font) - dx = random.randint(0, 4) - dy = random.randint(0, 6) - im = Image.new('RGBA', (w + dx, h + dy)) - Draw(im).text((dx, dy), c, font=font, fill=color) + dx1 = random.randint(0, 4) + dy1 = random.randint(0, 6) + im = createImage('RGBA', (w + dx1, h + dy1)) + Draw(im).text((dx1, dy1), c, font=font, fill=color) # rotate im = im.crop(im.getbbox()) - im = im.rotate(random.uniform(-30, 30), _BILINEAR, expand=1) + im = im.rotate(random.uniform(-30, 30), BILINEAR, expand=True) # warp - dx = w * random.uniform(0.1, 0.3) - dy = h * random.uniform(0.2, 0.3) - x1 = int(random.uniform(-dx, dx)) - y1 = int(random.uniform(-dy, dy)) - x2 = int(random.uniform(-dx, dx)) - y2 = int(random.uniform(-dy, dy)) + dx2 = w * random.uniform(0.1, 0.3) + dy2 = h * random.uniform(0.2, 0.3) + x1 = int(random.uniform(-dx2, dx2)) + y1 = int(random.uniform(-dy2, dy2)) + x2 = int(random.uniform(-dx2, dx2)) + y2 = int(random.uniform(-dy2, dy2)) w2 = w + abs(x1) + abs(x2) h2 = h + abs(y1) + abs(y2) data = ( @@ -201,10 +145,10 @@ class ImageCaptcha(_Captcha): w2 - x2, -y1, ) im = im.resize((w2, h2)) - im = im.transform((w, h), _QUAD, data) + im = im.transform((w, h), QUAD, data) return im - images = [] + images: t.List[Image] = [] for c in chars: if random.random() > 0.5: images.append(_draw_character(" ")) @@ -230,7 +174,7 @@ class ImageCaptcha(_Captcha): return image - def generate_image(self, chars): + def generate_image(self, chars: str) -> Image: """Generate the image of the given characters. :param chars: text to be generated. @@ -240,11 +184,36 @@ class ImageCaptcha(_Captcha): im = self.create_captcha_image(chars, color, background) self.create_noise_dots(im, color) self.create_noise_curve(im, color) - im = im.filter(ImageFilter.SMOOTH) + im = im.filter(SMOOTH) return im + def generate(self, chars: str, format: str = 'png') -> BytesIO: + """Generate an Image Captcha of the given characters. -def random_color(start, end, opacity=None): + :param chars: text to be generated. + :param format: image file format + """ + im = self.generate_image(chars) + out = BytesIO() + im.save(out, format=format) + out.seek(0) + return out + + def write(self, chars: str, output: str, format: str = 'png') -> None: + """Generate and write an image CAPTCHA data to the output. + + :param chars: text to be generated. + :param output: output destination. + :param format: image file format + """ + im = self.generate_image(chars) + im.save(output, format=format) + + +def random_color( + start: int, + end: int, + opacity: t.Optional[int]=None) -> ColorTuple: red = random.randint(start, end) green = random.randint(start, end) blue = random.randint(start, end) diff --git a/tests/test_image.py b/tests/test_image.py index 3ed6d10..13f7143 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -1,15 +1,9 @@ # coding: utf-8 -import sys +from captcha.image import ImageCaptcha -if not hasattr(sys, 'pypy_version_info'): - from captcha.image import ImageCaptcha, WheezyCaptcha - def test_image_generate(): - captcha = ImageCaptcha() - data = captcha.generate('1234') - assert hasattr(data, 'read') - - captcha = WheezyCaptcha() - data = captcha.generate('1234') - assert hasattr(data, 'read') +def test_image_generate(): + captcha = ImageCaptcha() + data = captcha.generate('1234') + assert hasattr(data, 'read') diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 809e8d7..0000000 --- a/tox.ini +++ /dev/null @@ -1,13 +0,0 @@ -[tox] -envlist = py26,py27,py33,py34,pypy - -[testenv] -deps = - nose - Pillow - wheezy.captcha -commands = nosetests -s - -[testenv:pypy] -deps = nose -commands = nosetests -s