diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..aec596b --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,37 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-merge-conflict + - id: debug-statements + - id: mixed-line-ending + args: [--fix=lf] +- repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: [--profile=black] +- repo: https://github.com/asottile/pyupgrade + rev: v3.15.1 + hooks: + - id: pyupgrade + args: [--py36-plus] +- repo: https://github.com/psf/black + rev: 24.2.0 + hooks: + - id: black +- repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 +- repo: meta + hooks: + - id: check-hooks-apply + - id: check-useless-excludes +- repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks + rev: v2.12.0 + hooks: + - id: pretty-format-yaml + args: [--autofix, --indent, '2'] diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec591b..48c2c00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased](https://github.com/python-social-auth/social-storage-sqlalchemy/commits/master) +### Changed +- Modified model and access code to work with SQLAlchemy version 2 (Issue #9) +- Updated packaging information files per PEP 517, PEP 518 (Issue #10) +- Restricted Python minimum working version to 3.7 or higher to align with SQLAlchemy 2 (Issue #9) + ## [1.1.0](https://github.com/python-social-auth/social-storage-sqlalchemy/releases/tag/1.1.0) - 2017-05-06 ### Changed diff --git a/Makefile b/Makefile index 53cf9f8..8b9b717 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,8 @@ build: - @ python setup.py sdist - @ python setup.py bdist_wheel --python-tag py2 - @ BUILD_VERSION=3 python setup.py bdist_wheel --python-tag py3 + @ python -m build publish: - @ python setup.py sdist upload - @ python setup.py bdist_wheel --python-tag py2 upload - @ BUILD_VERSION=3 python setup.py bdist_wheel --python-tag py3 upload + @ twine upload dist/* clean: @ find . -name '*.py[co]' -delete diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3a1e576 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = 'social-auth-storage-sqlalchemy' +dynamic = ["version"] +dependencies = [ + "six", + "sqlalchemy", + "social-auth-core>=1.0.0", +] +authors = [ + {name = "Matias Aguirre", email = "matiasaguirre@gmail.com"}, + {name = "Lee Ji-ho", email = "search5@gmail.com"}, +] +description = 'Python Social Authentication, SQLAlchemy storage.' +license = {text = 'BSD'} +keywords = ["sqlalchemy", "social auth"] +readme = "README.md" +classifiers=[ + 'Development Status :: 4 - Beta', + 'Topic :: Internet', + 'License :: OSI Approved :: BSD License', + 'Intended Audience :: Developers', + 'Environment :: Web Environment', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12' +] +requires-python = ">= 3.7" + +[project.urls] +Repository = 'https://github.com/python-social-auth/social-storage-sqlalchemy' +Documentation = 'http://python-social-auth.readthedocs.org' +Issues = 'https://github.com/python-social-auth/social-storage-sqlalchemy/issues' +Changelog = 'https://github.com/python-social-auth/social-storage-sqlalchemy/blob/master/CHANGELOG.md' + +[options] +zip_safe = false + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.packages] +find = {} + +[tool.setuptools.dynamic] +version = {attr = "social_sqlalchemy.__version__"} + +[tool.flake8] +max-line-length = 80 +# Ignore some well known paths +exclude = ['.venv','.tox','dist','doc','build','*.egg','db/env.py','db/versions/*.py','site','Pipfile','Pipfile.lock'] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 08c3b2e..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -six -sqlalchemy -social-auth-core >= 1.0.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index cd25caf..0000000 --- a/setup.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- -"""Setup file for easy installation""" -from os.path import join, dirname -from setuptools import setup - - -def long_description(): - return open(join(dirname(__file__), 'README.md')).read() - -def load_requirements(): - return open(join(dirname(__file__), 'requirements.txt')).readlines() - -setup( - name='social-auth-storage-sqlalchemy', - version=__import__('social_sqlalchemy').__version__, - author='Matias Aguirre', - author_email='matiasaguirre@gmail.com', - description='Python Social Authentication, SQLAlchemy storage.', - license='BSD', - keywords='sqlalchemy, social auth', - url='https://github.com/python-social-auth/social-storage-sqlalchemy', - packages=['social_sqlalchemy'], - long_description=long_description(), - install_requires=load_requirements(), - classifiers=[ - 'Development Status :: 4 - Beta', - 'Topic :: Internet', - 'License :: OSI Approved :: BSD License', - 'Intended Audience :: Developers', - 'Environment :: Web Environment', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3' - ], - python_requires='>=3.6', - zip_safe=False -) diff --git a/social_sqlalchemy/__init__.py b/social_sqlalchemy/__init__.py index 1a72d32..6849410 100644 --- a/social_sqlalchemy/__init__.py +++ b/social_sqlalchemy/__init__.py @@ -1 +1 @@ -__version__ = '1.1.0' +__version__ = "1.1.0" diff --git a/social_sqlalchemy/storage.py b/social_sqlalchemy/storage.py index c525d6c..6cda73a 100644 --- a/social_sqlalchemy/storage.py +++ b/social_sqlalchemy/storage.py @@ -1,24 +1,31 @@ """SQLAlchemy models for Social Auth""" + import base64 -import six import json +from typing import Optional try: import transaction except ImportError: transaction = None -from sqlalchemy import Column, Integer, String +from social_core.storage import ( + AssociationMixin, + BaseStorage, + CodeMixin, + NonceMixin, + PartialMixin, + UserMixin, +) +from sqlalchemy import Integer, String, delete, func, select from sqlalchemy.exc import IntegrityError -from sqlalchemy.types import PickleType, Text -from sqlalchemy.schema import UniqueConstraint -from sqlalchemy.ext.declarative import declared_attr from sqlalchemy.ext.mutable import MutableDict +from sqlalchemy.orm import Mapped, declared_attr, mapped_column +from sqlalchemy.schema import UniqueConstraint +from sqlalchemy.types import PickleType, Text -from social_core.storage import UserMixin, AssociationMixin, NonceMixin, \ - CodeMixin, PartialMixin, BaseStorage -class JSONPickler(object): +class JSONPickler: """JSON pickler wrapper around json lib since SQLAlchemy invokes dumps with extra positional parameters""" @@ -38,20 +45,20 @@ class JSONType(PickleType): impl = Text def __init__(self, *args, **kwargs): - kwargs['pickler'] = JSONPickler - super(JSONType, self).__init__(*args, **kwargs) + kwargs["pickler"] = JSONPickler + super().__init__(*args, **kwargs) -class SQLAlchemyMixin(object): +class SQLAlchemyMixin: COMMIT_SESSION = True @classmethod def _session(cls): - raise NotImplementedError('Implement in subclass') + raise NotImplementedError("Implement in subclass") @classmethod def _query(cls): - return cls._session().query(cls) + return select(cls) @classmethod def _new_instance(cls, model, *args, **kwargs): @@ -84,39 +91,45 @@ def save(self): class SQLAlchemyUserMixin(SQLAlchemyMixin, UserMixin): """Social Auth association model""" - __tablename__ = 'social_auth_usersocialauth' - __table_args__ = (UniqueConstraint('provider', 'uid'),) - id = Column(Integer, primary_key=True) - provider = Column(String(32)) - uid = None + + __tablename__ = "social_auth_usersocialauth" + __table_args__ = (UniqueConstraint("provider", "uid"),) + id: Mapped[int] = mapped_column(primary_key=True) + provider: Mapped[str] = mapped_column(String(32)) + uid: Mapped[str] = mapped_column(String(255)) user_id = None user = None @declared_attr - def extra_data(cls): - return Column(MutableDict.as_mutable(JSONType)) + def extra_data(cls) -> Mapped[Optional[dict[str, str]]]: + return mapped_column(MutableDict.as_mutable(JSONType)) @classmethod def changed(cls, user): cls._save_instance(user) def set_extra_data(self, extra_data=None): - if super(SQLAlchemyUserMixin, self).set_extra_data(extra_data): + if super().set_extra_data(extra_data): self._save_instance(self) @classmethod def allowed_to_disconnect(cls, user, backend_name, association_id=None): if association_id is not None: - qs = cls._query().filter(cls.id != association_id) + qs = cls._query().where(cls.id != association_id) else: - qs = cls._query().filter(cls.provider != backend_name) - qs = qs.filter(cls.user == user) + qs = cls._query().where(cls.provider != backend_name) + qs = qs.where(cls.user == user) - if hasattr(user, 'has_usable_password'): # TODO + if hasattr(user, "has_usable_password"): # TODO valid_password = user.has_usable_password() else: valid_password = True - return valid_password or qs.count() > 0 + + qs_count = cls._session().scalar( + select(func.count()).select_from(qs.subquery()) + ) + + return valid_password or qs_count > 0 @classmethod def disconnect(cls, entry): @@ -125,7 +138,7 @@ def disconnect(cls, entry): @classmethod def user_query(cls): - return cls._session().query(cls.user_model()) + return select(cls.user_model()) @classmethod def user_exists(cls, *args, **kwargs): @@ -133,11 +146,17 @@ def user_exists(cls, *args, **kwargs): Return True/False if a User instance exists with the given arguments. Arguments are directly passed to filter() manager method. """ - return cls.user_query().filter_by(*args, **kwargs).count() > 0 + stmt = cls.user_query().filter_by(*args, **kwargs) + + user_count = cls._session().scalar( + select(func.count()).select_from(stmt.subquery()) + ) + + return user_count > 0 @classmethod def get_username(cls, user): - return getattr(user, 'username', None) + return getattr(user, "username", None) @classmethod def create_user(cls, *args, **kwargs): @@ -145,19 +164,20 @@ def create_user(cls, *args, **kwargs): @classmethod def get_user(cls, pk): - return cls.user_query().get(pk) + return cls._session().get(cls.user_model(), pk) @classmethod def get_users_by_email(cls, email): - return cls.user_query().filter_by(email=email) + return cls._session().scalar(cls.user_query().filter_by(email=email)) @classmethod def get_social_auth(cls, provider, uid): - if not isinstance(uid, six.string_types): + if not isinstance(uid, str): uid = str(uid) try: - return cls._query().filter_by(provider=provider, - uid=uid)[0] + return cls._session().scalar( + cls._query().filter_by(provider=provider, uid=uid) + ) except IndexError: return None @@ -168,53 +188,60 @@ def get_social_auth_for_user(cls, user, provider=None, id=None): qs = qs.filter_by(provider=provider) if id: qs = qs.filter_by(id=id) - return qs + return cls._session().scalars(qs) @classmethod def create_social_auth(cls, user, uid, provider): - if not isinstance(uid, six.string_types): + if not isinstance(uid, str): uid = str(uid) return cls._new_instance(cls, user=user, uid=uid, provider=provider) class SQLAlchemyNonceMixin(SQLAlchemyMixin, NonceMixin): - __tablename__ = 'social_auth_nonce' - __table_args__ = (UniqueConstraint('server_url', 'timestamp', 'salt'),) - id = Column(Integer, primary_key=True) - server_url = Column(String(255)) - timestamp = Column(Integer) - salt = Column(String(40)) + __tablename__ = "social_auth_nonce" + __table_args__ = (UniqueConstraint("server_url", "timestamp", "salt"),) + + id: Mapped[int] = mapped_column(primary_key=True) + server_url: Mapped[str] = mapped_column(String(255)) + timestamp: Mapped[int] = mapped_column(Integer) + salt: Mapped[str] = mapped_column(String(40)) @classmethod def use(cls, server_url, timestamp, salt): - kwargs = {'server_url': server_url, 'timestamp': timestamp, - 'salt': salt} + kwargs = { # fix: skip + "server_url": server_url, + "timestamp": timestamp, + "salt": salt, + } try: - return cls._query().filter_by(**kwargs)[0] + return cls._session().scalar(cls._query().filter_by(**kwargs)) except IndexError: return cls._new_instance(cls, **kwargs) class SQLAlchemyAssociationMixin(SQLAlchemyMixin, AssociationMixin): - __tablename__ = 'social_auth_association' - __table_args__ = (UniqueConstraint('server_url', 'handle'),) - id = Column(Integer, primary_key=True) - server_url = Column(String(255)) - handle = Column(String(255)) - secret = Column(String(255)) # base64 encoded - issued = Column(Integer) - lifetime = Column(Integer) - assoc_type = Column(String(64)) + __tablename__ = "social_auth_association" + __table_args__ = (UniqueConstraint("server_url", "handle"),) + id: Mapped[int] = mapped_column(primary_key=True) + server_url: Mapped[str] = mapped_column(String(255)) + handle: Mapped[str] = mapped_column(String(255)) + secret: Mapped[str] = mapped_column(String(255)) # base64 encoded + issued: Mapped[int] = mapped_column() + lifetime: Mapped[int] = mapped_column() + assoc_type: Mapped[str] = mapped_column(String(64)) @classmethod def store(cls, server_url, association): # Don't use get_or_create because issued cannot be null try: - assoc = cls._query().filter_by(server_url=server_url, - handle=association.handle)[0] + assoc = cls._session().scalar( # fix: skip + cls._query().filter_by( + server_url=server_url, # fix: skip + handle=association.handle, # fix: skip + ) + ) except IndexError: - assoc = cls(server_url=server_url, - handle=association.handle) + assoc = cls(server_url=server_url, handle=association.handle) assoc.secret = base64.encodebytes(association.secret).decode() assoc.issued = association.issued assoc.lifetime = association.lifetime @@ -223,38 +250,42 @@ def store(cls, server_url, association): @classmethod def get(cls, *args, **kwargs): - return cls._query().filter_by(*args, **kwargs) + return cls._session().scalar(cls._query().filter_by(*args, **kwargs)) @classmethod def remove(cls, ids_to_delete): - cls._query().filter(cls.id.in_(ids_to_delete)).delete( - synchronize_session='fetch' + cls._session().execute( + delete( + cls._query().where(cls.id.in_(ids_to_delete)) # fix: skip + ).execution_options(synchronize_session="fetch") ) class SQLAlchemyCodeMixin(SQLAlchemyMixin, CodeMixin): - __tablename__ = 'social_auth_code' - __table_args__ = (UniqueConstraint('code', 'email'),) - id = Column(Integer, primary_key=True) - email = Column(String(200)) - code = Column(String(32), index=True) + __tablename__ = "social_auth_code" + __table_args__ = (UniqueConstraint("code", "email"),) + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(200)) + code: Mapped[str] = mapped_column(String(32), index=True) @classmethod def get_code(cls, code): - return cls._query().filter(cls.code == code).first() + return cls._session().scalar(cls._query().where(cls.code == code)) class SQLAlchemyPartialMixin(SQLAlchemyMixin, PartialMixin): - __tablename__ = 'social_auth_partial' - id = Column(Integer, primary_key=True) - token = Column(String(32), index=True) - data = Column(MutableDict.as_mutable(JSONType)) - next_step = Column(Integer) - backend = Column(String(32)) + __tablename__ = "social_auth_partial" + id: Mapped[int] = mapped_column(primary_key=True) + token: Mapped[str] = mapped_column(String(32), index=True) + data: Mapped[dict[str, str]] = mapped_column( # fix: skip + MutableDict.as_mutable(JSONType) + ) + next_step: Mapped[int] = mapped_column() + backend: Mapped[str] = mapped_column(String(32)) @classmethod def load(cls, token): - return cls._query().filter(cls.token == token).first() + return cls._session().scalar(cls._query().where(cls.token == token)) @classmethod def destroy(cls, token):