Merge branch 'python-3.7' into 'staging'

Python 3.7

See merge request s3lph/matemat!26
This commit is contained in:
s3lph 2018-08-02 20:27:38 +00:00
commit 0daf37d94a
10 changed files with 66 additions and 178 deletions

View file

@ -1,5 +1,5 @@
--- ---
image: s3lph/matemat-ci:20180720-01 image: s3lph/matemat-ci:20180802-02
stages: stages:
- test - test
@ -10,8 +10,8 @@ test:
stage: test stage: test
script: script:
- pip3 install -r requirements.txt - pip3 install -r requirements.txt
- sudo -u matemat python3-coverage run --branch -m unittest discover matemat - sudo -u matemat python3 -m coverage run --branch -m unittest discover matemat
- sudo -u matemat python3-coverage report -m --include 'matemat/*' --omit '*/test_*.py' - sudo -u matemat python3 -m coverage report -m --include 'matemat/*' --omit '*/test_*.py'
codestyle: codestyle:
stage: test stage: test

View file

@ -1,5 +1,5 @@
FROM python:3.6-alpine FROM python:3.7-alpine
RUN mkdir -p /var/matemat/db /var/matemat/upload RUN mkdir -p /var/matemat/db /var/matemat/upload
RUN apk --update add libmagic RUN apk --update add libmagic

View file

@ -16,7 +16,7 @@ This project intends to provide a well-tested and maintainable alternative to
## Dependencies ## Dependencies
- Python 3 (>=3.6) - Python 3 (>=3.7)
- Python dependencies: - Python dependencies:
- file-magic - file-magic
- jinja2 - jinja2

View file

@ -1,4 +1,5 @@
from __future__ import annotations
from typing import List, Optional, Any, Type from typing import List, Optional, Any, Type
import crypt import crypt
@ -9,12 +10,6 @@ from matemat.exceptions import AuthenticationError, DatabaseConsistencyError
from matemat.db import DatabaseWrapper from matemat.db import DatabaseWrapper
from matemat.db.wrapper import Transaction from matemat.db.wrapper import Transaction
# TODO: Change to METHOD_BLOWFISH when adopting Python 3.7
"""
The method to use for password hashing.
"""
_CRYPT_METHOD = crypt.METHOD_SHA512
class MatematDatabase(object): class MatematDatabase(object):
""" """
@ -42,7 +37,7 @@ class MatematDatabase(object):
""" """
self.db: DatabaseWrapper = DatabaseWrapper(filename) self.db: DatabaseWrapper = DatabaseWrapper(filename)
def __enter__(self) -> 'MatematDatabase': def __enter__(self) -> MatematDatabase:
# Pass context manager stuff through to the database wrapper # Pass context manager stuff through to the database wrapper
self.db.__enter__() self.db.__enter__()
return self return self
@ -116,7 +111,7 @@ class MatematDatabase(object):
:raises ValueError: If a user with the same name already exists. :raises ValueError: If a user with the same name already exists.
""" """
# Hash the password. # Hash the password.
pwhash: str = crypt.crypt(password, crypt.mksalt(_CRYPT_METHOD)) pwhash: str = crypt.crypt(password, crypt.mksalt())
user_id: int = -1 user_id: int = -1
with self.db.transaction() as c: with self.db.transaction() as c:
# Look up whether a user with the same name already exists. # Look up whether a user with the same name already exists.
@ -194,7 +189,7 @@ class MatematDatabase(object):
if verify_password and not compare_digest(crypt.crypt(oldpass, row[0]), row[0]): if verify_password and not compare_digest(crypt.crypt(oldpass, row[0]), row[0]):
raise AuthenticationError('Old password does not match.') raise AuthenticationError('Old password does not match.')
# Hash the new password and write it to the database. # Hash the new password and write it to the database.
pwhash: str = crypt.crypt(newpass, crypt.mksalt(_CRYPT_METHOD)) pwhash: str = crypt.crypt(newpass, crypt.mksalt())
c.execute(''' c.execute('''
UPDATE users SET password = :pwhash, lastchange = STRFTIME('%s', 'now') WHERE user_id = :user_id UPDATE users SET password = :pwhash, lastchange = STRFTIME('%s', 'now') WHERE user_id = :user_id
''', { ''', {
@ -224,7 +219,7 @@ class MatematDatabase(object):
if verify_password and not compare_digest(crypt.crypt(password, row[0]), row[0]): if verify_password and not compare_digest(crypt.crypt(password, row[0]), row[0]):
raise AuthenticationError('Password does not match.') raise AuthenticationError('Password does not match.')
# Hash the new touchkey and write it to the database. # Hash the new touchkey and write it to the database.
tkhash: Optional[str] = crypt.crypt(touchkey, crypt.mksalt(_CRYPT_METHOD)) if touchkey is not None else None tkhash: Optional[str] = crypt.crypt(touchkey, crypt.mksalt()) if touchkey is not None else None
c.execute(''' c.execute('''
UPDATE users SET touchkey = :tkhash, lastchange = STRFTIME('%s', 'now') WHERE user_id = :user_id UPDATE users SET touchkey = :tkhash, lastchange = STRFTIME('%s', 'now') WHERE user_id = :user_id
''', { ''', {

View file

@ -1,75 +1,22 @@
from typing import Any from dataclasses import dataclass
class Product(object): @dataclass
class Product:
""" """
Representation of a product offered by the Matemat, with a name, prices for users, and the number of items Representation of a product offered by the Matemat, with a name, prices for users, and the number of items
currently in stock. currently in stock.
:param id: The product ID in the database.
:param name: The product's name.
:param price_member: The price of a unit of this product for users marked as "members".
:param price_non_member: The price of a unit of this product for users NOT marked as "members".
:param stock: The number of items of this product currently in stock.
""" """
def __init__(self, id: int
product_id: int, name: str
name: str, price_member: int
price_member: int, price_non_member: int
price_non_member: int, stock: int
stock: int) -> None:
"""
Create a new product instance with the given arguments.
:param product_id: The product ID in the database.
:param name: The product's name.
:param price_member: The price of a unit of this product for users marked as "members".
:param price_non_member: The price of a unit of this product for users NOT marked as "members".
:param stock: The number of items of this product currently in stock.
"""
self._product_id: int = product_id
self._name: str = name
self._price_member: int = price_member
self._price_non_member: int = price_non_member
self._stock: int = stock
def __eq__(self, other: Any) -> bool:
if other is None or not isinstance(other, Product):
return False
return self._product_id == other._product_id \
and self._name == other._name \
and self._price_member == other._price_member \
and self._price_non_member == other._price_non_member \
and self._stock == other._stock
@property
def id(self) -> int:
return self._product_id
@property
def name(self) -> str:
return self._name
@name.setter
def name(self, name: str) -> None:
self._name = name
@property
def price_member(self) -> int:
return self._price_member
@price_member.setter
def price_member(self, price: int) -> None:
self._price_member = price
@property
def price_non_member(self) -> int:
return self._price_non_member
@price_non_member.setter
def price_non_member(self, price: int) -> None:
self._price_non_member = price
@property
def stock(self) -> int:
return self._stock
@stock.setter
def stock(self, stock: int) -> None:
self._stock = stock

View file

@ -1,87 +1,27 @@
from typing import Optional, Any from typing import Optional
from dataclasses import dataclass
class User(object): @dataclass
class User:
""" """
Representation of a user registered with the Matemat, with a name, e-mail address (optional), whether the user is a Representation of a user registered with the Matemat, with a name, e-mail address (optional), whether the user is a
member of the organization the Matemat instance is used in, whether the user is an administrator, and the user's member of the organization the Matemat instance is used in, whether the user is an administrator, and the user's
account balance. account balance.
:param id: The user ID in the database.
:param username: The user's name.
:param balance: The balance of the user's account.
:param email: The user's e-mail address (optional).
:param admin: Whether the user is an administrator.
:param member: Whether the user is a member.
""" """
def __init__(self, id: int
user_id: int, name: str
username: str, balance: int
balance: int, email: Optional[str] = None
email: Optional[str] = None, is_admin: bool = False
admin: bool = False, is_member: bool = False
member: bool = True) -> None:
"""
Create a new user instance with the given arguments.
:param user_id: The user ID in the database.
:param username: The user's name.
:param balance: The balance of the user's account.
:param email: The user's e-mail address (optional).
:param admin: Whether the user is an administrator.
:param member: Whether the user is a member.
"""
self._user_id: int = user_id
self._username: str = username
self._email: Optional[str] = email
self._admin: bool = admin
self._member: bool = member
self._balance: int = balance
def __eq__(self, other: Any) -> bool:
if other is None or not isinstance(other, User):
return False
return self._user_id == other._user_id \
and self._username == other._username \
and self._email == other._email \
and self._admin == other._admin \
and self._member == other._member
@property
def id(self) -> int:
return self._user_id
@property
def name(self) -> str:
return self._username
@name.setter
def name(self, value):
self._username = value
@property
def email(self) -> Optional[str]:
return self._email
@email.setter
def email(self, email: str) -> None:
self._email = email
@property
def is_admin(self) -> bool:
return self._admin
@is_admin.setter
def is_admin(self, admin: bool) -> None:
self._admin = admin
@property
def is_member(self) -> bool:
return self._member
@is_member.setter
def is_member(self, member: bool) -> None:
self._member = member
@property
def balance(self) -> int:
return self._balance
@balance.setter
def balance(self, balance: int) -> None:
self._balance = balance

View file

@ -71,7 +71,7 @@ class DatabaseTest(unittest.TestCase):
u = db.create_user('testuser', 'supersecurepassword', 'testuser@example.com') u = db.create_user('testuser', 'supersecurepassword', 'testuser@example.com')
# Add a touchkey without using the provided function # Add a touchkey without using the provided function
c.execute('''UPDATE users SET touchkey = :tkhash WHERE user_id = :user_id''', { c.execute('''UPDATE users SET touchkey = :tkhash WHERE user_id = :user_id''', {
'tkhash': crypt.crypt('0123', crypt.mksalt(crypt.METHOD_SHA512)), 'tkhash': crypt.crypt('0123', crypt.mksalt()),
'user_id': u.id 'user_id': u.id
}) })
user = db.login('testuser', 'supersecurepassword') user = db.login('testuser', 'supersecurepassword')
@ -112,7 +112,7 @@ class DatabaseTest(unittest.TestCase):
with self.assertRaises(AuthenticationError): with self.assertRaises(AuthenticationError):
db.login('testuser', 'mynewpassword') db.login('testuser', 'mynewpassword')
db.login('testuser', 'adminpasswordreset') db.login('testuser', 'adminpasswordreset')
user._user_id = -1 user.id = -1
with self.assertRaises(AuthenticationError): with self.assertRaises(AuthenticationError):
# Password change for an inexistent user should fail # Password change for an inexistent user should fail
db.change_password(user, 'adminpasswordreset', 'passwordwithoutuser') db.change_password(user, 'adminpasswordreset', 'passwordwithoutuser')
@ -136,7 +136,7 @@ class DatabaseTest(unittest.TestCase):
with self.assertRaises(AuthenticationError): with self.assertRaises(AuthenticationError):
db.login('testuser', touchkey='4567') db.login('testuser', touchkey='4567')
db.login('testuser', touchkey='89ab') db.login('testuser', touchkey='89ab')
user._user_id = -1 user.id = -1
with self.assertRaises(AuthenticationError): with self.assertRaises(AuthenticationError):
# Touchkey change for an inexistent user should fail # Touchkey change for an inexistent user should fail
db.change_touchkey(user, '89ab', '048c') db.change_touchkey(user, '89ab', '048c')
@ -157,7 +157,7 @@ class DatabaseTest(unittest.TestCase):
self.assertFalse(checkuser.is_admin) self.assertFalse(checkuser.is_admin)
self.assertFalse(checkuser.is_member) self.assertFalse(checkuser.is_member)
self.assertEqual(4200, checkuser.balance) self.assertEqual(4200, checkuser.balance)
user._user_id = -1 user.id = -1
with self.assertRaises(DatabaseConsistencyError): with self.assertRaises(DatabaseConsistencyError):
db.change_user(user, agent, is_member='True') db.change_user(user, agent, is_member='True')
@ -238,7 +238,7 @@ class DatabaseTest(unittest.TestCase):
self.assertEqual(150, checkproduct.price_member) self.assertEqual(150, checkproduct.price_member)
self.assertEqual(250, checkproduct.price_non_member) self.assertEqual(250, checkproduct.price_non_member)
self.assertEqual(42, checkproduct.stock) self.assertEqual(42, checkproduct.stock)
product._product_id = -1 product.id = -1
with self.assertRaises(DatabaseConsistencyError): with self.assertRaises(DatabaseConsistencyError):
db.change_product(product) db.change_product(product)
product2 = db.create_product('Club Mate', 200, 200) product2 = db.create_product('Club Mate', 200, 200)
@ -281,7 +281,7 @@ class DatabaseTest(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
# Should fail, negative amount # Should fail, negative amount
db.deposit(user, -42) db.deposit(user, -42)
user._user_id = -1 user.id = -1
with self.assertRaises(DatabaseConsistencyError): with self.assertRaises(DatabaseConsistencyError):
# Should fail, user id -1 does not exist # Should fail, user id -1 does not exist
db.deposit(user, 42) db.deposit(user, 42)
@ -300,7 +300,7 @@ class DatabaseTest(unittest.TestCase):
self.assertEqual(42, c.fetchone()[0]) self.assertEqual(42, c.fetchone()[0])
c.execute('''SELECT stock FROM products WHERE product_id = ?''', [product2.id]) c.execute('''SELECT stock FROM products WHERE product_id = ?''', [product2.id])
self.assertEqual(0, c.fetchone()[0]) self.assertEqual(0, c.fetchone()[0])
product._product_id = -1 product.id = -1
with self.assertRaises(DatabaseConsistencyError): with self.assertRaises(DatabaseConsistencyError):
# Should fail, product id -1 does not exist # Should fail, product id -1 does not exist
db.restock(product, 42) db.restock(product, 42)

View file

@ -1,4 +1,5 @@
from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
import sqlite3 import sqlite3
@ -48,7 +49,7 @@ class DatabaseWrapper(object):
self._filename: str = filename self._filename: str = filename
self._sqlite_db: Optional[sqlite3.Connection] = None self._sqlite_db: Optional[sqlite3.Connection] = None
def __enter__(self) -> 'DatabaseWrapper': def __enter__(self) -> DatabaseWrapper:
self.connect() self.connect()
return self return self

View file

@ -1,4 +1,5 @@
from __future__ import annotations
from typing import Dict, Iterator, List, Tuple, Union from typing import Dict, Iterator, List, Tuple, Union
@ -24,7 +25,7 @@ class RequestArguments(object):
""" """
self.__container: Dict[str, RequestArgument] = dict() self.__container: Dict[str, RequestArgument] = dict()
def __getitem__(self, key: str) -> 'RequestArgument': def __getitem__(self, key: str) -> RequestArgument:
""" """
Retrieve the argument for the given name, creating it on the fly, if it doesn't exist. Retrieve the argument for the given name, creating it on the fly, if it doesn't exist.
@ -40,7 +41,7 @@ class RequestArguments(object):
# Return the argument for the name # Return the argument for the name
return self.__container[key] return self.__container[key]
def __getattr__(self, key: str) -> 'RequestArgument': def __getattr__(self, key: str) -> RequestArgument:
""" """
Syntactic sugar for accessing values with a name that can be used in Python attributes. The value will be Syntactic sugar for accessing values with a name that can be used in Python attributes. The value will be
returned as an immutable view. returned as an immutable view.
@ -278,7 +279,7 @@ class RequestArgument(object):
# Yield an immutable scalar view for each (ctype, value) element in the array # Yield an immutable scalar view for each (ctype, value) element in the array
yield _View(self.__name, v) yield _View(self.__name, v)
def __getitem__(self, index: Union[int, slice]) -> 'RequestArgument': def __getitem__(self, index: Union[int, slice]) -> RequestArgument:
""" """
Index the argument with either an int or a slice. The returned values are represented as immutable Index the argument with either an int or a slice. The returned values are represented as immutable
RequestArgument views. RequestArgument views.

View file

@ -1,11 +1,15 @@
FROM debian:buster # There is no buster image yet and stretch doesn't have a docker package. So let's just "upgrade" the image to buster.
FROM python:3.7-stretch
RUN useradd -d /home/matemat -m matemat RUN sed -re 's/stretch/buster/g' -i /etc/apt/sources.list \
RUN mkdir -p /var/matemat/db && chown matemat:matemat -R /var/matemat/db && useradd -d /home/matemat -m matemat \
RUN mkdir -p /var/matemat/upload && chown matemat:matemat -R /var/matemat/upload && mkdir -p /var/matemat/db /var/matemat/upload \
RUN apt-get update -qy && chown matemat:matemat -R /var/matemat/db \
RUN apt-get install -y --no-install-recommends file sudo openssh-client git docker.io python3-dev python3-pip python3-coverage python3-setuptools build-essential && chown matemat:matemat -R /var/matemat/upload \
RUN pip3 install wheel pycodestyle mypy && apt-get update -qy \
&& apt-get install -y --no-install-recommends file sudo openssh-client git docker.io build-essential \
&& python3.7 -m pip install coverage wheel pycodestyle mypy \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /home/matemat WORKDIR /home/matemat