Migrating from 3.x to 4.0.0¶
Most 3.x code runs unchanged on 4.0.0. portalocker.Lock, the
module-level portalocker.lock and portalocker.unlock, the with
statement, and every exception type kept their names and their meaning.
What follows is everything that can still require an edit, ordered from
most to least likely to affect you.
Windows no longer installs pywin32 for you¶
Up to 3.x, pip install portalocker pulled in pywin32 on Windows.
Since 4.0.0 it does not: the default is MsvcrtLocker, which calls
msvcrt.locking from the standard library and needs nothing extra.
Exclusive locks are unaffected. That covers the common case, including every plain portalocker.Lock, on a dependency-free install.
Shared locks are affected. msvcrt.locking has no shared mode at
all, so LockFlags.SHARED on Windows delegates to Win32Locker, which
calls LockFileEx from pywin32. Without the package installed you
get a descriptive ImportError rather than an obscure failure:
ImportError: Shared locks on Windows require the win32 extra
(pywin32); msvcrt provides no true shared lock. Install it with:
pip install "portalocker[win32]"
The fix is to ask for the extra:
pip install "portalocker[win32]"
POSIX installs are unaffected either way; the extra only declares a
dependency on Windows. See Platform Behaviour for the rest of the
msvcrt/pywin32 split, including the second code path that needs
it – releasing a lock. (#104)
POSIX lock exceptions changed shape¶
This is the subtlest break in the release: no type changed, no new exception is raised, only the text of one that already existed.
On 3.x, a POSIX lock failure passed the originating OSError as its
only positional argument and the message as a strerror keyword,
which BaseLockException never read. So str(exc) was the bare
OSError text and exc.strerror was always None. Since 4.0.0 the
message is passed as the second positional argument – the
two-argument convention BaseLockException already followed on Windows
– so strerror is populated and str(exc) renders a 2-tuple repr
of both arguments:
portalocker 3.x
str(exc) '[Errno 11] Resource temporarily unavailable'
exc.strerror None
portalocker 4.0.0
str(exc) "(BlockingIOError(11, 'Resource temporarily
unavailable'), '[Errno 11] Resource temporarily
unavailable')"
exc.strerror '[Errno 11] Resource temporarily unavailable'
The same shape, built by hand so it runs everywhere – a real failure
puts the OSError that fcntl raised in the first slot, which is
why its [Errno N] prefix shows up in both halves above:
>>> from portalocker import exceptions
>>> original = OSError('Resource temporarily unavailable')
>>> exc = exceptions.AlreadyLocked(original, str(original))
>>> str(exc)
"(OSError('Resource temporarily unavailable'), 'Resource temporarily unavailable')"
>>> exc.strerror
'Resource temporarily unavailable'
What to change: if you parse str(exc) to recover the operating
system’s message, read exc.strerror instead. It holds that message on
both platforms now, which the 3.x attribute never did on POSIX. Nothing
else about these exceptions moved – the class hierarchy, the fh
attribute, and except portalocker.AlreadyLocked all behave as before.
Troubleshooting covers this from the symptom side.
Python 3.10 is the minimum¶
3.x still supported Python 3.9. 4.0.0 requires 3.10 or later, because 3.9 reached end of life and no longer receives fixes:
>>> import sys
>>> sys.version_info >= (3, 10)
True
If you are pinned to an older interpreter, stay on the 3.x series:
pip install "portalocker<4"
LockBase is generic over its acquire return type¶
LockBase used to be a plain abc.ABC. It is now also
typing.Generic, parameterised by whatever its acquire returns, so
that type checkers know what a with block yields: portalocker.Lock
declares itself as LockBase[typing.IO[typing.Any]], and
BoundedSemaphore as LockBase['Lock | None'].
This is a typing-only change. Subclasses keep working whether or not they supply a parameter, and nothing about them changes at runtime:
>>> import typing
>>> from portalocker.utils import LockBase
>>> LockBase[typing.IO[typing.Any]]
portalocker.utils.LockBase[typing.IO[typing.Any]]
>>> class CountingLock(LockBase):
... def acquire(
... self, timeout=None, check_interval=None, fail_when_locked=None
... ):
... return 42
...
... def release(self):
... pass
>>> CountingLock().acquire()
42
To get the benefit in your own subclass, parameterise the base with what
your acquire returns – class CountingLock(LockBase[int]).
New names in the top-level namespace¶
PidFileLock is new in 4.0.0. It is a TemporaryFileLock that publishes
the holder’s PID into the lock file, so a supervisor can read who holds
it, and its PidFileLock.fail_closed context manager refuses to enter
the body on contention, exposing the competing PID through
AlreadyLocked.holder_pid when that is readable (#106, #118).
NamedBoundedSemaphore is not new – it lived in portalocker.utils on
3.x – but it was not re-exported. portalocker.NamedBoundedSemaphore
now works, so the recommended semaphore no longer needs a utils
import:
>>> import portalocker
>>> with portalocker.PidFileLock('worker.pid') as holder_pid:
... holder_pid is None # None means this process is the holder
True
>>> semaphore = portalocker.NamedBoundedSemaphore(
... 2, name='migration_demo', directory=''
... )
>>> with semaphore as slot:
... slot is not None
True
See Lock Types for how both sit next to the other lock classes.
Behavioural fixes worth knowing about¶
Lock.release() no longer propagates cleanup errors¶
On 3.x, Lock.release unlocked and then closed, with no error handling
around either step. An unlock failure therefore propagated to the caller
and skipped the close, leaving the handle open and lock.fh still
set. Since 4.0.0 both steps are always attempted and lock.fh is
always cleared, and failures are swallowed by default – which matters
most where you cannot handle them anyway, in __del__ and when leaving
a with block that is already unwinding an exception:
>>> import portalocker
>>> lock = portalocker.Lock('release_demo.txt', 'w', timeout=1)
>>> fh = lock.acquire()
>>> fh.close() # close it behind the lock's back
>>> lock.release() # unlock and close both fail, both are swallowed
>>> lock.fh is None
True
Opt back into reporting with raise_on_release_error=True (#117):
>>> strict = portalocker.Lock(
... 'release_demo.txt', 'w', timeout=1, raise_on_release_error=True
... )
>>> fh = strict.acquire()
>>> fh.close()
>>> strict.release()
Traceback (most recent call last):
...
ValueError: I/O operation on closed file
BoundedSemaphore recovers from non-contention errors¶
BoundedSemaphore.try_lock used to store its Lock on the instance
before acquiring it. A failure that was not contention – a missing
directory, say – then left that half-set lock behind, and every
later BoundedSemaphore.acquire tripped its assert not self.lock,
'Already locked' guard: the object stayed permanently “already locked”
even after the real cause was fixed. The lock is now recorded only once
it is actually held, and any other failure resets the attribute before
propagating:
>>> import portalocker
>>> semaphore = portalocker.NamedBoundedSemaphore(
... 2, name='recovery_demo', directory='no_such_directory'
... )
>>> try:
... semaphore.acquire()
... except FileNotFoundError:
... print('propagated, and the instance is still usable')
propagated, and the instance is still usable
>>> semaphore.lock is None
True
RedisLock reaps crashed holders¶
The liveness check published its ping before the prober’s own
subscription was confirmed active, so the prober’s own confirmation
satisfied the check and a dead holder was never reaped. 4.0.0 publishes
the ping only after the subscription is confirmed, stops leaking pubsub
connections on the reap path, makes fail_when_locked=True fail fast
instead of polling for the whole timeout, closes internally created
connections on release, and rolls a failed acquire() back cleanly so
the lock can be retried.
No signature changed, but code that worked around a crashed holder’s lock
staying stuck can drop the workaround. See Redis Lock for how reaping
works, and for the shared (LockFlags.SHARED) Redis readers that 4.0.0
also added (#124).
Everything else¶
A few more fixes change behaviour without changing any signature:
portalocker.open_atomic no longer silently replaces a destination that another process created while its context was open. Publication raises
FileExistsErrorand preserves the concurrent winner (#114).TemporaryFileLock and PidFileLock closed an unlock-then-unlink race that could let two processes believe they held the same lock. Release now unlinks before unlocking on POSIX, and acquire re-verifies file identity after locking (#115). Their
release()is also a no-op when the object does not hold the lock, so a stale object can no longer unlink the file out from under the current holder.On Windows, the
msvcrtlocker locked from the current file position instead of byte 0 for raw file descriptors, which could break mutual exclusion on files larger than 64KiB.python -m portalocker combinenow inlines portalocker.redis properly, so the single-file build bindsRedisLockthe way the package does instead of always leaving it as None. See Command-Line Interface.
The complete list is in the Changelog. If an upgrade still misbehaves, Troubleshooting groups the recurring symptoms by what you actually see.