lockfile-0.8/0000775000076500000240000000000011164466255012016 5ustar skipstafflockfile-0.8/ACKS0000664000076500000240000000013710717551725012463 0ustar skipstaffThanks to the following people for help with lockfile. Konstantin Veretennicov Scott Dial lockfile-0.8/doc/0000775000076500000240000000000011164466255012563 5ustar skipstafflockfile-0.8/doc/glossary.rst0000664000076500000240000000046411063044722015151 0ustar skipstaff.. _glossary: ******** Glossary ******** .. if you add new entries, keep the alphabetical sorting! .. glossary:: context manager An object which controls the environment seen in a :keyword:`with` statement by defining :meth:`__enter__` and :meth:`__exit__` methods. See :pep:`343`. lockfile-0.8/doc/index.rst0000664000076500000240000000071111063044605014410 0ustar skipstaff.. lockfile documentation master file, created by sphinx-quickstart on Sat Sep 13 17:54:17 2008. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to lockfile's documentation! ==================================== Contents: .. toctree:: :maxdepth: 2 lockfile.rst glossary.rst Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` lockfile-0.8/doc/lockfile.rst0000664000076500000240000002175511164121176015105 0ustar skipstaff :mod:`lockfile` --- Platform-independent file locking ===================================================== .. module:: lockfile :synopsis: Platform-independent file locking .. moduleauthor:: Skip Montanaro .. sectionauthor:: Skip Montanaro .. note:: This module is alpha software. It is quite possible that the API and implementation will change in important ways as people test it and provide feedback and bug fixes. In particular, if the mkdir-based locking scheme is sufficient for both Windows and Unix platforms, the link-based scheme may be deleted so that only a single locking scheme is used, providing cross-platform lockfile cooperation. .. note:: The implementation uses the :keyword:`with` statement, both in the tests and in the main code, so will only work out-of-the-box with Python 2.5 or later. However, the use of the :keyword:`with` statement is minimal, so if you apply the patch in the included 2.4.diff file you can use it with Python 2.4. It's possible that it will work in Python 2.3 with that patch applied as well, though the doctest code relies on APIs new in 2.4, so will have to be rewritten somewhat to allow testing on 2.3. As they say, patches welcome. ``;-)`` The :mod:`lockfile` module exports a :class:`FileLock` class which provides a simple API for locking files. Unlike the Windows :func:`msvcrt.locking` function, the Unix :func:`fcntl.flock`, :func:`fcntl.lockf` and the deprecated :mod:`posixfile` module, the API is identical across both Unix (including Linux and Mac) and Windows platforms. The lock mechanism relies on the atomic nature of the :func:`link` (on Unix) and :func:`mkdir` (On Windows) system calls. .. note:: The current implementation uses :func:`os.link` on Unix, but since that function is unavailable on Windows it uses :func:`os.mkdir` there. At this point it's not clear that using the :func:`os.mkdir` method would be insufficient on Unix systems. If it proves to be adequate on Unix then the implementation could be simplified and truly cross-platform locking would be possible. .. note:: The current implementation doesn't provide for shared vs. exclusive locks. It should be possible for multiple reader processes to hold the lock at the same time. The module defines the following exceptions: .. exception:: Error This is the base class for all exceptions raised by the :class:`LockFile` class. .. exception:: LockError This is the base class for all exceptions raised when attempting to lock a file. .. exception:: UnlockError This is the base class for all exceptions raised when attempting to unlock a file. .. exception:: LockTimeout This exception is raised if the :func:`LockFile.acquire` method is called with a timeout which expires before an existing lock is released. .. exception:: AlreadyLocked This exception is raised if the :func:`LockFile.acquire` detects a file is already locked when in non-blocking mode. .. exception:: LockFailed This exception is raised if the :func:`LockFile.acquire` detects some other condition (such as a non-writable directory) which prevents it from creating its lock file. .. exception:: NotLocked This exception is raised if the file is not locked when :func:`LockFile.release` is called. .. exception:: NotMyLock This exception is raised if the file is locked by another thread or process when :func:`LockFile.release` is called. The following classes are provided: .. class:: LinkFileLock(path, threaded=True) This class uses the :func:`link(2)` system call as the basic lock mechanism. *path* is an object in the file system to be locked. It need not exist, but its directory must exist and be writable at the time the :func:`acquire` and :func:`release` methods are called. *threaded* is optional, but when set to :const:`True` locks will be distinguished between threads in the same process. .. class:: MkdirFileLock(path, threaded=True) This class uses the :func:`mkdir(2)` system call as the basic lock mechanism. The parameters have the same meaning as for the :class:`LinkFileLock` class. .. class:: SQLiteFileLock(path, threaded=True) This class uses the :mod:`sqlite3` module to implement the lock mechanism. The parameters have the same meaning as for the :class:`LinkFileLock` class. By default, the :const:`FileLock` object refers to the :class:`MkdirFileLock` class on Windows. On all other platforms it refers to the :class:`LinkFileLock` class. When locking a file the :class:`LinkFileLock` class creates a uniquely named hard link to an empty lock file. That hard link contains the hostname, process id, and if locks between threads are distinguished, the thread identifier. For example, if you want to lock access to a file named "README", the lock file is named "README.lock". With per-thread locks enabled the hard link is named HOSTNAME-THREADID-PID. With only per-process locks enabled the hard link is named HOSTNAME--PID. When using the :class:`MkdirFileLock` class the lock file is a directory. Referring to the example above, README.lock will be a directory and HOSTNAME-THREADID-PID will be an empty file within that directory. .. seealso:: Module :mod:`msvcrt` Provides the :func:`locking` function, the standard Windows way of locking (parts of) a file. Module :mod:`posixfile` The deprecated (since Python 1.5) way of locking files on Posix systems. Module :mod:`fcntl` Provides the current best way to lock files on Unix systems (:func:`lockf` and :func:`flock`). Implementing Other Locking Schemes ---------------------------------- There is a :class:`LockBase` base class which can be used as the foundation for other locking schemes. For example, if shared filesystems are not available, :class:`LockBase` could be subclassed to provide locking via an SQL database. FileLock Objects ---------------- :class:`FileLock` objects support the :term:`context manager` protocol used by the statement:`with` statement. The timeout option is not supported when used in this fashion. While support for timeouts could be implemented, there is no support for handling the eventual :exc:`Timeout` exceptions raised by the :func:`__enter__` method, so you would have to protect the :keyword:`with` statement with a :keyword:`try` statement. The resulting construct would not be much simpler than just using a :keyword:`try` statement in the first place. :class:`FileLock` has the following user-visible methods: .. method:: FileLock.acquire(timeout=None) Lock the file associated with the :class:`FileLock` object. If the *timeout* is omitted or :const:`None` the caller will block until the file is unlocked by the object currently holding the lock. If the *timeout* is zero or a negative number the :exc:`AlreadyLocked` exception will be raised if the file is currently locked by another process or thread. If the *timeout* is positive, the caller will block for that many seconds waiting for the lock to be released. If the lock is not released within that period the :exc:`LockTimeout` exception will be raised. .. method:: FileLock.release() Unlock the file associated with the :class:`FileLock` object. If the file is not currently locked, the :exc:`NotLocked` exception is raised. If the file is locked by another thread or process the :exc:`NotMyLock` exception is raised. .. method:: is_locked() Return the status of the lock on the current file. If any process or thread (including the current one) is locking the file, :const:`True` is returned, otherwise :const:`False` is returned. .. method:: break_lock() If the file is currently locked, break it. Examples -------- This example is the "hello world" for the :mod:`lockfile` module:: lock = FileLock("/some/file/or/other") with lock: print lock.path, 'is locked.' To use this with Python 2.4, you can execute:: lock = FileLock("/some/file/or/other") lock.acquire() print lock.path, 'is locked.' lock.release() If you don't want to wait forever, you might try:: lock = FileLock("/some/file/or/other") while not lock.i_am_locking(): try: lock.acquire(timeout=60) # wait up to 60 seconds except LockTimeout: lock.break_lock() lock.acquire() print "I locked", lock.path lock.release() Other Libraries --------------- The idea of implementing advisory locking with a standard API is not new with :mod:`lockfile`. There are a number of other libraries available: * locknix - http://pypi.python.org/pypi/locknix - Unix only * mx.MiscLockFile - from Marc André Lemburg, part of the mx.Base distribution - cross-platform. * Twisted - http://twistedmatrix.com/trac/browser/trunk/twisted/python/lockfile.py * zc.lockfile - http://pypi.python.org/pypi/zc.lockfile Contacting the Author --------------------- If you encounter any problems with ``lockfile``, would like help or want to submit a patch, contact me directly: Skip Montanaro (skip@pobox.com). lockfile-0.8/LICENSE0000664000076500000240000000216010711345043013006 0ustar skipstaffThis is the MIT license: http://www.opensource.org/licenses/mit-license.php Copyright (c) 2007 Skip Montanaro. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. lockfile-0.8/lockfile.py0000664000076500000240000003542011164121176014152 0ustar skipstaff """ lockfile.py - Platform-independent advisory file locks. Requires Python 2.5 unless you apply 2.4.diff Locking is done on a per-thread basis instead of a per-process basis. Usage: >>> lock = FileLock('somefile') >>> try: ... lock.acquire() ... except AlreadyLocked: ... print 'somefile', 'is locked already.' ... except LockFailed: ... print 'somefile', 'can\\'t be locked.' ... else: ... print 'got lock' got lock >>> print lock.is_locked() True >>> lock.release() >>> lock = FileLock('somefile') >>> print lock.is_locked() False >>> with lock: ... print lock.is_locked() True >>> print lock.is_locked() False >>> # It is okay to lock twice from the same thread... >>> with lock: ... lock.acquire() ... >>> # Though no counter is kept, so you can't unlock multiple times... >>> print lock.is_locked() False Exceptions: Error - base class for other exceptions LockError - base class for all locking exceptions AlreadyLocked - Another thread or process already holds the lock LockFailed - Lock failed for some other reason UnlockError - base class for all unlocking exceptions AlreadyUnlocked - File was not locked. NotMyLock - File was locked but not by the current thread/process """ from __future__ import division import sys import socket import os import thread import threading import time import errno import urllib # Work with PEP8 and non-PEP8 versions of threading module. if not hasattr(threading, "current_thread"): threading.current_thread = threading.currentThread if not hasattr(threading.Thread, "get_name"): threading.Thread.get_name = threading.Thread.getName __all__ = ['Error', 'LockError', 'LockTimeout', 'AlreadyLocked', 'LockFailed', 'UnlockError', 'NotLocked', 'NotMyLock', 'LinkFileLock', 'MkdirFileLock', 'SQLiteFileLock'] class Error(Exception): """ Base class for other exceptions. >>> try: ... raise Error ... except Exception: ... pass """ pass class LockError(Error): """ Base class for error arising from attempts to acquire the lock. >>> try: ... raise LockError ... except Error: ... pass """ pass class LockTimeout(LockError): """Raised when lock creation fails within a user-defined period of time. >>> try: ... raise LockTimeout ... except LockError: ... pass """ pass class AlreadyLocked(LockError): """Some other thread/process is locking the file. >>> try: ... raise AlreadyLocked ... except LockError: ... pass """ pass class LockFailed(LockError): """Lock file creation failed for some other reason. >>> try: ... raise LockFailed ... except LockError: ... pass """ pass class UnlockError(Error): """ Base class for errors arising from attempts to release the lock. >>> try: ... raise UnlockError ... except Error: ... pass """ pass class NotLocked(UnlockError): """Raised when an attempt is made to unlock an unlocked file. >>> try: ... raise NotLocked ... except UnlockError: ... pass """ pass class NotMyLock(UnlockError): """Raised when an attempt is made to unlock a file someone else locked. >>> try: ... raise NotMyLock ... except UnlockError: ... pass """ pass class LockBase: """Base class for platform-specific lock classes.""" def __init__(self, path, threaded=True): """ >>> lock = LockBase('somefile') >>> lock = LockBase('somefile', threaded=False) """ self.path = path self.lock_file = os.path.abspath(path) + ".lock" self.hostname = socket.gethostname() self.pid = os.getpid() if threaded: name = threading.current_thread().get_name() tname = "%s-" % urllib.quote(name, safe="") else: tname = "" dirname = os.path.dirname(self.lock_file) self.unique_name = os.path.join(dirname, "%s.%s%s" % (self.hostname, tname, self.pid)) def acquire(self, timeout=None): """ Acquire the lock. * If timeout is omitted (or None), wait forever trying to lock the file. * If timeout > 0, try to acquire the lock for that many seconds. If the lock period expires and the file is still locked, raise LockTimeout. * If timeout <= 0, raise AlreadyLocked immediately if the file is already locked. """ raise NotImplemented("implement in subclass") def release(self): """ Release the lock. If the file is not locked, raise NotLocked. """ raise NotImplemented("implement in subclass") def is_locked(self): """ Tell whether or not the file is locked. """ raise NotImplemented("implement in subclass") def i_am_locking(self): """ Return True if this object is locking the file. """ raise NotImplemented("implement in subclass") def break_lock(self): """ Remove a lock. Useful if a locking thread failed to unlock. """ raise NotImplemented("implement in subclass") def __enter__(self): """ Context manager support. """ self.acquire() return self def __exit__(self, *_exc): """ Context manager support. """ self.release() class LinkFileLock(LockBase): """Lock access to a file using atomic property of link(2).""" def acquire(self, timeout=None): try: open(self.unique_name, "wb").close() except IOError: raise LockFailed("failed to create %s" % self.unique_name) end_time = time.time() if timeout is not None and timeout > 0: end_time += timeout while True: # Try and create a hard link to it. try: os.link(self.unique_name, self.lock_file) except OSError: # Link creation failed. Maybe we've double-locked? nlinks = os.stat(self.unique_name).st_nlink if nlinks == 2: # The original link plus the one I created == 2. We're # good to go. return else: # Otherwise the lock creation failed. if timeout is not None and time.time() > end_time: os.unlink(self.unique_name) if timeout > 0: raise LockTimeout else: raise AlreadyLocked time.sleep(timeout is not None and timeout/10 or 0.1) else: # Link creation succeeded. We're good to go. return def release(self): if not self.is_locked(): raise NotLocked elif not os.path.exists(self.unique_name): raise NotMyLock os.unlink(self.unique_name) os.unlink(self.lock_file) def is_locked(self): return os.path.exists(self.lock_file) def i_am_locking(self): return (self.is_locked() and os.path.exists(self.unique_name) and os.stat(self.unique_name).st_nlink == 2) def break_lock(self): if os.path.exists(self.lock_file): os.unlink(self.lock_file) class MkdirFileLock(LockBase): """Lock file by creating a directory.""" def __init__(self, path, threaded=True): """ >>> lock = MkdirFileLock('somefile') >>> lock = MkdirFileLock('somefile', threaded=False) """ LockBase.__init__(self, path, threaded) if threaded: tname = "%x-" % thread.get_ident() else: tname = "" # Lock file itself is a directory. Place the unique file name into # it. self.unique_name = os.path.join(self.lock_file, "%s.%s%s" % (self.hostname, tname, self.pid)) def acquire(self, timeout=None): end_time = time.time() if timeout is not None and timeout > 0: end_time += timeout if timeout is None: wait = 0.1 else: wait = max(0, timeout / 10) while True: try: os.mkdir(self.lock_file) except OSError: err = sys.exc_info()[1] if err.errno == errno.EEXIST: # Already locked. if os.path.exists(self.unique_name): # Already locked by me. return if timeout is not None and time.time() > end_time: if timeout > 0: raise LockTimeout else: # Someone else has the lock. raise AlreadyLocked time.sleep(wait) else: # Couldn't create the lock for some other reason raise LockFailed("failed to create %s" % self.lock_file) else: open(self.unique_name, "wb").close() return def release(self): if not self.is_locked(): raise NotLocked elif not os.path.exists(self.unique_name): raise NotMyLock os.unlink(self.unique_name) os.rmdir(self.lock_file) def is_locked(self): return os.path.exists(self.lock_file) def i_am_locking(self): return (self.is_locked() and os.path.exists(self.unique_name)) def break_lock(self): if os.path.exists(self.lock_file): for name in os.listdir(self.lock_file): os.unlink(os.path.join(self.lock_file, name)) os.rmdir(self.lock_file) class SQLiteFileLock(LockBase): "Demonstration of using same SQL-based locking." import tempfile _fd, testdb = tempfile.mkstemp() os.close(_fd) os.unlink(testdb) del _fd, tempfile def __init__(self, path, threaded=True): LockBase.__init__(self, path, threaded) self.lock_file = unicode(self.lock_file) self.unique_name = unicode(self.unique_name) import sqlite3 self.connection = sqlite3.connect(SQLiteFileLock.testdb) c = self.connection.cursor() try: c.execute("create table locks" "(" " lock_file varchar(32)," " unique_name varchar(32)" ")") except sqlite3.OperationalError: pass else: self.connection.commit() import atexit atexit.register(os.unlink, SQLiteFileLock.testdb) def acquire(self, timeout=None): end_time = time.time() if timeout is not None and timeout > 0: end_time += timeout if timeout is None: wait = 0.1 elif timeout <= 0: wait = 0 else: wait = timeout / 10 cursor = self.connection.cursor() while True: if not self.is_locked(): # Not locked. Try to lock it. cursor.execute("insert into locks" " (lock_file, unique_name)" " values" " (?, ?)", (self.lock_file, self.unique_name)) self.connection.commit() # Check to see if we are the only lock holder. cursor.execute("select * from locks" " where unique_name = ?", (self.unique_name,)) rows = cursor.fetchall() if len(rows) > 1: # Nope. Someone else got there. Remove our lock. cursor.execute("delete from locks" " where unique_name = ?", (self.unique_name,)) self.connection.commit() else: # Yup. We're done, so go home. return else: # Check to see if we are the only lock holder. cursor.execute("select * from locks" " where unique_name = ?", (self.unique_name,)) rows = cursor.fetchall() if len(rows) == 1: # We're the locker, so go home. return # Maybe we should wait a bit longer. if timeout is not None and time.time() > end_time: if timeout > 0: # No more waiting. raise LockTimeout else: # Someone else has the lock and we are impatient.. raise AlreadyLocked # Well, okay. We'll give it a bit longer. time.sleep(wait) def release(self): if not self.is_locked(): raise NotLocked if not self.i_am_locking(): raise NotMyLock((self._who_is_locking(), self.unique_name)) cursor = self.connection.cursor() cursor.execute("delete from locks" " where unique_name = ?", (self.unique_name,)) self.connection.commit() def _who_is_locking(self): cursor = self.connection.cursor() cursor.execute("select unique_name from locks" " where lock_file = ?", (self.lock_file,)) return cursor.fetchone()[0] def is_locked(self): cursor = self.connection.cursor() cursor.execute("select * from locks" " where lock_file = ?", (self.lock_file,)) rows = cursor.fetchall() return not not rows def i_am_locking(self): cursor = self.connection.cursor() cursor.execute("select * from locks" " where lock_file = ?" " and unique_name = ?", (self.lock_file, self.unique_name)) return not not cursor.fetchall() def break_lock(self): cursor = self.connection.cursor() cursor.execute("delete from locks" " where lock_file = ?", (self.lock_file,)) self.connection.commit() if hasattr(os, "link"): FileLock = LinkFileLock else: FileLock = MkdirFileLock lockfile-0.8/MANIFEST0000664000076500000240000000016011164121176013132 0ustar skipstaffACKS LICENSE MANIFEST README RELEASE-NOTES lockfile.py setup.py doc/glossary.rst doc/index.rst doc/lockfile.rst lockfile-0.8/PKG-INFO0000664000076500000240000000275611164466255013125 0ustar skipstaffMetadata-Version: 1.0 Name: lockfile Version: 0.8 Summary: Platform-independent file locking module Home-page: http://smontanaro.dyndns.org/python/ Author: Skip Montanaro Author-email: skip@pobox.com License: MIT License Download-URL: http://smontanaro.dyndns.org/python/lockfile-0.8.tar.gz Description: The lockfile module exports a FileLock class which provides a simple API for locking files. Unlike the Windows msvcrt.locking function, the Unix fcntl.flock, fcntl.lockf and the deprecated posixfile module, the API is identical across both Unix (including Linux and Mac) and Windows platforms. The lock mechanism relies on the atomic nature of the link (on Unix) and mkdir (on Windows) system calls. Version 0.8 fixes several bugs relating to threads and test reorganization. Platform: UNKNOWN Classifier: Development Status :: 4 - Beta Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: MacOS Classifier: Operating System :: Microsoft :: Windows :: Windows NT/2000 Classifier: Operating System :: POSIX Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2.4 Classifier: Programming Language :: Python :: 2.5 Classifier: Programming Language :: Python :: 2.6 Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3.0 Classifier: Topic :: Software Development :: Libraries :: Python Modules lockfile-0.8/README0000664000076500000240000000111511164121176012662 0ustar skipstaffThe lockfile module exports a FileLock class which provides a simple API for locking files. Unlike the Windows msvcrt.locking function, the fcntl.lockf and flock functions, and the deprecated posixfile module, the API is identical across both Unix (including Linux and Mac) and Windows platforms. The lock mechanism relies on the atomic nature of the link (on Unix) and mkdir (on Windows) system calls. An implementation based on SQLite is also provided, more as a demonstration of the possibilities it provides than as production-quality code. To install: python setup.py install lockfile-0.8/RELEASE-NOTES0000664000076500000240000000106511164121176013677 0ustar skipstaffVersion 0.3 =========== * Fix 2.4.diff file error. * More documentation updates. Version 0.2 =========== * Added 2.4.diff file to patch lockfile to work with Python 2.4 (removes use of with statement). * Renamed _FileLock base class to LockBase to expose it (and its docstrings) to pydoc. * Got rid of time.sleep() calls in tests (thanks to Konstantin Veretennicov). * Use thread.get_ident() as the thread discriminator. * Updated documentation a bit. * Added RELEASE-NOTES. Version 0.1 =========== * First release - All basic functionality there. lockfile-0.8/setup.py0000664000076500000240000000320411164121176013515 0ustar skipstaff#!/usr/bin/env python V = "0.8" from distutils.core import setup setup(name='lockfile', author='Skip Montanaro', author_email='skip@pobox.com', url='http://smontanaro.dyndns.org/python/', download_url=('http://smontanaro.dyndns.org/python/lockfile-%s.tar.gz' % V), version=V, description="Platform-independent file locking module", long_description=""" The lockfile module exports a FileLock class which provides a simple API for locking files. Unlike the Windows msvcrt.locking function, the Unix fcntl.flock, fcntl.lockf and the deprecated posixfile module, the API is identical across both Unix (including Linux and Mac) and Windows platforms. The lock mechanism relies on the atomic nature of the link (on Unix) and mkdir (on Windows) system calls. Version %s fixes several bugs relating to threads and test reorganization.""" % V, py_modules=['lockfile'], license='MIT License', classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: MacOS', 'Operating System :: Microsoft :: Windows :: Windows NT/2000', 'Operating System :: POSIX', 'Programming Language :: Python', 'Programming Language :: Python :: 2.4', 'Programming Language :: Python :: 2.5', 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.0', 'Topic :: Software Development :: Libraries :: Python Modules', ] )