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 *.c
*.so *.so
venv/ 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 LICENSE
include README.rst include README.rst
recursive-include captcha/data *.wav recursive-include src/captcha/data *.wav
recursive-include captcha/data *.ttf 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 from setuptools import setup
# -*- coding: utf-8 -*-
try: setup()
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
)

View File

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

View File

@ -8,17 +8,14 @@
This module is totally inspired by https://github.com/dchest/captcha This module is totally inspired by https://github.com/dchest/captcha
""" """
import typing as t
import os import os
import copy import copy
import wave import wave
import struct import struct
import random import random
import operator import operator
from functools import reduce
import sys
if sys.version_info[0] != 2:
import functools
reduce = functools.reduce
__all__ = ['AudioCaptcha'] __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') 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) w = wave.open(filepath)
data = w.readframes(-1) data = w.readframes(-1)
w.close() w.close()
return bytearray(data) 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.""" """Change the voice speed of the wave body."""
if speed == 1: if speed == 1:
return body return body
@ -47,7 +44,7 @@ def change_speed(body, speed=1):
length = int(len(body) * speed) length = int(len(body) * speed)
rv = bytearray(length) rv = bytearray(length)
step = 0 step: float = 0
for v in body: for v in body:
i = int(step) i = int(step)
while i < int(step + speed) and i < length: while i < int(step + speed) and i < length:
@ -57,7 +54,7 @@ def change_speed(body, speed=1):
return rv return rv
def patch_wave_header(body): def patch_wave_header(body: bytearray) -> bytearray:
"""Patch header to the given wave body. """Patch header to the given wave body.
:param body: the wave content body, it should be bytearray. :param body: the wave content body, it should be bytearray.
@ -81,7 +78,7 @@ def patch_wave_header(body):
return data return data
def create_noise(length, level=4): def create_noise(length: int, level: int = 4) -> bytearray:
"""Create white noise for background""" """Create white noise for background"""
noise = bytearray(length) noise = bytearray(length)
adjust = 128 - int(level / 2) adjust = 128 - int(level / 2)
@ -93,7 +90,7 @@ def create_noise(length, level=4):
return noise return noise
def create_silence(length): def create_silence(length: int) -> bytearray:
"""Create a piece of silence.""" """Create a piece of silence."""
data = bytearray(length) data = bytearray(length)
i = 0 i = 0
@ -103,25 +100,25 @@ def create_silence(length):
return data return data
def change_sound(body, level=1): def change_sound(body: bytearray, level: float = 1) -> bytearray:
if level == 1: if level == 1:
return body return body
body = copy.copy(body) body = copy.copy(body)
for i, v in enumerate(body): for i, v in enumerate(body):
if v > 128: if v > 128:
v = (v - 128) * level + 128 v = int((v - 128) * level + 128)
v = max(int(v), 128) v = max(v, 128)
v = min(v, 255) v = min(v, 255)
elif v < 128: elif v < 128:
v = 128 - (128 - v) * level v = int(128 - (128 - v) * level)
v = min(int(v), 128) v = min(v, 128)
v = max(v, 0) v = max(v, 0)
body[i] = v body[i] = v
return body return body
def mix_wave(src, dst): def mix_wave(src: bytearray, dst: bytearray) -> bytearray:
"""Mix two wave body into one.""" """Mix two wave body into one."""
if len(src) > len(dst): if len(src) > len(dst):
# output should be longer # output should be longer
@ -141,7 +138,7 @@ END_BEEP = change_speed(BEEP, 1.4)
SILENCE = create_silence(int(WAVE_SAMPLE_RATE / 5)) SILENCE = create_silence(int(WAVE_SAMPLE_RATE / 5))
class AudioCaptcha(object): class AudioCaptcha:
"""Create an audio CAPTCHA. """Create an audio CAPTCHA.
Create an instance of AudioCaptcha is pretty simple:: Create an instance of AudioCaptcha is pretty simple::
@ -166,16 +163,16 @@ class AudioCaptcha(object):
captcha = AudioCaptcha(voicedir='/path/to/voices') captcha = AudioCaptcha(voicedir='/path/to/voices')
""" """
def __init__(self, voicedir=None): def __init__(self, voicedir: t.Optional[str] = None):
if voicedir is None: if voicedir is None:
voicedir = DATA_DIR voicedir = DATA_DIR
self._voicedir = voicedir self._voicedir = voicedir
self._cache = {} self._cache: t.Dict[str, t.List[bytearray]] = {}
self._choices = [] self._choices: t.List[str] = []
@property @property
def choices(self): def choices(self) -> t.List[str]:
"""Available choices for characters to be generated.""" """Available choices for characters to be generated."""
if self._choices: if self._choices:
return self._choices return self._choices
@ -184,7 +181,7 @@ class AudioCaptcha(object):
self._choices.append(n) self._choices.append(n)
return self._choices 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. """Generate a random string with the given length.
:param length: the return string length. :param length: the return string length.
@ -196,16 +193,16 @@ class AudioCaptcha(object):
for name in self.choices: for name in self.choices:
self._load_data(name) self._load_data(name)
def _load_data(self, name): def _load_data(self, name: str):
dirname = os.path.join(self._voicedir, name) dirname = os.path.join(self._voicedir, name)
data = [] data: t.List[bytearray] = []
for f in os.listdir(dirname): for f in os.listdir(dirname):
filepath = os.path.join(dirname, f) filepath = os.path.join(dirname, f)
if f.endswith('.wav') and os.path.isfile(filepath): if f.endswith('.wav') and os.path.isfile(filepath):
data.append(_read_wave_file(filepath)) data.append(_read_wave_file(filepath))
self._cache[name] = data self._cache[name] = data
def _twist_pick(self, key): def _twist_pick(self, key: str) -> bytearray:
voice = random.choice(self._cache[key]) voice = random.choice(self._cache[key])
# random change speed # random change speed
@ -217,7 +214,7 @@ class AudioCaptcha(object):
voice = change_sound(voice, level) voice = change_sound(voice, level)
return voice return voice
def _noise_pick(self): def _noise_pick(self) -> bytearray:
key = random.choice(self.choices) key = random.choice(self.choices)
voice = random.choice(self._cache[key]) voice = random.choice(self._cache[key])
voice = copy.copy(voice) voice = copy.copy(voice)
@ -230,7 +227,7 @@ class AudioCaptcha(object):
voice = change_sound(voice, level) voice = change_sound(voice, level)
return voice return voice
def create_background_noise(self, length, chars): def create_background_noise(self, length: int, chars: str):
noise = create_noise(length, 4) noise = create_noise(length, 4)
pos = 0 pos = 0
while pos < length: while pos < length:
@ -240,20 +237,20 @@ class AudioCaptcha(object):
pos = end + random.randint(0, int(WAVE_SAMPLE_RATE / 10)) pos = end + random.randint(0, int(WAVE_SAMPLE_RATE / 10))
return noise return noise
def create_wave_body(self, chars): def create_wave_body(self, chars: str) -> bytearray:
voices = [] voices: t.List[bytearray] = []
inters = [] inters: t.List[int] = []
for key in chars: for c in chars:
voices.append(self._twist_pick(key)) voices.append(self._twist_pick(c))
v = random.randint(WAVE_SAMPLE_RATE, WAVE_SAMPLE_RATE * 3) i = random.randint(WAVE_SAMPLE_RATE, WAVE_SAMPLE_RATE * 3)
inters.append(v) inters.append(i)
durations = map(lambda a: len(a), voices) durations = map(lambda a: len(a), voices)
length = max(durations) * len(chars) + reduce(operator.add, inters) length = max(durations) * len(chars) + reduce(operator.add, inters)
bg = self.create_background_noise(length, chars) bg = self.create_background_noise(length, chars)
# begin # begin
pos = inters[0] pos: int = inters[0]
for i, v in enumerate(voices): for i, v in enumerate(voices):
end = pos + len(v) + 1 end = pos + len(v) + 1
bg[pos:end] = mix_wave(v, bg[pos:end]) 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 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. """Generate audio CAPTCHA data. The return data is a bytearray.
:param chars: text to be generated. :param chars: text to be generated.
@ -271,7 +268,7 @@ class AudioCaptcha(object):
body = self.create_wave_body(chars) body = self.create_wave_body(chars)
return patch_wave_header(body) 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. """Generate and write audio CAPTCHA data to the output.
:param chars: text to be generated. :param chars: text to be generated.

View File

@ -8,94 +8,28 @@
import os import os
import random import random
from PIL import Image import typing as t
from PIL import ImageFilter from PIL.Image import new as createImage, Image, QUAD, BILINEAR
from PIL.ImageDraw import Draw from PIL.ImageDraw import Draw
from PIL.ImageFont import truetype from PIL.ImageFilter import SMOOTH
try: from PIL.ImageFont import FreeTypeFont, truetype
from cStringIO import StringIO as BytesIO from io import BytesIO
except ImportError:
from io import BytesIO __all__ = ['ImageCaptcha']
try:
from wheezy.captcha import image as wheezy_captcha
except ImportError: ColorTuple = t.Union[t.Tuple[int, int, int], t.Tuple[int, int, int, int]]
wheezy_captcha = None
DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data') DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data')
DEFAULT_FONTS = [os.path.join(DATA_DIR, 'DroidSansMono.ttf')] DEFAULT_FONTS = [os.path.join(DATA_DIR, 'DroidSansMono.ttf')]
if wheezy_captcha:
__all__ = ['ImageCaptcha', 'WheezyCaptcha']
else:
__all__ = ['ImageCaptcha']
try: table: t.List[int] = []
_QUAD = Image.Transform.QUAD for i in range(256):
except AttributeError: table.append(int(i * 1.97))
_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) )
class _Captcha(object): class ImageCaptcha:
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):
"""Create an image CAPTCHA. """Create an image CAPTCHA.
Many of the codes are borrowed from wheezy.captcha, with a modification 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 fonts: Fonts to be used to generate CAPTCHA images.
:param font_sizes: Random choose a font size from this parameters. :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._width = width
self._height = height self._height = height
self._fonts = fonts or DEFAULT_FONTS self._fonts = fonts or DEFAULT_FONTS
self._font_sizes = font_sizes or (42, 50, 56) self._font_sizes = font_sizes or (42, 50, 56)
self._truefonts = [] self._truefonts: t.List[FreeTypeFont] = []
@property @property
def truefonts(self): def truefonts(self) -> t.List[FreeTypeFont]:
if self._truefonts: if self._truefonts:
return self._truefonts return self._truefonts
self._truefonts = tuple([ self._truefonts = [
truetype(n, s) truetype(n, s)
for n in self._fonts for n in self._fonts
for s in self._font_sizes for s in self._font_sizes
]) ]
return self._truefonts return self._truefonts
@staticmethod @staticmethod
def create_noise_curve(image, color): def create_noise_curve(image: Image, color: ColorTuple):
w, h = image.size w, h = image.size
x1 = random.randint(0, int(w / 5)) x1 = random.randint(0, int(w / 5))
x2 = random.randint(w - int(w / 5), w) x2 = random.randint(w - int(w / 5), w)
@ -147,7 +86,11 @@ class ImageCaptcha(_Captcha):
return image return image
@staticmethod @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) draw = Draw(image)
w, h = image.size w, h = image.size
while number: while number:
@ -157,7 +100,11 @@ class ImageCaptcha(_Captcha):
number -= 1 number -= 1
return image 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. """Create the CAPTCHA image itself.
:param chars: text to be generated. :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). 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) draw = Draw(image)
def _draw_character(c): def _draw_character(c: str):
font = random.choice(self.truefonts) font = random.choice(self.truefonts)
try: _, _, w, h = draw.textbbox((1, 1), c, font=font)
_, _, w, h = draw.textbbox((1, 1), c, font=font)
except AttributeError:
w, h = draw.textsize(c, font=font)
dx = random.randint(0, 4) dx1 = random.randint(0, 4)
dy = random.randint(0, 6) dy1 = random.randint(0, 6)
im = Image.new('RGBA', (w + dx, h + dy)) im = createImage('RGBA', (w + dx1, h + dy1))
Draw(im).text((dx, dy), c, font=font, fill=color) Draw(im).text((dx1, dy1), c, font=font, fill=color)
# rotate # rotate
im = im.crop(im.getbbox()) 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 # warp
dx = w * random.uniform(0.1, 0.3) dx2 = w * random.uniform(0.1, 0.3)
dy = h * random.uniform(0.2, 0.3) dy2 = h * random.uniform(0.2, 0.3)
x1 = int(random.uniform(-dx, dx)) x1 = int(random.uniform(-dx2, dx2))
y1 = int(random.uniform(-dy, dy)) y1 = int(random.uniform(-dy2, dy2))
x2 = int(random.uniform(-dx, dx)) x2 = int(random.uniform(-dx2, dx2))
y2 = int(random.uniform(-dy, dy)) y2 = int(random.uniform(-dy2, dy2))
w2 = w + abs(x1) + abs(x2) w2 = w + abs(x1) + abs(x2)
h2 = h + abs(y1) + abs(y2) h2 = h + abs(y1) + abs(y2)
data = ( data = (
@ -201,10 +145,10 @@ class ImageCaptcha(_Captcha):
w2 - x2, -y1, w2 - x2, -y1,
) )
im = im.resize((w2, h2)) im = im.resize((w2, h2))
im = im.transform((w, h), _QUAD, data) im = im.transform((w, h), QUAD, data)
return im return im
images = [] images: t.List[Image] = []
for c in chars: for c in chars:
if random.random() > 0.5: if random.random() > 0.5:
images.append(_draw_character(" ")) images.append(_draw_character(" "))
@ -230,7 +174,7 @@ class ImageCaptcha(_Captcha):
return image return image
def generate_image(self, chars): def generate_image(self, chars: str) -> Image:
"""Generate the image of the given characters. """Generate the image of the given characters.
:param chars: text to be generated. :param chars: text to be generated.
@ -240,11 +184,36 @@ class ImageCaptcha(_Captcha):
im = self.create_captcha_image(chars, color, background) im = self.create_captcha_image(chars, color, background)
self.create_noise_dots(im, color) self.create_noise_dots(im, color)
self.create_noise_curve(im, color) self.create_noise_curve(im, color)
im = im.filter(ImageFilter.SMOOTH) im = im.filter(SMOOTH)
return im 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) red = random.randint(start, end)
green = random.randint(start, end) green = random.randint(start, end)
blue = random.randint(start, end) blue = random.randint(start, end)

View File

@ -1,15 +1,9 @@
# coding: utf-8 # 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() captcha = ImageCaptcha()
data = captcha.generate('1234') data = captcha.generate('1234')
assert hasattr(data, 'read') 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