Skip to content

Commit

Permalink
Add type checks and typing
Browse files Browse the repository at this point in the history
  • Loading branch information
fizyk committed Dec 15, 2020
1 parent 61b6c39 commit 360fc0c
Show file tree
Hide file tree
Showing 12 changed files with 234 additions and 61 deletions.
18 changes: 18 additions & 0 deletions .github/workflows/linters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,21 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: psf/black@stable

mypy:
runs-on: ubuntu-latest
strategy:
fail-fast: false
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: 3.8
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-lint.txt
- name: Run pydocstyle
run: |
mypy src/ tests/
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ __pycache__
# coverage
.coverage
coverage.xml

#typing
.pytype
26 changes: 26 additions & 0 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[mypy]
check_untyped_defs = True
show_error_codes = True
mypy_path = src
warn_unused_ignores = True
warn_redundant_casts = True
warn_unused_configs = True
disallow_untyped_calls = True
disallow_untyped_defs = True
disallow_incomplete_defs = True
no_implicit_optional = False

[mypy-pytest.*]
ignore_missing_imports = True

[mypy-zope.*]
ignore_missing_imports = True

[mypy-sqlalchemy.*]
ignore_missing_imports = True

[mypy-pyramid.*]
ignore_missing_imports = True

[mypy-inflect.*]
ignore_missing_imports = True
1 change: 1 addition & 0 deletions requirements-lint.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
black==20.8b1
pydocstyle==5.1.1
pycodestyle==2.6.0
mypy==0.790
1 change: 1 addition & 0 deletions requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ pytest==6.2.0
pytest-cov==2.10.1
coverage==5.3
mock==4.0.3; python_version>'3'
typing-extensions==3.7.4.3; python_version<'3.8'
-e .[tests]
62 changes: 62 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ install_requires =
zope.sqlalchemy
python-slugify
inflect
typing-extensions; python_version<'3.8'

[options.packages.find]
where = src
Expand All @@ -65,3 +66,64 @@ ignore = D203,D212
[pycodestyle]
max-line-length = 120
exclude = docs/*,build/*,venv/*
# NOTE: All relative paths are relative to the location of this file.

[pytype]

# Space-separated list of files or directories to exclude.
exclude =
**/*_test.py
**/test_*.py

# Space-separated list of files or directories to process.
inputs =
src/

# Keep going past errors to analyze as many files as possible.
keep_going = False

# Run N jobs in parallel. When 'auto' is used, this will be equivalent to the
# number of CPUs on the host system.
jobs = 4

# All pytype output goes here.
output = .pytype

# Paths to source code directories, separated by ':'.
pythonpath =
.

# Python version (major.minor) of the target code.
python_version = 3.8

# Check attribute values against their annotations. This flag is temporary and
# will be removed once this behavior is enabled by default.
check_attribute_types = False

# Check container mutations against their annotations. This flag is temporary
# and will be removed once this behavior is enabled by default.
check_container_types = False

# Check parameter defaults and assignments against their annotations. This flag
# is temporary and will be removed once this behavior is enabled by default.
check_parameter_types = False

# Check variable values against their annotations. This flag is temporary and
# will be removed once this behavior is enabled by default.
check_variable_types = False

# Comma or space separated list of error names to ignore.
disable =
pyi-error

# Don't report errors.
report_errors = True

# Experimental: Infer precise return types even for invalid function calls.
precise_return = False

# Experimental: solve unknown types to label with structural types.
protocols = False

# Experimental: Only load submodules that are explicitly imported.
strict_import = False
39 changes: 29 additions & 10 deletions src/pyramid_basemodel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,19 @@ class and ``bind_engine`` function.
"bind_engine",
]

from typing import Any, Type, Callable, List, Tuple, Union

import inflect
from datetime import datetime

from pyramid.config import Configurator
from sqlalchemy.engine import Engine
from zope.interface import classImplements
from zope.sqlalchemy import register

from sqlalchemy import engine_from_config
from sqlalchemy import Column, DateTime, Integer
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta
from sqlalchemy.orm import scoped_session, sessionmaker

from pyramid.path import DottedNameResolver
Expand All @@ -55,10 +59,10 @@ class and ``bind_engine`` function.
class classproperty:
"""A basic [class property](http://stackoverflow.com/a/3203659)."""

def __init__(self, getter):
def __init__(self, getter: Callable[..., Any]) -> None:
self.getter = getter

def __get__(self, instance, owner):
def __get__(self, instance: "BaseMixin", owner: Type["BaseMixin"]) -> Any:
return self.getter(owner)


Expand All @@ -70,6 +74,12 @@ class BaseMixin:
``modified`` columns and a scoped ``self.query`` property.
"""

_class_name: str
__name__: str
__tablename__: str
_singular_class_slug: str
_plural_class_name: str

#: primary key
id = Column(Integer, primary_key=True)

Expand All @@ -85,7 +95,7 @@ class BaseMixin:
query = Session.query_property()

@classproperty
def class_name(cls):
def class_name(cls) -> str:
"""
Determine class name based on the _class_name or the __tablename__.
Expand All @@ -108,12 +118,12 @@ def class_name(cls):
return cls.__name__

@classproperty
def class_slug(cls):
def class_slug(cls) -> str:
"""Class slug based on either _class_slug or __tablename__."""
return getattr(cls, "_class_slug", cls.__tablename__)

@classproperty
def singular_class_slug(cls):
def singular_class_slug(cls) -> str:
"""Return singular version of ``cls.class_slug``."""
# If provided, use ``self._singular_class_slug``.
if hasattr(cls, "_singular_class_slug"):
Expand All @@ -130,7 +140,7 @@ def singular_class_slug(cls):
return cls.class_name.split()[-1].lower()

@classproperty
def plural_class_name(cls):
def plural_class_name(cls) -> str:
"""Return plurar version of a class name."""
# If provided, use ``self._plural_class_name``.
if hasattr(cls, "_plural_class_name"):
Expand All @@ -140,7 +150,10 @@ def plural_class_name(cls):
return cls.__tablename__.replace("_", " ").title()


def save(instance_or_instances, session=Session):
def save(
instance_or_instances: Union[List[DeclarativeMeta], Tuple[DeclarativeMeta, ...], DeclarativeMeta],
session: scoped_session = Session,
) -> None:
"""
Save model instance(s) to the db.
Expand All @@ -153,7 +166,13 @@ def save(instance_or_instances, session=Session):
session.add(v)


def bind_engine(engine, session=Session, base=Base, should_create=False, should_drop=False):
def bind_engine(
engine: Engine,
session: scoped_session = Session,
base: DeclarativeMeta = Base,
should_create: bool = False,
should_drop: bool = False,
) -> None:
"""
Bind the ``session`` and ``base`` to the ``engine``.
Expand All @@ -168,7 +187,7 @@ def bind_engine(engine, session=Session, base=Base, should_create=False, should_
base.metadata.create_all(engine)


def includeme(config):
def includeme(config: Configurator) -> None:
"""Bind to the db engine specifed in ``config.registry.settings``."""
# Bind the engine.
settings = config.get_settings()
Expand Down
26 changes: 10 additions & 16 deletions src/pyramid_basemodel/blob.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,11 @@
]

import logging
from io import StringIO

from gzip import GzipFile
from http import HTTPStatus
from tempfile import NamedTemporaryFile
from typing import IO

import requests as requests_lib
import requests

from sqlalchemy.schema import Column
from sqlalchemy.types import Unicode
Expand Down Expand Up @@ -76,13 +75,13 @@ class Blob(Base, BaseMixin):
value = Column(LargeBinary, nullable=False)

@classmethod
def factory(cls, name, file_like_object=None):
def factory(cls, name: str, file_like_object: IO = None) -> "Blob":
"""Create and return."""
instance = cls()
instance.update(name, file_like_object=file_like_object)
return instance

def update(self, name, file_like_object=None):
def update(self, name: str, file_like_object: IO = None) -> None:
"""
Update value from file like object.
Expand All @@ -93,7 +92,7 @@ def update(self, name, file_like_object=None):
if file_like_object is not None:
self.value = file_like_object.read()

def update_from_url(self, url, should_unzip=False, requests=requests_lib, gzip_cls=GzipFile, io_cls=StringIO):
def update_from_url(self, url: str) -> None:
"""
Update value from url's content.
Expand All @@ -107,19 +106,14 @@ def update_from_url(self, url, should_unzip=False, requests=requests_lib, gzip_c
while True:
attempts += 1
r = requests.get(url)
if r.status_code == requests.codes.ok:
if r.status_code == HTTPStatus.OK:
break
if attempts < max_attempts:
continue
r.raise_for_status()
self.value = r.content

# If necessary, unzip using the gzip library.
if should_unzip:
self.value = gzip_cls(fileobj=io_cls(r.content)).read()
else: # Read the response into ``self.value``.
self.value = r.content

def get_as_named_tempfile(self, should_close=False, named_tempfile_cls=NamedTemporaryFile):
def get_as_named_tempfile(self, should_close: bool = False) -> IO:
"""Read ``self.value`` into and return a named temporary file."""
# Prepare the temp file.
f = NamedTemporaryFile(delete=False)
Expand All @@ -135,6 +129,6 @@ def get_as_named_tempfile(self, should_close=False, named_tempfile_cls=NamedTemp
# Return the file.
return f

def __json__(self):
def __json__(self) -> dict:
"""Create a JSONable representation."""
return {"name": self.name}
6 changes: 4 additions & 2 deletions src/pyramid_basemodel/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
]

import logging
from typing import Any, Callable

from pyramid.request import Request
from zope.interface import implementer
from zope.interface import alsoProvides

Expand All @@ -29,7 +31,7 @@ class BaseRoot:
__name__ = ""
__parent__ = None

def locatable(self, context, key, provides=alsoProvides):
def locatable(self, context: Any, key: str, provides: Callable[[Any, Any], None] = alsoProvides) -> Any:
"""Make a context object locatable and return it."""
if not hasattr(context, "__name__"):
context.__name__ = key
Expand All @@ -39,7 +41,7 @@ def locatable(self, context, key, provides=alsoProvides):
provides(context, ILocation)
return context

def __init__(self, request, key="", parent=None):
def __init__(self, request: Request, key: str = "", parent: Any = None) -> None:
"""Initialize BaseRoot class."""
self.__name__ = key
self.__parent__ = parent
Expand Down
Loading

0 comments on commit 360fc0c

Please sign in to comment.