diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 7570e26..54bfeb3 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -16,7 +16,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.7, 3.8, 3.9, 2.7] + python-version: [3.7, 3.8, 3.9, "3.10"] os: [ubuntu-latest, windows-latest, macos-latest] include: # https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#using-environment-variables-in-a-matrix @@ -26,8 +26,8 @@ jobs: toxenv: "py38" - python-version: 3.9 toxenv: "py39" - - python-version: 2.7 - toxenv: "py27" + - python-version: "3.10" + toxenv: "py310" - python-version: 3.9 os: ubuntu-latest lint: "true" diff --git a/msal_extensions/__init__.py b/msal_extensions/__init__.py index adfb0e0..e4ea3d7 100644 --- a/msal_extensions/__init__.py +++ b/msal_extensions/__init__.py @@ -8,6 +8,9 @@ KeychainPersistence, LibsecretPersistence, ) -from .cache_lock import CrossPlatLock +try: + from .cache_lock import CrossPlatLock, LockError # It needs portalocker +except ImportError: + from .filelock import CrossPlatLock, LockError from .token_cache import PersistedTokenCache diff --git a/msal_extensions/cache_lock.py b/msal_extensions/cache_lock.py index ebb2601..9d66505 100644 --- a/msal_extensions/cache_lock.py +++ b/msal_extensions/cache_lock.py @@ -6,12 +6,15 @@ import logging from distutils.version import LooseVersion -import portalocker +import portalocker # pylint: disable=import-error logger = logging.getLogger(__name__) +LockError = portalocker.exceptions.LockException + + class CrossPlatLock(object): """Offers a mechanism for waiting until another process is finished interacting with a shared resource. This is specifically written to interact with a class of the same name in the .NET diff --git a/msal_extensions/filelock.py b/msal_extensions/filelock.py new file mode 100644 index 0000000..2c02247 --- /dev/null +++ b/msal_extensions/filelock.py @@ -0,0 +1,62 @@ +"""A cross-process lock based on exclusive creation of a given file name""" +import os +import sys +import errno +import time +import logging + + +logger = logging.getLogger(__name__) + + +class LockError(RuntimeError): + """It will be raised when unable to obtain a lock""" + + +class CrossPlatLock(object): + """This implementation relies only on ``open(..., 'x')``""" + def __init__(self, lockfile_path): + self._lockpath = lockfile_path + + def __enter__(self): + self._create_lock_file('{} {}'.format( + os.getpid(), + sys.argv[0], + ).encode('utf-8')) # pylint: disable=consider-using-f-string + return self + + def _create_lock_file(self, content): + timeout = 5 + check_interval = 0.25 + current_time = getattr(time, "monotonic", time.time) + timeout_end = current_time() + timeout + while timeout_end > current_time(): + try: + with open(self._lockpath, 'xb') as lock_file: # pylint: disable=unspecified-encoding + lock_file.write(content) + return None # Happy path + except ValueError: # This needs to be the first clause, for Python 2 to hit it + raise LockError("Python 2 does not support atomic creation of file") + except FileExistsError: # Only Python 3 will reach this clause + logger.debug( + "Process %d found existing lock file, will retry after %f second", + os.getpid(), check_interval) + time.sleep(check_interval) + raise LockError( + "Unable to obtain lock, despite trying for {} second(s). " + "You may want to manually remove the stale lock file {}".format( + timeout, + self._lockpath, + )) + + def __exit__(self, *args): + try: + os.remove(self._lockpath) + except OSError as ex: # pylint: disable=invalid-name + if ex.errno in (errno.ENOENT, errno.EACCES): + # Probably another process has raced this one + # and ended up clearing or locking the file for itself. + logger.debug("Unable to remove lock file") + else: + raise + diff --git a/msal_extensions/token_cache.py b/msal_extensions/token_cache.py index 119c9c2..1c56169 100644 --- a/msal_extensions/token_cache.py +++ b/msal_extensions/token_cache.py @@ -5,7 +5,10 @@ import msal -from .cache_lock import CrossPlatLock +try: + from .cache_lock import CrossPlatLock # It needs portalocker +except ImportError: + from .filelock import CrossPlatLock from .persistence import _mkdir_p, PersistenceNotFound diff --git a/setup.py b/setup.py index 1500a37..8602ae3 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,12 @@ package_data={'': ['LICENSE']}, install_requires=[ 'msal>=0.4.1,<2.0.0', - + "pathlib2;python_version<'3.0'", + ## We choose to NOT define a hard dependency on this. + # "pygobject>=3,<4;platform_system=='Linux'", + ], + extras_require={ + "portalocker": [ # In order to implement these requirements: # Lowerbound = (1.6 if playform_system == 'Windows' else 1.0) # Upperbound < (3 if python_version >= '3.5' else 2) @@ -32,10 +37,7 @@ "portalocker<2,>=1.0;python_version=='2.7' and platform_system!='Windows'", "portalocker<3,>=1.6;python_version>='3.5' and platform_system=='Windows'", "portalocker<2,>=1.6;python_version=='2.7' and platform_system=='Windows'", - - "pathlib2;python_version<'3.0'", - ## We choose to NOT define a hard dependency on this. - # "pygobject>=3,<4;platform_system=='Linux'", - ], + ], + }, tests_require=['pytest'], ) diff --git a/tests/cache_file_generator.py b/tests/cache_file_generator.py index 4164314..377d2db 100644 --- a/tests/cache_file_generator.py +++ b/tests/cache_file_generator.py @@ -14,9 +14,10 @@ import sys import time -from portalocker import exceptions +from msal_extensions import FilePersistence, CrossPlatLock, LockError -from msal_extensions import FilePersistence, CrossPlatLock + +print("Testing with {}".format(CrossPlatLock)) def _acquire_lock_and_write_to_cache(cache_location, sleep_interval): @@ -31,7 +32,7 @@ def _acquire_lock_and_write_to_cache(cache_location, sleep_interval): time.sleep(sleep_interval) data += "> " + str(os.getpid()) + "\n" cache_accessor.save(data) - except exceptions.LockException as e: + except LockError as e: logging.warning("Unable to acquire lock %s", e) diff --git a/tests/test_crossplatlock.py b/tests/test_crossplatlock.py index ea3c9d5..4aac516 100644 --- a/tests/test_crossplatlock.py +++ b/tests/test_crossplatlock.py @@ -1,5 +1,5 @@ import pytest -from msal_extensions.cache_lock import CrossPlatLock +from msal_extensions import CrossPlatLock def test_ensure_file_deleted(): @@ -10,6 +10,7 @@ def test_ensure_file_deleted(): except NameError: FileNotFoundError = IOError + print("Testing with {}".format(CrossPlatLock)) with CrossPlatLock(lockfile): pass diff --git a/tox.ini b/tox.ini index 8a538bc..dc27c67 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27,py35,py36,py37,py38 +envlist = py35,py36,py37,py38,py39,py310 [testenv] deps = pytest