refactor!: restructure the code, add type hints

This commit is contained in:
Hsiaoming Yang
2023-07-28 23:38:16 +09:00
parent 10a464cc78
commit d369a13a63
24 changed files with 200 additions and 272 deletions

1
.gitignore vendored
View File

@ -17,3 +17,4 @@ cover/
*.c
*.so
venv/
.venv/

View File

@ -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

View File

@ -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

View File

@ -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

70
pyproject.toml Normal file
View File

@ -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

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
Pillow
types-Pillow
pytest
flake8
mypy

View File

@ -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()

View File

@ -10,6 +10,6 @@
:license: BSD, see LICENSE for more details.
"""
__version__ = '0.4'
__version__ = '0.5.0'
__author__ = 'Hsiaoming Yang <me@lepture.com>'
__homepage__ = 'https://github.com/lepture/captcha'

View File

@ -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.

View File

@ -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)
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)

View File

@ -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():
def test_image_generate():
captcha = ImageCaptcha()
data = captcha.generate('1234')
assert hasattr(data, 'read')
captcha = WheezyCaptcha()
data = captcha.generate('1234')
assert hasattr(data, 'read')

13
tox.ini
View File

@ -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