diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..52516c34 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,72 @@ +name: Test + +on: + - push + - pull_request + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: + - 2.7 + - 3.5 + - 3.6 + - 3.7 + - 3.8 + - 3.9 + - pypy-2.7 + - pypy-3.6 + - pypy-3.7 + steps: + - name: Checkout code + uses: actions/checkout@v1 + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Update pip + run: python -m pip install -U pip wheel setuptools + - name: Install tox + run: python -m pip install tox tox-gh-actions + - name: Test with tox + run: python -m tox + - name: Collect coverage results + uses: AndreMiras/coveralls-python-action@develop + with: + parallel: true + flag-name: test (${{ matrix.python-version}}) + + coveralls: + needs: test + runs-on: ubuntu-latest + steps: + - name: Upload coverage statistics to Coveralls + uses: AndreMiras/coveralls-python-action@develop + with: + parallel-finished: true + + lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + linter: + - typecheck + - codestyle + - docstyle + - codeformat + steps: + - name: Checkout code + uses: actions/checkout@v1 + - name: Setup Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Update pip + run: python -m pip install -U pip wheel setuptools + - name: Install tox + run: python -m pip install tox tox-gh-actions + - name: Run ${{ matrix.linter }} linter + run: python -m tox -e ${{ matrix.linter }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 89b1cce9..00000000 --- a/.travis.yml +++ /dev/null @@ -1,57 +0,0 @@ -dist: xenial -sudo: false -language: python - -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" - - "3.7" - - "3.8" - - "3.9" - - "pypy" - - "pypy3.5-7.0" # Need 7.0+ due to a bug in earlier versions that broke our tests. - -matrix: - include: - - name: "Type checking" - python: "3.7" - env: TOXENV=typecheck - - name: "Lint" - python: "3.7" - env: TOXENV=lint - - # Temporary bandaid for https://github.com/PyFilesystem/pyfilesystem2/issues/342 - allow_failures: - - python: pypy - - python: pypy3.5-7.0 - -before_install: - - pip install -U tox tox-travis - - pip --version - - pip install -r testrequirements.txt - - pip freeze - -install: - - pip install -e . - -# command to run tests -script: tox - -after_success: - - coveralls - -before_deploy: - - pip install -U twine wheel - - python setup.py sdist bdist_wheel - -deploy: - provider: script - script: twine upload dist/* - skip_cleanup: true - on: - python: 3.9 - tags: true - repo: PyFilesystem/pyfilesystem2 - diff --git a/CHANGELOG.md b/CHANGELOG.md index 48676340..b75569fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased ### Changed -- Make `FS.upload` explicit about the expected error when the parent directory of the destination does not exist - [#445](https://github.com/PyFilesystem/pyfilesystem2/pull/445). + +- Make `FS.upload` explicit about the expected error when the parent directory of the destination does not exist. + Closes [#445](https://github.com/PyFilesystem/pyfilesystem2/pull/445). +- Migrate continuous integration from Travis-CI to GitHub Actions and introduce several linters + again in the build steps ([#448](https://github.com/PyFilesystem/pyfilesystem2/pull/448)). + Closes [#446](https://github.com/PyFilesystem/pyfilesystem2/pull/446). +- Stop requiring `pytest` to run tests, allowing any test runner supporting `unittest`-style + test suites. +- `FSTestCases` now builds the large data required for `upload` and `download` tests only + once in order to reduce the total testing time. + +### Fixed + +- Make `FTPFile`, `MemoryFile` and `RawWrapper` accept [`array.array`](https://docs.python.org/3/library/array.html) + arguments for the `write` and `writelines` methods, as expected by their base class [`io.RawIOBase`](https://docs.python.org/3/library/io.html#io.RawIOBase). +- Various documentation issues, including `MemoryFS` docstring not rendering properly. ## [2.4.12] - 2021-01-14 @@ -22,6 +36,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). [#380](https://github.com/PyFilesystem/pyfilesystem2/issues/380). - Added compatibility if a Windows FTP server returns file information to the `LIST` command with 24-hour times. Closes [#438](https://github.com/PyFilesystem/pyfilesystem2/issues/438). +- Added Python 3.9 support. Closes [#443](https://github.com/PyFilesystem/pyfilesystem2/issues/443). ### Changed @@ -30,25 +45,30 @@ and this project adheres to [Semantic Versioning](http://semver.org/). be able to see if we break something aside from known issues with FTP tests. - Include docs in source distributions as well as the whole tests folder, ensuring `conftest.py` is present, fixes [#364](https://github.com/PyFilesystem/pyfilesystem2/issues/364). -- Stop patching copy with Python 3.8+ because it already uses `sendfile`. +- Stop patching copy with Python 3.8+ because it already uses `sendfile` + ([#424](https://github.com/PyFilesystem/pyfilesystem2/pull/424)). + Closes [#421](https://github.com/PyFilesystem/pyfilesystem2/issues/421). ### Fixed - Fixed crash when CPython's -OO flag is used -- Fixed error when parsing timestamps from a FTP directory served from a WindowsNT FTP Server, fixes [#395](https://github.com/PyFilesystem/pyfilesystem2/issues/395). +- Fixed error when parsing timestamps from a FTP directory served from a WindowsNT FTP Server. + Closes [#395](https://github.com/PyFilesystem/pyfilesystem2/issues/395). - Fixed documentation of `Mode.to_platform_bin`. Closes [#382](https://github.com/PyFilesystem/pyfilesystem2/issues/382). - Fixed the code example in the "Testing Filesystems" section of the "Implementing Filesystems" guide. Closes [#407](https://github.com/PyFilesystem/pyfilesystem2/issues/407). - Fixed `FTPFS.openbin` not implicitly opening files in binary mode like expected from `openbin`. Closes [#406](https://github.com/PyFilesystem/pyfilesystem2/issues/406). + ## [2.4.11] - 2019-09-07 ### Added - Added geturl for TarFS and ZipFS for 'fs' purpose. NoURL for 'download' purpose. -- Added helpful root path in CreateFailed exception [#340](https://github.com/PyFilesystem/pyfilesystem2/issues/340) -- Added Python 3.8 support +- Added helpful root path in CreateFailed exception. + Closes [#340](https://github.com/PyFilesystem/pyfilesystem2/issues/340). +- Added Python 3.8 support. ### Fixed @@ -76,7 +96,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- Fixed broken WrapFS.movedir [#322](https://github.com/PyFilesystem/pyfilesystem2/issues/322) +- Fixed broken WrapFS.movedir [#322](https://github.com/PyFilesystem/pyfilesystem2/issues/322). ## [2.4.9] - 2019-07-28 @@ -458,7 +478,7 @@ No changes, pushed wrong branch to PyPi. ### Added -- New `copy_if_newer' functionality in`copy` module. +- New `copy_if_newer` functionality in `copy` module. ### Fixed @@ -469,17 +489,17 @@ No changes, pushed wrong branch to PyPi. ### Changed - Improved FTP support for non-compliant servers -- Fix for ZipFS implied directories +- Fix for `ZipFS` implied directories ## [2.0.1] - 2017-03-11 ### Added -- TarFS contributed by Martin Larralde +- `TarFS` contributed by Martin Larralde. ### Fixed -- FTPFS bugs. +- `FTPFS` bugs. ## [2.0.0] - 2016-12-07 diff --git a/README.md b/README.md index 787b29ec..0c4fe2ba 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,14 @@ Python's Filesystem abstraction layer. -[![PyPI version](https://badge.fury.io/py/fs.svg)](https://badge.fury.io/py/fs) +[![PyPI version](https://img.shields.io/pypi/v/fs)](https://pypi.org/project/fs/) [![PyPI](https://img.shields.io/pypi/pyversions/fs.svg)](https://pypi.org/project/fs/) -[![Downloads](https://pepy.tech/badge/fs/month)](https://pepy.tech/project/fs/month) - - -[![Build Status](https://travis-ci.org/PyFilesystem/pyfilesystem2.svg?branch=master)](https://travis-ci.org/PyFilesystem/pyfilesystem2) -[![Windows Build Status](https://ci.appveyor.com/api/projects/status/github/pyfilesystem/pyfilesystem2?branch=master&svg=true)](https://ci.appveyor.com/project/willmcgugan/pyfilesystem2) -[![Coverage Status](https://coveralls.io/repos/github/PyFilesystem/pyfilesystem2/badge.svg)](https://coveralls.io/github/PyFilesystem/pyfilesystem2) -[![Codacy Badge](https://api.codacy.com/project/badge/Grade/30ad6445427349218425d93886ade9ee)](https://www.codacy.com/app/will-mcgugan/pyfilesystem2?utm_source=github.com&utm_medium=referral&utm_content=PyFilesystem/pyfilesystem2&utm_campaign=Badge_Grade) +[![Downloads](https://pepy.tech/badge/fs/month)](https://pepy.tech/project/fs/) +[![Build Status](https://img.shields.io/github/workflow/status/PyFilesystem/pyfilesystem2/Test/master?logo=github&cacheSeconds=600)](https://github.com/PyFilesystem/pyfilesystem2/actions?query=branch%3Amaster) +[![Windows Build Status](https://img.shields.io/appveyor/build/willmcgugan/pyfilesystem2/master?logo=appveyor&cacheSeconds=600)](https://ci.appveyor.com/project/willmcgugan/pyfilesystem2) +[![Coverage Status](https://img.shields.io/coveralls/github/PyFilesystem/pyfilesystem2/master?cacheSeconds=600)](https://coveralls.io/github/PyFilesystem/pyfilesystem2) +[![Codacy Badge](https://img.shields.io/codacy/grade/30ad6445427349218425d93886ade9ee/master?logo=codacy)](https://www.codacy.com/app/will-mcgugan/pyfilesystem2?utm_source=github.com&utm_medium=referral&utm_content=PyFilesystem/pyfilesystem2&utm_campaign=Badge_Grade) +[![Docs](https://img.shields.io/readthedocs/pyfilesystem2?maxAge=3600)](http://pyfilesystem2.readthedocs.io/en/stable/?badge=stable) ## Documentation diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..c2d9a973 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,3 @@ +# the bare requirements for building docs +Sphinx ~=3.0 +sphinx-rtd-theme ~=0.5.1 diff --git a/docs/source/conf.py b/docs/source/conf.py index a5cc0d23..749c3330 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -304,3 +304,14 @@ #texinfo_no_detailmenu = False napoleon_include_special_with_doc = True + + +# -- Options for autodoc ----------------------------------------------------- + +# Configure autodoc so that it doesn't skip building the documentation for +# __init__ methods, since the arguments to instantiate classes should be in +# the __init__ docstring and not at the class-level. + +autodoc_default_options = { + 'special-members': '__init__', +} diff --git a/fs/appfs.py b/fs/appfs.py index dafe2e98..131ea8a8 100644 --- a/fs/appfs.py +++ b/fs/appfs.py @@ -9,8 +9,11 @@ # see http://technet.microsoft.com/en-us/library/cc766489(WS.10).aspx +import abc import typing +import six + from .osfs import OSFS from ._repr import make_repr from appdirs import AppDirs @@ -29,10 +32,25 @@ ] -class _AppFS(OSFS): - """Abstract base class for an app FS. +class _CopyInitMeta(abc.ABCMeta): + """A metaclass that performs a hard copy of the `__init__`. + + This is a fix for Sphinx, which is a pain to configure in a way that + it documents the ``__init__`` method of a class when it is inherited. + Copying ``__init__`` makes it think it is not inherited, and let us + share the documentation between all the `_AppFS` subclasses. + """ + def __new__(mcls, classname, bases, cls_dict): + cls_dict.setdefault("__init__", bases[0].__init__) + return super(abc.ABCMeta, mcls).__new__(mcls, classname, bases, cls_dict) + + +@six.add_metaclass(_CopyInitMeta) +class _AppFS(OSFS): + """Abstract base class for an app FS.""" + # FIXME(@althonos): replace by ClassVar[Text] once # https://github.com/python/mypy/pull/4718 is accepted # (subclass override will raise errors until then) @@ -47,6 +65,19 @@ def __init__( create=True, # type: bool ): # type: (...) -> None + """Create a new application-specific filesystem. + + Arguments: + appname (str): The name of the application. + author (str): The name of the author (used on Windows). + version (str): Optional version string, if a unique location + per version of the application is required. + roaming (bool): If `True`, use a *roaming* profile on + Windows. + create (bool): If `True` (the default) the directory + will be created if it does not exist. + + """ self.app_dirs = AppDirs(appname, author, version, roaming) self._create = create super(_AppFS, self).__init__( @@ -77,16 +108,6 @@ class UserDataFS(_AppFS): May also be opened with ``open_fs('userdata://appname:author:version')``. - Arguments: - appname (str): The name of the application. - author (str): The name of the author (used on Windows). - version (str): Optional version string, if a unique location - per version of the application is required. - roaming (bool): If `True`, use a *roaming* profile on - Windows. - create (bool): If `True` (the default) the directory - will be created if it does not exist. - """ app_dir = "user_data_dir" @@ -98,16 +119,6 @@ class UserConfigFS(_AppFS): May also be opened with ``open_fs('userconf://appname:author:version')``. - Arguments: - appname (str): The name of the application. - author (str): The name of the author (used on Windows). - version (str): Optional version string, if a unique location - per version of the application is required. - roaming (bool): If `True`, use a *roaming* profile on - Windows. - create (bool): If `True` (the default) the directory - will be created if it does not exist. - """ app_dir = "user_config_dir" @@ -119,16 +130,6 @@ class UserCacheFS(_AppFS): May also be opened with ``open_fs('usercache://appname:author:version')``. - Arguments: - appname (str): The name of the application. - author (str): The name of the author (used on Windows). - version (str): Optional version string, if a unique location - per version of the application is required. - roaming (bool): If `True`, use a *roaming* profile on - Windows. - create (bool): If `True` (the default) the directory - will be created if it does not exist. - """ app_dir = "user_cache_dir" @@ -140,16 +141,6 @@ class SiteDataFS(_AppFS): May also be opened with ``open_fs('sitedata://appname:author:version')``. - Arguments: - appname (str): The name of the application. - author (str): The name of the author (used on Windows). - version (str): Optional version string, if a unique location - per version of the application is required. - roaming (bool): If `True`, use a *roaming* profile on - Windows. - create (bool): If `True` (the default) the directory - will be created if it does not exist. - """ app_dir = "site_data_dir" @@ -161,16 +152,6 @@ class SiteConfigFS(_AppFS): May also be opened with ``open_fs('siteconf://appname:author:version')``. - Arguments: - appname (str): The name of the application. - author (str): The name of the author (used on Windows). - version (str): Optional version string, if a unique location - per version of the application is required. - roaming (bool): If `True`, use a *roaming* profile on - Windows. - create (bool): If `True` (the default) the directory - will be created if it does not exist. - """ app_dir = "site_config_dir" @@ -182,16 +163,6 @@ class UserLogFS(_AppFS): May also be opened with ``open_fs('userlog://appname:author:version')``. - Arguments: - appname (str): The name of the application. - author (str): The name of the author (used on Windows). - version (str): Optional version string, if a unique location - per version of the application is required. - roaming (bool): If `True`, use a *roaming* profile on - Windows. - create (bool): If `True` (the default) the directory - will be created if it does not exist. - """ app_dir = "user_log_dir" diff --git a/fs/base.py b/fs/base.py index 286cd025..ecd82a9e 100644 --- a/fs/base.py +++ b/fs/base.py @@ -92,8 +92,7 @@ def _method(*args, **kwargs): @six.add_metaclass(abc.ABCMeta) class FS(object): - """Base class for FS objects. - """ + """Base class for FS objects.""" # This is the "standard" meta namespace. _meta = {} # type: Dict[Text, Union[Text, int, bool, None]] @@ -106,8 +105,7 @@ class FS(object): def __init__(self): # type: (...) -> None - """Create a filesystem. See help(type(self)) for accurate signature. - """ + """Create a filesystem. See help(type(self)) for accurate signature.""" self._closed = False self._lock = threading.RLock() super(FS, self).__init__() @@ -118,8 +116,7 @@ def __del__(self): def __enter__(self): # type: (...) -> FS - """Allow use of filesystem as a context manager. - """ + """Allow use of filesystem as a context manager.""" return self def __exit__( @@ -129,21 +126,18 @@ def __exit__( traceback, # type: Optional[TracebackType] ): # type: (...) -> None - """Close filesystem on exit. - """ + """Close filesystem on exit.""" self.close() @property def glob(self): - """`~fs.glob.BoundGlobber`: a globber object.. - """ + """`~fs.glob.BoundGlobber`: a globber object..""" return BoundGlobber(self) @property def walk(self): # type: (_F) -> BoundWalker[_F] - """`~fs.walk.BoundWalker`: a walker bound to this filesystem. - """ + """`~fs.walk.BoundWalker`: a walker bound to this filesystem.""" return self.walker_class.bind(self) # ---------------------------------------------------------------- # @@ -544,26 +538,22 @@ def filterdir( def match_dir(patterns, info): # type: (Optional[Iterable[Text]], Info) -> bool - """Pattern match info.name. - """ + """Pattern match info.name.""" return info.is_file or self.match(patterns, info.name) def match_file(patterns, info): # type: (Optional[Iterable[Text]], Info) -> bool - """Pattern match info.name. - """ + """Pattern match info.name.""" return info.is_dir or self.match(patterns, info.name) def exclude_dir(patterns, info): # type: (Optional[Iterable[Text]], Info) -> bool - """Pattern match info.name. - """ + """Pattern match info.name.""" return info.is_file or not self.match(patterns, info.name) def exclude_file(patterns, info): # type: (Optional[Iterable[Text]], Info) -> bool - """Pattern match info.name. - """ + """Pattern match info.name.""" return info.is_dir or not self.match(patterns, info.name) if files: @@ -608,7 +598,7 @@ def readbytes(self, path): def download(self, path, file, chunk_size=None, **options): # type: (Text, BinaryIO, Optional[int], **Any) -> None - """Copies a file from the filesystem to a file-like object. + """Copy a file from the filesystem to a file-like object. This may be more efficient that opening and copying files manually if the filesystem supplies an optimized method. @@ -751,7 +741,7 @@ def getsyspath(self, path): # type: (Text) -> Text """Get the *system path* of a resource. - Parameters: + Arguments: path (str): A path on the filesystem. Returns: @@ -787,10 +777,9 @@ def getsyspath(self, path): def getospath(self, path): # type: (Text) -> bytes - """Get a *system path* to a resource, encoded in the operating - system's prefered encoding. + """Get the *system path* to a resource, in the OS' prefered encoding. - Parameters: + Arguments: path (str): A path on the filesystem. Returns: @@ -819,7 +808,7 @@ def gettype(self, path): # type: (Text) -> ResourceType """Get the type of a resource. - Parameters: + Arguments: path (str): A path on the filesystem. Returns: @@ -857,7 +846,7 @@ def geturl(self, path, purpose="download"): # type: (Text, Text) -> Text """Get the URL to a given resource. - Parameters: + Arguments: path (str): A path on the filesystem purpose (str): A short string that indicates which URL to retrieve for the given path (if there is more than @@ -878,7 +867,7 @@ def hassyspath(self, path): # type: (Text) -> bool """Check if a path maps to a system path. - Parameters: + Arguments: path (str): A path on the filesystem. Returns: @@ -896,7 +885,7 @@ def hasurl(self, path, purpose="download"): # type: (Text, Text) -> bool """Check if a path has a corresponding URL. - Parameters: + Arguments: path (str): A path on the filesystem. purpose (str): A purpose parameter, as given in `~fs.base.FS.geturl`. @@ -914,15 +903,14 @@ def hasurl(self, path, purpose="download"): def isclosed(self): # type: () -> bool - """Check if the filesystem is closed. - """ + """Check if the filesystem is closed.""" return getattr(self, "_closed", False) def isdir(self, path): # type: (Text) -> bool """Check if a path maps to an existing directory. - Parameters: + Arguments: path (str): A path on the filesystem. Returns: @@ -941,7 +929,7 @@ def isempty(self, path): A directory is considered empty when it does not contain any file or any directory. - Parameters: + Arguments: path (str): A path to a directory on the filesystem. Returns: @@ -958,7 +946,7 @@ def isfile(self, path): # type: (Text) -> bool """Check if a path maps to an existing file. - Parameters: + Arguments: path (str): A path on the filesystem. Returns: @@ -974,7 +962,7 @@ def islink(self, path): # type: (Text) -> bool """Check if a path maps to a symlink. - Parameters: + Arguments: path (str): A path on the filesystem. Returns: @@ -1020,7 +1008,7 @@ def movedir(self, src_path, dst_path, create=False): # type: (Text, Text, bool) -> None """Move directory ``src_path`` to ``dst_path``. - Parameters: + Arguments: src_path (str): Path of source directory on the filesystem. dst_path (str): Path to destination directory. create (bool): If `True`, then ``dst_path`` will be created @@ -1454,8 +1442,7 @@ def touch(self, path): def validatepath(self, path): # type: (Text) -> Text - """Check if a path is valid, returning a normalized absolute - path. + """Validate a path, returning a normalized absolute path on sucess. Many filesystems have restrictions on the format of paths they support. This method will check that ``path`` is valid on the diff --git a/fs/compress.py b/fs/compress.py index 2110403b..a3d73033 100644 --- a/fs/compress.py +++ b/fs/compress.py @@ -46,9 +46,9 @@ def write_zip( compression (int): Compression to use (one of the constants defined in the `zipfile` module in the stdlib). Defaults to `zipfile.ZIP_DEFLATED`. - encoding (str): - The encoding to use for filenames. The default is ``"utf-8"``, - use ``"CP437"`` if compatibility with WinZip is desired. + encoding (str): The encoding to use for filenames. The default + is ``"utf-8"``, use ``"CP437"`` if compatibility with WinZip + is desired. walker (~fs.walk.Walker, optional): A `Walker` instance, or `None` to use default walker. You can use this to specify which files you want to compress. @@ -116,6 +116,7 @@ def write_tar( """Write the contents of a filesystem to a tar file. Arguments: + src_fs (~fs.base.FS): The source filesystem to compress. file (str or io.IOBase): Destination file, may be a file name or an open file object. compression (str, optional): Compression to use, or `None` diff --git a/fs/copy.py b/fs/copy.py index 80fcdc6b..6cd34392 100644 --- a/fs/copy.py +++ b/fs/copy.py @@ -150,7 +150,7 @@ def copy_file_internal( dst_path, # type: Text ): # type: (...) -> None - """Low level copy, that doesn't call manage_fs or lock. + """Copy a file at low level, without calling `manage_fs` or locking. If the destination exists, and is a file, it will be first truncated. @@ -160,7 +160,7 @@ def copy_file_internal( Arguments: src_fs (FS): Source filesystem. src_path (str): Path to a file on the source filesystem. - dst_fs (FS: Destination filesystem. + dst_fs (FS): Destination filesystem. dst_path (str): Path to a file on the destination filesystem. """ diff --git a/fs/error_tools.py b/fs/error_tools.py index 28c200bf..66d38696 100644 --- a/fs/error_tools.py +++ b/fs/error_tools.py @@ -28,8 +28,7 @@ class _ConvertOSErrors(object): - """Context manager to convert OSErrors in to FS Errors. - """ + """Context manager to convert OSErrors in to FS Errors.""" FILE_ERRORS = { 64: errors.RemoteConnectionError, # ENONET diff --git a/fs/errors.py b/fs/errors.py index b70b62e3..25625e28 100644 --- a/fs/errors.py +++ b/fs/errors.py @@ -55,10 +55,9 @@ class MissingInfoNamespace(AttributeError): - """An expected namespace is missing. - """ + """An expected namespace is missing.""" - def __init__(self, namespace): + def __init__(self, namespace): # noqa: D107 # type: (Text) -> None self.namespace = namespace msg = "namespace '{}' is required for this attribute" @@ -70,20 +69,18 @@ def __reduce__(self): @six.python_2_unicode_compatible class FSError(Exception): - """Base exception for the `fs` module. - """ + """Base exception for the `fs` module.""" default_message = "Unspecified error" - def __init__(self, msg=None): + def __init__(self, msg=None): # noqa: D107 # type: (Optional[Text]) -> None self._msg = msg or self.default_message super(FSError, self).__init__() def __str__(self): # type: () -> Text - """Return the error message. - """ + """Return the error message.""" msg = self._msg.format(**self.__dict__) return msg @@ -94,8 +91,7 @@ def __repr__(self): class FilesystemClosed(FSError): - """Attempt to use a closed filesystem. - """ + """Attempt to use a closed filesystem.""" default_message = "attempt to use closed filesystem" @@ -105,18 +101,17 @@ class BulkCopyFailed(FSError): default_message = "One or more copy operations failed (see errors attribute)" - def __init__(self, errors): + def __init__(self, errors): # noqa: D107 self.errors = errors super(BulkCopyFailed, self).__init__() class CreateFailed(FSError): - """Filesystem could not be created. - """ + """Filesystem could not be created.""" default_message = "unable to create filesystem, {details}" - def __init__(self, msg=None, exc=None): + def __init__(self, msg=None, exc=None): # noqa: D107 # type: (Optional[Text], Optional[Exception]) -> None self._msg = msg or self.default_message self.details = "" if exc is None else text_type(exc) @@ -140,12 +135,11 @@ def __reduce__(self): class PathError(FSError): - """Base exception for errors to do with a path string. - """ + """Base exception for errors to do with a path string.""" default_message = "path '{path}' is invalid" - def __init__(self, path, msg=None): + def __init__(self, path, msg=None): # noqa: D107 # type: (Text, Optional[Text]) -> None self.path = path super(PathError, self).__init__(msg=msg) @@ -155,19 +149,17 @@ def __reduce__(self): class NoSysPath(PathError): - """The filesystem does not provide *sys paths* to the resource. - """ + """The filesystem does not provide *sys paths* to the resource.""" default_message = "path '{path}' does not map to the local filesystem" class NoURL(PathError): - """The filesystem does not provide an URL for the resource. - """ + """The filesystem does not provide an URL for the resource.""" default_message = "path '{path}' has no '{purpose}' URL" - def __init__(self, path, purpose, msg=None): + def __init__(self, path, purpose, msg=None): # noqa: D107 # type: (Text, Text, Optional[Text]) -> None self.purpose = purpose super(NoURL, self).__init__(path, msg=msg) @@ -177,22 +169,19 @@ def __reduce__(self): class InvalidPath(PathError): - """Path can't be mapped on to the underlaying filesystem. - """ + """Path can't be mapped on to the underlaying filesystem.""" default_message = "path '{path}' is invalid on this filesystem " class InvalidCharsInPath(InvalidPath): - """Path contains characters that are invalid on this filesystem. - """ + """Path contains characters that are invalid on this filesystem.""" default_message = "path '{path}' contains invalid characters" class OperationFailed(FSError): - """A specific operation failed. - """ + """A specific operation failed.""" default_message = "operation failed, {details}" @@ -201,7 +190,7 @@ def __init__( path=None, # type: Optional[Text] exc=None, # type: Optional[Exception] msg=None, # type: Optional[Text] - ): + ): # noqa: D107 # type: (...) -> None self.path = path self.exc = exc @@ -214,54 +203,47 @@ def __reduce__(self): class Unsupported(OperationFailed): - """Operation not supported by the filesystem. - """ + """Operation not supported by the filesystem.""" default_message = "not supported" class RemoteConnectionError(OperationFailed): - """Operations encountered remote connection trouble. - """ + """Operations encountered remote connection trouble.""" default_message = "remote connection error" class InsufficientStorage(OperationFailed): - """Storage is insufficient for requested operation. - """ + """Storage is insufficient for requested operation.""" default_message = "insufficient storage space" class PermissionDenied(OperationFailed): - """Not enough permissions. - """ + """Not enough permissions.""" default_message = "permission denied" class OperationTimeout(OperationFailed): - """Filesystem took too long. - """ + """Filesystem took too long.""" default_message = "operation timed out" class RemoveRootError(OperationFailed): - """Attempt to remove the root directory. - """ + """Attempt to remove the root directory.""" default_message = "root directory may not be removed" class ResourceError(FSError): - """Base exception class for error associated with a specific resource. - """ + """Base exception class for error associated with a specific resource.""" default_message = "failed on path {path}" - def __init__(self, path, exc=None, msg=None): + def __init__(self, path, exc=None, msg=None): # noqa: D107 # type: (Text, Optional[Exception], Optional[Text]) -> None self.path = path self.exc = exc @@ -272,71 +254,61 @@ def __reduce__(self): class ResourceNotFound(ResourceError): - """Required resource not found. - """ + """Required resource not found.""" default_message = "resource '{path}' not found" class ResourceInvalid(ResourceError): - """Resource has the wrong type. - """ + """Resource has the wrong type.""" default_message = "resource '{path}' is invalid for this operation" class FileExists(ResourceError): - """File already exists. - """ + """File already exists.""" default_message = "resource '{path}' exists" class FileExpected(ResourceInvalid): - """Operation only works on files. - """ + """Operation only works on files.""" default_message = "path '{path}' should be a file" class DirectoryExpected(ResourceInvalid): - """Operation only works on directories. - """ + """Operation only works on directories.""" default_message = "path '{path}' should be a directory" class DestinationExists(ResourceError): - """Target destination already exists. - """ + """Target destination already exists.""" default_message = "destination '{path}' exists" class DirectoryExists(ResourceError): - """Directory already exists. - """ + """Directory already exists.""" default_message = "directory '{path}' exists" class DirectoryNotEmpty(ResourceError): - """Attempt to remove a non-empty directory. - """ + """Attempt to remove a non-empty directory.""" default_message = "directory '{path}' is not empty" class ResourceLocked(ResourceError): - """Attempt to use a locked resource. - """ + """Attempt to use a locked resource.""" default_message = "resource '{path}' is locked" class ResourceReadOnly(ResourceError): - """Attempting to modify a read-only resource. - """ + """Attempting to modify a read-only resource.""" default_message = "resource '{path}' is read only" @@ -354,7 +326,7 @@ class IllegalBackReference(ValueError): """ - def __init__(self, path): + def __init__(self, path): # noqa: D107 # type: (Text) -> None self.path = path msg = ("path '{path}' contains back-references outside of filesystem").format( diff --git a/fs/ftpfs.py b/fs/ftpfs.py index e3a39411..d2e37d7f 100644 --- a/fs/ftpfs.py +++ b/fs/ftpfs.py @@ -4,6 +4,7 @@ from __future__ import print_function from __future__ import unicode_literals +import array import calendar import io import itertools @@ -36,6 +37,7 @@ from . import _ftp_parse as ftp_parse if typing.TYPE_CHECKING: + import mmap import ftplib from typing import ( Any, @@ -236,14 +238,17 @@ def read(self, size=-1): return b"".join(chunks) def readinto(self, buffer): - # type: (bytearray) -> int + # type: (Union[bytearray, memoryview, array.array[Any], mmap.mmap]) -> int data = self.read(len(buffer)) bytes_read = len(data) - buffer[:bytes_read] = data + if isinstance(buffer, array.array): + buffer[:bytes_read] = array.array(buffer.typecode, data) + else: + buffer[:bytes_read] = data # type: ignore return bytes_read - def readline(self, size=-1): - # type: (int) -> bytes + def readline(self, size=None): + # type: (Optional[int]) -> bytes return next(line_iterator(self, size)) # type: ignore def readlines(self, hint=-1): @@ -262,10 +267,13 @@ def writable(self): return self.mode.writing def write(self, data): - # type: (bytes) -> int + # type: (Union[bytes, memoryview, array.array[Any], mmap.mmap]) -> int if not self.mode.writing: raise IOError("File not open for writing") + if isinstance(data, array.array): + data = data.tobytes() + with self._lock: conn = self.write_conn data_pos = 0 @@ -281,8 +289,16 @@ def write(self, data): return data_pos def writelines(self, lines): - # type: (Iterable[bytes]) -> None - self.write(b"".join(lines)) + # type: (Iterable[Union[bytes, memoryview, array.array[Any], mmap.mmap]]) -> None # noqa: E501 + if not self.mode.writing: + raise IOError("File not open for writing") + data = bytearray() + for line in lines: + if isinstance(line, array.array): + data.extend(line.tobytes()) + else: + data.extend(line) # type: ignore + self.write(data) def truncate(self, size=None): # type: (Optional[int]) -> int @@ -330,20 +346,7 @@ def seek(self, pos, whence=Seek.set): class FTPFS(FS): - """A FTP (File Transport Protocol) Filesystem. - - Arguments: - host (str): A FTP host, e.g. ``'ftp.mirror.nl'``. - user (str): A username (default is ``'anonymous'``). - passwd (str): Password for the server, or `None` for anon. - acct (str): FTP account. - timeout (int): Timeout for contacting server (in seconds, - defaults to 10). - port (int): FTP port number (default 21). - proxy (str, optional): An FTP proxy, or ``None`` (default) - for no proxy. - - """ + """A FTP (File Transport Protocol) Filesystem.""" _meta = { "invalid_path_chars": "\0", @@ -365,6 +368,20 @@ def __init__( proxy=None, # type: Optional[Text] ): # type: (...) -> None + """Create a new `FTPFS` instance. + + Arguments: + host (str): A FTP host, e.g. ``'ftp.mirror.nl'``. + user (str): A username (default is ``'anonymous'``). + passwd (str): Password for the server, or `None` for anon. + acct (str): FTP account. + timeout (int): Timeout for contacting server (in seconds, + defaults to 10). + port (int): FTP port number (default 21). + proxy (str, optional): An FTP proxy, or ``None`` (default) + for no proxy. + + """ super(FTPFS, self).__init__() self._host = host self._user = user @@ -403,8 +420,7 @@ def host(self): @classmethod def _parse_features(cls, feat_response): # type: (Text) -> Dict[Text, Text] - """Parse a dict of features from FTP feat response. - """ + """Parse a dict of features from FTP feat response.""" features = {} if feat_response.split("-")[0] == "211": for line in feat_response.splitlines(): @@ -415,8 +431,7 @@ def _parse_features(cls, feat_response): def _open_ftp(self): # type: () -> FTP - """Open a new ftp object. - """ + """Open a new ftp object.""" _ftp = FTP() _ftp.set_debuglevel(0) with ftp_errors(self): @@ -462,8 +477,7 @@ def ftp_url(self): @property def ftp(self): # type: () -> FTP - """~ftplib.FTP: the underlying FTP client. - """ + """~ftplib.FTP: the underlying FTP client.""" return self._get_ftp() def geturl(self, path, purpose="download"): @@ -481,10 +495,9 @@ def _get_ftp(self): return self._ftp @property - def features(self): + def features(self): # noqa: D401 # type: () -> Dict[Text, Text] - """dict: features of the remote FTP server. - """ + """`dict`: Features of the remote FTP server.""" self._get_ftp() return self._features @@ -506,8 +519,7 @@ def _read_dir(self, path): @property def supports_mlst(self): # type: () -> bool - """bool: whether the server supports MLST feature. - """ + """bool: whether the server supports MLST feature.""" return "MLST" in self.features def create(self, path, wipe=False): @@ -525,8 +537,7 @@ def create(self, path, wipe=False): @classmethod def _parse_ftp_time(cls, time_text): # type: (Text) -> Optional[int] - """Parse a time from an ftp directory listing. - """ + """Parse a time from an ftp directory listing.""" try: tm_year = int(time_text[0:4]) tm_month = int(time_text[4:6]) diff --git a/fs/glob.py b/fs/glob.py index ac12125e..c21bb9c6 100644 --- a/fs/glob.py +++ b/fs/glob.py @@ -1,3 +1,6 @@ +"""Useful functions for working with glob patterns. +""" + from __future__ import unicode_literals from collections import namedtuple @@ -92,20 +95,7 @@ def imatch(pattern, path): class Globber(object): - """A generator of glob results. - - Arguments: - fs (~fs.base.FS): A filesystem object - pattern (str): A glob pattern, e.g. ``"**/*.py"`` - path (str): A path to a directory in the filesystem. - namespaces (list): A list of additional info namespaces. - case_sensitive (bool): If ``True``, the path matching will be - case *sensitive* i.e. ``"FOO.py"`` and ``"foo.py"`` will - be different, otherwise path matching will be case *insensitive*. - exclude_dirs (list): A list of patterns to exclude when searching, - e.g. ``["*.git"]``. - - """ + """A generator of glob results.""" def __init__( self, @@ -117,6 +107,20 @@ def __init__( exclude_dirs=None, ): # type: (FS, str, str, Optional[List[str]], bool, Optional[List[str]]) -> None + """Create a new Globber instance. + + Arguments: + fs (~fs.base.FS): A filesystem object + pattern (str): A glob pattern, e.g. ``"**/*.py"`` + path (str): A path to a directory in the filesystem. + namespaces (list): A list of additional info namespaces. + case_sensitive (bool): If ``True``, the path matching will be + case *sensitive* i.e. ``"FOO.py"`` and ``"foo.py"`` will be + different, otherwise path matching will be case *insensitive*. + exclude_dirs (list): A list of patterns to exclude when searching, + e.g. ``["*.git"]``. + + """ self.fs = fs self.pattern = pattern self.path = path @@ -160,7 +164,7 @@ def _make_iter(self, search="breadth", namespaces=None): def __iter__(self): # type: () -> Iterator[GlobMatch] - """An iterator of :class:`fs.glob.GlobMatch` objects.""" + """Get an iterator of :class:`fs.glob.GlobMatch` objects.""" return self._make_iter() def count(self): @@ -200,7 +204,6 @@ def count_lines(self): LineCounts(lines=5767102, non_blank=4915110) """ - lines = 0 non_blank = 0 for path, info in self._make_iter(): @@ -213,7 +216,7 @@ def count_lines(self): def remove(self): # type: () -> int - """Removed all matched paths. + """Remove all matched paths. Returns: int: Number of file and directories removed. @@ -235,13 +238,10 @@ def remove(self): class BoundGlobber(object): - """A :class:`~Globber` object bound to a filesystem. + """A `~fs.glob.Globber` object bound to a filesystem. An instance of this object is available on every Filesystem object - as ``.glob``. - - Arguments: - fs (FS): A filesystem object. + as the `~fs.base.FS.glob` property. """ @@ -249,6 +249,12 @@ class BoundGlobber(object): def __init__(self, fs): # type: (FS) -> None + """Create a new bound Globber. + + Arguments: + fs (FS): A filesystem object to bind to. + + """ self.fs = fs def __repr__(self): @@ -270,9 +276,7 @@ def __call__( e.g. ``["*.git"]``. Returns: - `~Globber`: - An object that may be queried for the glob matches. - + `Globber`: An object that may be queried for the glob matches. """ return Globber( diff --git a/fs/info.py b/fs/info.py index 13f7b17f..60a659e6 100644 --- a/fs/info.py +++ b/fs/info.py @@ -49,8 +49,7 @@ class Info(object): def __init__(self, raw_info, to_datetime=epoch_to_datetime): # type: (RawInfo, ToDatetime) -> None - """Create a resource info object from a raw info dict. - """ + """Create a resource info object from a raw info dict.""" self.raw = raw_info self._to_datetime = to_datetime self.namespaces = frozenset(self.raw.keys()) @@ -73,8 +72,8 @@ def _make_datetime(self, t): # type: (None) -> None pass - @overload # noqa: F811 - def _make_datetime(self, t): + @overload + def _make_datetime(self, t): # noqa: F811 # type: (int) -> datetime pass @@ -91,7 +90,7 @@ def get(self, namespace, key): pass @overload # noqa: F811 - def get(self, namespace, key, default): + def get(self, namespace, key, default): # noqa: F811 # type: (Text, Text, T) -> Union[Any, T] pass @@ -160,8 +159,7 @@ def has_namespace(self, namespace): def copy(self, to_datetime=None): # type: (Optional[ToDatetime]) -> Info - """Create a copy of this resource info object. - """ + """Create a copy of this resource info object.""" return Info(deepcopy(self.raw), to_datetime=to_datetime or self._to_datetime) def make_path(self, dir_path): @@ -180,21 +178,26 @@ def make_path(self, dir_path): @property def name(self): # type: () -> Text - """`str`: the resource name. - """ + """`str`: the resource name.""" return cast(Text, self.get("basic", "name")) @property def suffix(self): # type: () -> Text - """`str`: the last component of the name (including dot), or an - empty string if there is no suffix. + """`str`: the last component of the name (with dot). + + In case there is no suffix, an empty string is returned. Example: >>> info >>> info.suffix '.py' + >>> info2 + + >>> info2.suffix + '' + """ name = self.get("basic", "name") if name.startswith(".") and name.count(".") == 1: @@ -212,6 +215,7 @@ def suffixes(self): >>> info.suffixes ['.tar', '.gz'] + """ name = self.get("basic", "name") if name.startswith(".") and name.count(".") == 1: @@ -238,22 +242,19 @@ def stem(self): @property def is_dir(self): # type: () -> bool - """`bool`: `True` if the resource references a directory. - """ + """`bool`: `True` if the resource references a directory.""" return cast(bool, self.get("basic", "is_dir")) @property def is_file(self): # type: () -> bool - """`bool`: `True` if the resource references a file. - """ + """`bool`: `True` if the resource references a file.""" return not cast(bool, self.get("basic", "is_dir")) @property def is_link(self): # type: () -> bool - """`bool`: `True` if the resource is a symlink. - """ + """`bool`: `True` if the resource is a symlink.""" self._require_namespace("link") return self.get("link", "target", None) is not None diff --git a/fs/iotools.py b/fs/iotools.py index 44849680..bf7f37a5 100644 --- a/fs/iotools.py +++ b/fs/iotools.py @@ -4,6 +4,7 @@ from __future__ import print_function from __future__ import unicode_literals +import array import io import typing from io import SEEK_SET, SEEK_CUR @@ -11,6 +12,7 @@ from .mode import Mode if typing.TYPE_CHECKING: + import mmap from io import RawIOBase from typing import ( Any, @@ -25,10 +27,9 @@ class RawWrapper(io.RawIOBase): - """Convert a Python 2 style file-like object in to a IO object. - """ + """Convert a Python 2 style file-like object in to a IO object.""" - def __init__(self, f, mode=None, name=None): + def __init__(self, f, mode=None, name=None): # noqa: D107 # type: (IO[bytes], Optional[Text], Optional[Text]) -> None self._f = f self.mode = mode or getattr(f, "mode", None) @@ -89,8 +90,11 @@ def truncate(self, size=None): return self._f.truncate(size) def write(self, data): - # type: (bytes) -> int - count = self._f.write(data) + # type: (Union[bytes, memoryview, array.array[Any], mmap.mmap]) -> int + if isinstance(data, array.array): + count = self._f.write(data.tobytes()) + else: + count = self._f.write(data) # type: ignore return len(data) if count is None else count @typing.no_type_check @@ -131,17 +135,20 @@ def readinto1(self, b): b[:bytes_read] = data return bytes_read - def readline(self, limit=-1): - # type: (int) -> bytes - return self._f.readline(limit) + def readline(self, limit=None): + # type: (Optional[int]) -> bytes + return self._f.readline(-1 if limit is None else limit) - def readlines(self, hint=-1): - # type: (int) -> List[bytes] - return self._f.readlines(hint) + def readlines(self, hint=None): + # type: (Optional[int]) -> List[bytes] + return self._f.readlines(-1 if hint is None else hint) - def writelines(self, sequence): - # type: (Iterable[Union[bytes, bytearray]]) -> None - return self._f.writelines(sequence) + def writelines(self, lines): + # type: (Iterable[Union[bytes, memoryview, array.array[Any], mmap.mmap]]) -> None # noqa: E501 + _lines = ( + line.tobytes() if isinstance(line, array.array) else line for line in lines + ) + return self._f.writelines(typing.cast("Iterable[bytes]", _lines)) def __iter__(self): # type: () -> Iterator[bytes] @@ -161,8 +168,7 @@ def make_stream( **kwargs # type: Any ): # type: (...) -> IO - """Take a Python 2.x binary file and return an IO Stream. - """ + """Take a Python 2.x binary file and return an IO Stream.""" reading = "r" in mode writing = "w" in mode appending = "a" in mode diff --git a/fs/lrucache.py b/fs/lrucache.py index 490d2700..9deb98bd 100644 --- a/fs/lrucache.py +++ b/fs/lrucache.py @@ -22,13 +22,13 @@ class LRUCache(OrderedDict, typing.Generic[_K, _V]): def __init__(self, cache_size): # type: (int) -> None + """Create a new LRUCache with the given size.""" self.cache_size = cache_size super(LRUCache, self).__init__() def __setitem__(self, key, value): # type: (_K, _V) -> None - """Store a new views, potentially discarding an old value. - """ + """Store a new views, potentially discarding an old value.""" if key not in self: if len(self) >= self.cache_size: self.popitem(last=False) @@ -36,8 +36,7 @@ def __setitem__(self, key, value): def __getitem__(self, key): # type: (_K) -> _V - """Get the item, but also makes it most recent. - """ + """Get the item, but also makes it most recent.""" _super = typing.cast(OrderedDict, super(LRUCache, self)) value = _super.__getitem__(key) _super.__delitem__(key) diff --git a/fs/memoryfs.py b/fs/memoryfs.py index d1c23724..30e9c21f 100644 --- a/fs/memoryfs.py +++ b/fs/memoryfs.py @@ -24,11 +24,14 @@ from ._typing import overload if typing.TYPE_CHECKING: + import array + import mmap from typing import ( Any, BinaryIO, Collection, Dict, + Iterable, Iterator, List, Optional, @@ -90,14 +93,12 @@ def _seek_lock(self): def on_modify(self): # noqa: D401 # type: () -> None - """Called when file data is modified. - """ + """Called when file data is modified.""" self._dir_entry.modified_time = self.modified_time = time.time() def on_access(self): # noqa: D401 # type: () -> None - """Called when file is accessed. - """ + """Called when file is accessed.""" self._dir_entry.accessed_time = self.accessed_time = time.time() def flush(self): @@ -118,8 +119,8 @@ def next(self): __next__ = next - def readline(self, size=-1): - # type: (int) -> bytes + def readline(self, size=None): + # type: (Optional[int]) -> bytes if not self._mode.reading: raise IOError("File not open for reading") with self._seek_lock(): @@ -133,7 +134,7 @@ def close(self): self._dir_entry.remove_open_file(self) super(_MemoryFile, self).close() - def read(self, size=-1): + def read(self, size=None): # type: (Optional[int]) -> bytes if not self._mode.reading: raise IOError("File not open for reading") @@ -192,17 +193,15 @@ def writable(self): return self._mode.writing def write(self, data): - # type: (bytes) -> int + # type: (Union[bytes, memoryview, array.array[Any], mmap.mmap]) -> int if not self._mode.writing: raise IOError("File not open for writing") with self._seek_lock(): self.on_modify() return self._bytes_io.write(data) - def writelines(self, sequence): # type: ignore - # type: (List[bytes]) -> None - # FIXME(@althonos): For some reason the stub for IOBase.writelines - # is List[Any] ?! It should probably be Iterable[ByteString] + def writelines(self, sequence): + # type: (Iterable[Union[bytes, memoryview, array.array[Any], mmap.mmap]]) -> None # noqa: E501 with self._seek_lock(): self.on_modify() self._bytes_io.writelines(sequence) @@ -247,18 +246,18 @@ def size(self): _bytes_file.seek(0, os.SEEK_END) return _bytes_file.tell() - @overload # noqa: F811 - def get_entry(self, name, default): + @overload + def get_entry(self, name, default): # noqa: F811 # type: (Text, _DirEntry) -> _DirEntry pass - @overload # noqa: F811 - def get_entry(self, name): + @overload + def get_entry(self, name): # noqa: F811 # type: (Text) -> Optional[_DirEntry] pass - @overload # noqa: F811 - def get_entry(self, name, default): + @overload + def get_entry(self, name, default): # noqa: F811 # type: (Text, None) -> Optional[_DirEntry] pass @@ -305,12 +304,16 @@ class MemoryFS(FS): fast, but non-permanent. The `MemoryFS` constructor takes no arguments. - Example: - >>> mem_fs = MemoryFS() + Examples: + Create with the constructor:: + + >>> from fs.memoryfs import MemoryFS + >>> mem_fs = MemoryFS() + + Or via an FS URL:: - Or via an FS URL: - >>> import fs - >>> mem_fs = fs.open_fs('mem://') + >>> import fs + >>> mem_fs = fs.open_fs('mem://') """ @@ -326,8 +329,7 @@ class MemoryFS(FS): def __init__(self): # type: () -> None - """Create an in-memory filesystem. - """ + """Create an in-memory filesystem.""" self._meta = self._meta.copy() self.root = self._make_dir_entry(ResourceType.directory, "") super(MemoryFS, self).__init__() @@ -346,8 +348,7 @@ def _make_dir_entry(self, resource_type, name): def _get_dir_entry(self, dir_path): # type: (Text) -> Optional[_DirEntry] - """Get a directory entry, or `None` if one doesn't exist. - """ + """Get a directory entry, or `None` if one doesn't exist.""" with self._lock: dir_path = normpath(dir_path) current_entry = self.root # type: Optional[_DirEntry] diff --git a/fs/mirror.py b/fs/mirror.py index ceb8ccd3..6b989e63 100644 --- a/fs/mirror.py +++ b/fs/mirror.py @@ -73,6 +73,7 @@ def mirror( workers (int): Number of worker threads used (0 for single threaded). Set to a relatively low number for network filesystems, 4 would be a good start. + """ def src(): diff --git a/fs/mode.py b/fs/mode.py index 16d51875..613e1ba9 100644 --- a/fs/mode.py +++ b/fs/mode.py @@ -31,12 +31,6 @@ class Mode(typing.Container[Text]): `mode strings `_ used when opening files. - Arguments: - mode (str): A *mode* string, as used by `io.open`. - - Raises: - ValueError: If the mode string is invalid. - Example: >>> mode = Mode('rb') >>> mode.reading @@ -52,6 +46,15 @@ class Mode(typing.Container[Text]): def __init__(self, mode): # type: (Text) -> None + """Create a new `Mode` instance. + + Arguments: + mode (str): A *mode* string, as used by `io.open`. + + Raises: + ValueError: If the mode string is invalid. + + """ self._mode = mode self.validate() @@ -65,8 +68,7 @@ def __str__(self): def __contains__(self, character): # type: (object) -> bool - """Check if a mode contains a given character. - """ + """Check if a mode contains a given character.""" assert isinstance(character, Text) return character in self._mode @@ -123,64 +125,55 @@ def validate_bin(self): @property def create(self): # type: () -> bool - """`bool`: `True` if the mode would create a file. - """ + """`bool`: `True` if the mode would create a file.""" return "a" in self or "w" in self or "x" in self @property def reading(self): # type: () -> bool - """`bool`: `True` if the mode permits reading. - """ + """`bool`: `True` if the mode permits reading.""" return "r" in self or "+" in self @property def writing(self): # type: () -> bool - """`bool`: `True` if the mode permits writing. - """ + """`bool`: `True` if the mode permits writing.""" return "w" in self or "a" in self or "+" in self or "x" in self @property def appending(self): # type: () -> bool - """`bool`: `True` if the mode permits appending. - """ + """`bool`: `True` if the mode permits appending.""" return "a" in self @property def updating(self): # type: () -> bool - """`bool`: `True` if the mode permits both reading and writing. - """ + """`bool`: `True` if the mode permits both reading and writing.""" return "+" in self @property def truncate(self): # type: () -> bool - """`bool`: `True` if the mode would truncate an existing file. - """ + """`bool`: `True` if the mode would truncate an existing file.""" return "w" in self or "x" in self @property def exclusive(self): # type: () -> bool - """`bool`: `True` if the mode require exclusive creation. - """ + """`bool`: `True` if the mode require exclusive creation.""" return "x" in self @property def binary(self): # type: () -> bool - """`bool`: `True` if a mode specifies binary. - """ + """`bool`: `True` if a mode specifies binary.""" return "b" in self @property def text(self): # type: () -> bool - """`bool`: `True` if a mode specifies text. - """ + """`bool`: `True` if a mode specifies text.""" return "t" in self or "b" not in self diff --git a/fs/mountfs.py b/fs/mountfs.py index d51d7d9d..5e590637 100644 --- a/fs/mountfs.py +++ b/fs/mountfs.py @@ -41,18 +41,11 @@ class MountError(Exception): - """Thrown when mounts conflict. - """ + """Thrown when mounts conflict.""" class MountFS(FS): - """A virtual filesystem that maps directories on to other file-systems. - - Arguments: - auto_close (bool): If `True` (the default), the child - filesystems will be closed when `MountFS` is closed. - - """ + """A virtual filesystem that maps directories on to other file-systems.""" _meta = { "virtual": True, @@ -64,6 +57,13 @@ class MountFS(FS): def __init__(self, auto_close=True): # type: (bool) -> None + """Create a new `MountFS` instance. + + Arguments: + auto_close (bool): If `True` (the default), the child + filesystems will be closed when `MountFS` is closed. + + """ super(MountFS, self).__init__() self.auto_close = auto_close self.default_fs = MemoryFS() # type: FS diff --git a/fs/move.py b/fs/move.py index 4f6fc2ab..1d8e26c1 100644 --- a/fs/move.py +++ b/fs/move.py @@ -41,7 +41,7 @@ def move_file( Arguments: src_fs (FS or str): Source filesystem (instance or URL). src_path (str): Path to a file on ``src_fs``. - dst_fs (FS or str); Destination filesystem (instance or URL). + dst_fs (FS or str): Destination filesystem (instance or URL). dst_path (str): Path to a file on ``dst_fs``. """ @@ -72,8 +72,8 @@ def move_dir( src_path (str): Path to a directory on ``src_fs`` dst_fs (FS or str): Destination filesystem (instance or URL). dst_path (str): Path to a directory on ``dst_fs``. - workers (int): Use `worker` threads to copy data, or ``0`` (default) for - a single-threaded copy. + workers (int): Use ``worker`` threads to copy data, or ``0`` + (default) for a single-threaded copy. """ diff --git a/fs/multifs.py b/fs/multifs.py index e68d2c00..6f0fff42 100644 --- a/fs/multifs.py +++ b/fs/multifs.py @@ -55,6 +55,13 @@ class MultiFS(FS): def __init__(self, auto_close=True): # type: (bool) -> None + """Create a new MultiFS. + + Arguments: + auto_close (bool): If `True` (the default), the child + filesystems will be closed when `MultiFS` is closed. + + """ super(MultiFS, self).__init__() self._auto_close = auto_close @@ -127,14 +134,12 @@ def get_fs(self, name): def _resort(self): # type: () -> None - """Force `iterate_fs` to re-sort on next reference. - """ + """Force `iterate_fs` to re-sort on next reference.""" self._fs_sequence = None def iterate_fs(self): # type: () -> Iterator[Tuple[Text, FS]] - """Get iterator that returns (name, fs) in priority order. - """ + """Get iterator that returns (name, fs) in priority order.""" if self._fs_sequence is None: self._fs_sequence = [ (name, fs) @@ -146,8 +151,7 @@ def iterate_fs(self): def _delegate(self, path): # type: (Text) -> Optional[FS] - """Get a filesystem which has a given path. - """ + """Get a filesystem which has a given path.""" for _name, fs in self.iterate_fs(): if fs.exists(path): return fs @@ -155,8 +159,7 @@ def _delegate(self, path): def _delegate_required(self, path): # type: (Text) -> FS - """Check that there is a filesystem with the given ``path``. - """ + """Check that there is a filesystem with the given ``path``.""" fs = self._delegate(path) if fs is None: raise errors.ResourceNotFound(path) @@ -164,8 +167,7 @@ def _delegate_required(self, path): def _writable_required(self, path): # type: (Text) -> FS - """Check that ``path`` is writeable. - """ + """Check that ``path`` is writeable.""" if self.write_fs is None: raise errors.ResourceReadOnly(path) return self.write_fs diff --git a/fs/opener/appfs.py b/fs/opener/appfs.py index fccf603e..0b1d78fa 100644 --- a/fs/opener/appfs.py +++ b/fs/opener/appfs.py @@ -21,8 +21,7 @@ @registry.install class AppFSOpener(Opener): - """``AppFS`` opener. - """ + """``AppFS`` opener.""" protocols = ["userdata", "userconf", "sitedata", "siteconf", "usercache", "userlog"] _protocol_mapping = None diff --git a/fs/opener/errors.py b/fs/opener/errors.py index 593eb168..7c8ae8a5 100644 --- a/fs/opener/errors.py +++ b/fs/opener/errors.py @@ -4,25 +4,20 @@ class ParseError(ValueError): - """Attempt to parse an invalid FS URL. - """ + """Attempt to parse an invalid FS URL.""" class OpenerError(Exception): - """Base exception for opener related errors. - """ + """Base exception for opener related errors.""" class UnsupportedProtocol(OpenerError): - """No opener found for the given protocol. - """ + """No opener found for the given protocol.""" class EntryPointError(OpenerError): - """An entry point could not be loaded. - """ + """An entry point could not be loaded.""" class NotWriteable(OpenerError): - """A writable FS could not be created. - """ + """A writable FS could not be created.""" diff --git a/fs/opener/ftpfs.py b/fs/opener/ftpfs.py index f5beab21..af64606b 100644 --- a/fs/opener/ftpfs.py +++ b/fs/opener/ftpfs.py @@ -21,8 +21,7 @@ @registry.install class FTPOpener(Opener): - """`FTPFS` opener. - """ + """`FTPFS` opener.""" protocols = ["ftp"] diff --git a/fs/opener/memoryfs.py b/fs/opener/memoryfs.py index 696ee06a..1ce8f105 100644 --- a/fs/opener/memoryfs.py +++ b/fs/opener/memoryfs.py @@ -19,8 +19,7 @@ @registry.install class MemOpener(Opener): - """`MemoryFS` opener. - """ + """`MemoryFS` opener.""" protocols = ["mem"] diff --git a/fs/opener/osfs.py b/fs/opener/osfs.py index 00cb63ee..7cb87b99 100644 --- a/fs/opener/osfs.py +++ b/fs/opener/osfs.py @@ -19,8 +19,7 @@ @registry.install class OSFSOpener(Opener): - """`OSFS` opener. - """ + """`OSFS` opener.""" protocols = ["file", "osfs"] diff --git a/fs/opener/parse.py b/fs/opener/parse.py index e9423807..e49a8009 100644 --- a/fs/opener/parse.py +++ b/fs/opener/parse.py @@ -18,12 +18,12 @@ from typing import Optional, Text -_ParseResult = collections.namedtuple( - "ParseResult", ["protocol", "username", "password", "resource", "params", "path"] -) - - -class ParseResult(_ParseResult): +class ParseResult( + collections.namedtuple( + "ParseResult", + ["protocol", "username", "password", "resource", "params", "path"], + ) +): """A named tuple containing fields of a parsed FS URL. Attributes: diff --git a/fs/opener/registry.py b/fs/opener/registry.py index 50f2976c..4c1c2d3e 100644 --- a/fs/opener/registry.py +++ b/fs/opener/registry.py @@ -31,8 +31,7 @@ class Registry(object): - """A registry for `Opener` instances. - """ + """A registry for `Opener` instances.""" def __init__(self, default_opener="osfs", load_extern=False): # type: (Text, bool) -> None @@ -64,10 +63,12 @@ def install(self, opener): Note: May be used as a class decorator. For example:: + registry = Registry() @registry.install class ArchiveOpener(Opener): protocols = ['zip', 'tar'] + """ _opener = opener if isinstance(opener, Opener) else opener() assert isinstance(_opener, Opener), "Opener instance required" @@ -79,9 +80,7 @@ class ArchiveOpener(Opener): @property def protocols(self): # type: () -> List[Text] - """`list`: the list of supported protocols. - """ - + """`list`: the list of supported protocols.""" _protocols = list(self._protocols) if self.load_extern: _protocols.extend( diff --git a/fs/opener/tarfs.py b/fs/opener/tarfs.py index 3ff91f55..bacb4e65 100644 --- a/fs/opener/tarfs.py +++ b/fs/opener/tarfs.py @@ -20,8 +20,7 @@ @registry.install class TarOpener(Opener): - """`TarFS` opener. - """ + """`TarFS` opener.""" protocols = ["tar"] diff --git a/fs/opener/tempfs.py b/fs/opener/tempfs.py index ffa17983..22e26e0c 100644 --- a/fs/opener/tempfs.py +++ b/fs/opener/tempfs.py @@ -19,8 +19,7 @@ @registry.install class TempOpener(Opener): - """`TempFS` opener. - """ + """`TempFS` opener.""" protocols = ["temp"] diff --git a/fs/opener/zipfs.py b/fs/opener/zipfs.py index 81e48455..dbc0fe7c 100644 --- a/fs/opener/zipfs.py +++ b/fs/opener/zipfs.py @@ -20,8 +20,7 @@ @registry.install class ZipOpener(Opener): - """`ZipFS` opener. - """ + """`ZipFS` opener.""" protocols = ["zip"] diff --git a/fs/osfs.py b/fs/osfs.py index f854b16a..3b35541c 100644 --- a/fs/osfs.py +++ b/fs/osfs.py @@ -81,22 +81,6 @@ class OSFS(FS): """Create an OSFS. - Arguments: - root_path (str or ~os.PathLike): An OS path or path-like object to - the location on your HD you wish to manage. - create (bool): Set to `True` to create the root directory if it - does not already exist, otherwise the directory should exist - prior to creating the ``OSFS`` instance (defaults to `False`). - create_mode (int): The permissions that will be used to create - the directory if ``create`` is `True` and the path doesn't - exist, defaults to ``0o777``. - expand_vars(bool): If `True` (the default) environment variables of - the form $name or ${name} will be expanded. - - Raises: - `fs.errors.CreateFailed`: If ``root_path`` does not - exist, or could not be created. - Examples: >>> current_directory_fs = OSFS('.') >>> home_fs = OSFS('~/') @@ -113,6 +97,23 @@ def __init__( ): # type: (...) -> None """Create an OSFS instance. + + Arguments: + root_path (str or ~os.PathLike): An OS path or path-like object + to the location on your HD you wish to manage. + create (bool): Set to `True` to create the root directory if it + does not already exist, otherwise the directory should exist + prior to creating the ``OSFS`` instance (defaults to `False`). + create_mode (int): The permissions that will be used to create + the directory if ``create`` is `True` and the path doesn't + exist, defaults to ``0o777``. + expand_vars(bool): If `True` (the default) environment variables + of the form ``~``, ``$name`` or ``${name}`` will be expanded. + + Raises: + `fs.errors.CreateFailed`: If ``root_path`` does not + exist, or could not be created. + """ super(OSFS, self).__init__() if isinstance(root_path, bytes): @@ -188,8 +189,7 @@ def __str__(self): def _to_sys_path(self, path): # type: (Text) -> bytes - """Convert a FS path to a path on the OS. - """ + """Convert a FS path to a path on the OS.""" sys_path = fsencode( os.path.join(self._root_path, path.lstrip("/").replace("/", os.sep)) ) @@ -198,8 +198,7 @@ def _to_sys_path(self, path): @classmethod def _make_details_from_stat(cls, stat_result): # type: (os.stat_result) -> Dict[Text, object] - """Make a *details* info dict from an `os.stat_result` object. - """ + """Make a *details* info dict from an `os.stat_result` object.""" details = { "_write": ["accessed", "modified"], "accessed": stat_result.st_atime, @@ -218,8 +217,7 @@ def _make_details_from_stat(cls, stat_result): @classmethod def _make_access_from_stat(cls, stat_result): # type: (os.stat_result) -> Dict[Text, object] - """Make an *access* info dict from an `os.stat_result` object. - """ + """Make an *access* info dict from an `os.stat_result` object.""" access = {} # type: Dict[Text, object] access["permissions"] = Permissions(mode=stat_result.st_mode).dump() access["gid"] = gid = stat_result.st_gid @@ -252,8 +250,7 @@ def _make_access_from_stat(cls, stat_result): @classmethod def _get_type_from_stat(cls, _stat): # type: (os.stat_result) -> ResourceType - """Get the resource type from an `os.stat_result` object. - """ + """Get the resource type from an `os.stat_result` object.""" st_mode = _stat.st_mode st_type = stat.S_IFMT(st_mode) return cls.STAT_TO_RESOURCE_TYPE.get(st_type, ResourceType.unknown) @@ -673,6 +670,6 @@ def validatepath(self, path): raise errors.InvalidCharsInPath( path, msg="path '{path}' could not be encoded for the filesystem (check LANG" - " env var); {error}".format(path=path, error=error), + " env var); {error}".format(path=path, error=error), ) return super(OSFS, self).validatepath(path) diff --git a/fs/permissions.py b/fs/permissions.py index 19934465..032c3be0 100644 --- a/fs/permissions.py +++ b/fs/permissions.py @@ -18,14 +18,12 @@ def make_mode(init): # type: (Union[int, Iterable[Text], None]) -> int - """Make a mode integer from an initial value. - """ + """Make a mode integer from an initial value.""" return Permissions.get_mode(init) class _PermProperty(object): - """Creates simple properties to get/set permissions. - """ + """Creates simple properties to get/set permissions.""" def __init__(self, name): # type: (Text) -> None @@ -52,19 +50,6 @@ class Permissions(object): on a resource. It supports Linux permissions, but is generic enough to manage permission information from almost any filesystem. - Arguments: - names (list, optional): A list of permissions. - mode (int, optional): A mode integer. - user (str, optional): A triplet of *user* permissions, e.g. - ``"rwx"`` or ``"r--"`` - group (str, optional): A triplet of *group* permissions, e.g. - ``"rwx"`` or ``"r--"`` - other (str, optional): A triplet of *other* permissions, e.g. - ``"rwx"`` or ``"r--"`` - sticky (bool, optional): A boolean for the *sticky* bit. - setuid (bool, optional): A boolean for the *setuid* bit. - setguid (bool, optional): A boolean for the *setguid* bit. - Example: >>> from fs.permissions import Permissions >>> p = Permissions(user='rwx', group='rw-', other='r--') @@ -105,6 +90,22 @@ def __init__( setguid=None, # type: Optional[bool] ): # type: (...) -> None + """Create a new `Permissions` instance. + + Arguments: + names (list, optional): A list of permissions. + mode (int, optional): A mode integer. + user (str, optional): A triplet of *user* permissions, e.g. + ``"rwx"`` or ``"r--"`` + group (str, optional): A triplet of *group* permissions, e.g. + ``"rwx"`` or ``"r--"`` + other (str, optional): A triplet of *other* permissions, e.g. + ``"rwx"`` or ``"r--"`` + sticky (bool, optional): A boolean for the *sticky* bit. + setuid (bool, optional): A boolean for the *setuid* bit. + setguid (bool, optional): A boolean for the *setguid* bit. + + """ if names is not None: self._perms = set(names) elif mode is not None: @@ -174,8 +175,7 @@ def __ne__(self, other): @classmethod def parse(cls, ls): # type: (Text) -> Permissions - """Parse permissions in Linux notation. - """ + """Parse permissions in Linux notation.""" user = ls[:3] group = ls[3:6] other = ls[6:9] @@ -184,8 +184,7 @@ def parse(cls, ls): @classmethod def load(cls, permissions): # type: (List[Text]) -> Permissions - """Load a serialized permissions object. - """ + """Load a serialized permissions object.""" return cls(names=permissions) @classmethod @@ -222,26 +221,22 @@ def create(cls, init=None): @classmethod def get_mode(cls, init): # type: (Union[int, Iterable[Text], None]) -> int - """Convert an initial value to a mode integer. - """ + """Convert an initial value to a mode integer.""" return cls.create(init).mode def copy(self): # type: () -> Permissions - """Make a copy of this permissions object. - """ + """Make a copy of this permissions object.""" return Permissions(names=list(self._perms)) def dump(self): # type: () -> List[Text] - """Get a list suitable for serialization. - """ + """Get a list suitable for serialization.""" return sorted(self._perms) def as_str(self): # type: () -> Text - """Get a Linux-style string representation of permissions. - """ + """Get a Linux-style string representation of permissions.""" perms = [ c if name in self._perms else "-" for name, c in zip(self._LINUX_PERMS_NAMES[-9:], "rwxrwxrwx") @@ -259,8 +254,7 @@ def as_str(self): @property def mode(self): # type: () -> int - """`int`: mode integer. - """ + """`int`: mode integer.""" mode = 0 for name, mask in self._LINUX_PERMS: if name in self._perms: diff --git a/fs/subfs.py b/fs/subfs.py index 7172008e..1357eb1f 100644 --- a/fs/subfs.py +++ b/fs/subfs.py @@ -29,7 +29,7 @@ class SubFS(WrapFS[_F], typing.Generic[_F]): """ - def __init__(self, parent_fs, path): + def __init__(self, parent_fs, path): # noqa: D107 # type: (_F, Text) -> None super(SubFS, self).__init__(parent_fs) self._sub_dir = abspath(normpath(path)) @@ -55,8 +55,7 @@ def delegate_path(self, path): class ClosingSubFS(SubFS[_F], typing.Generic[_F]): - """A version of `SubFS` which closes its parent when closed. - """ + """A version of `SubFS` which closes its parent when closed.""" def close(self): # type: () -> None diff --git a/fs/tarfs.py b/fs/tarfs.py index 4f48d821..85e74840 100644 --- a/fs/tarfs.py +++ b/fs/tarfs.py @@ -150,15 +150,14 @@ def __init__( compression=None, # type: Optional[Text] encoding="utf-8", # type: Text temp_fs="temp://__tartemp__", # type: Text - ): + ): # noqa: D107 # type: (...) -> None pass @six.python_2_unicode_compatible class WriteTarFS(WrapFS): - """A writable tar file. - """ + """A writable tar file.""" def __init__( self, @@ -166,7 +165,7 @@ def __init__( compression=None, # type: Optional[Text] encoding="utf-8", # type: Text temp_fs="temp://__tartemp__", # type: Text - ): + ): # noqa: D107 # type: (...) -> None self._file = file # type: Union[Text, BinaryIO] self.compression = compression @@ -222,6 +221,7 @@ def write_tar( Note: This is called automatically when the TarFS is closed. + """ if not self.isclosed(): write_tar( @@ -234,8 +234,7 @@ def write_tar( @six.python_2_unicode_compatible class ReadTarFS(FS): - """A readable tar file. - """ + """A readable tar file.""" _meta = { "case_insensitive": True, @@ -260,7 +259,7 @@ class ReadTarFS(FS): } @errors.CreateFailed.catch_all - def __init__(self, file, encoding="utf-8"): + def __init__(self, file, encoding="utf-8"): # noqa: D107 # type: (Union[Text, BinaryIO], Text) -> None super(ReadTarFS, self).__init__() self._file = file diff --git a/fs/tempfs.py b/fs/tempfs.py index 748463c1..a1e5a3d2 100644 --- a/fs/tempfs.py +++ b/fs/tempfs.py @@ -27,20 +27,7 @@ @six.python_2_unicode_compatible class TempFS(OSFS): - """A temporary filesystem on the OS. - - Arguments: - identifier (str): A string to distinguish the directory within - the OS temp location, used as part of the directory name. - temp_dir (str, optional): An OS path to your temp directory - (leave as `None` to auto-detect) - auto_clean (bool): If `True` (the default), the directory - contents will be wiped on close. - ignore_clean_errors (bool): If `True` (the default), any errors - in the clean process will be suppressed. If `False`, they - will be raised. - - """ + """A temporary filesystem on the OS.""" def __init__( self, @@ -50,6 +37,20 @@ def __init__( ignore_clean_errors=True, # type: bool ): # type: (...) -> None + """Create a new `TempFS` instance. + + Arguments: + identifier (str): A string to distinguish the directory within + the OS temp location, used as part of the directory name. + temp_dir (str, optional): An OS path to your temp directory + (leave as `None` to auto-detect) + auto_clean (bool): If `True` (the default), the directory + contents will be wiped on close. + ignore_clean_errors (bool): If `True` (the default), any errors + in the clean process will be suppressed. If `False`, they + will be raised. + + """ self.identifier = identifier self._auto_clean = auto_clean self._ignore_clean_errors = ignore_clean_errors @@ -76,8 +77,7 @@ def close(self): def clean(self): # type: () -> None - """Clean (delete) temporary files created by this filesystem. - """ + """Clean (delete) temporary files created by this filesystem.""" if self._cleaned: return diff --git a/fs/test.py b/fs/test.py index de70f280..d8b5b812 100644 --- a/fs/test.py +++ b/fs/test.py @@ -245,13 +245,15 @@ class FSTestCases(object): - """Basic FS tests. - """ + """Basic FS tests.""" - def make_fs(self): - """Return an FS instance. + data1 = b"foo" * 256 * 1024 + data2 = b"bar" * 2 * 256 * 1024 + data3 = b"baz" * 3 * 256 * 1024 + data4 = b"egg" * 7 * 256 * 1024 - """ + def make_fs(self): + """Return an FS instance.""" raise NotImplementedError("implement me") def destroy_fs(self, fs): @@ -430,15 +432,13 @@ def test_geturl(self): self.fs.hasurl("a/b/c/foo/bar") def test_geturl_purpose(self): - """Check an unknown purpose raises a NoURL error. - """ + """Check an unknown purpose raises a NoURL error.""" self.fs.create("foo") with self.assertRaises(errors.NoURL): self.fs.geturl("foo", purpose="__nosuchpurpose__") def test_validatepath(self): - """Check validatepath returns an absolute path. - """ + """Check validatepath returns an absolute path.""" path = self.fs.validatepath("foo") self.assertEqual(path, "/foo") @@ -1196,22 +1196,17 @@ def test_copy(self): def _test_upload(self, workers): """Test fs.copy with varying number of worker threads.""" - data1 = b"foo" * 256 * 1024 - data2 = b"bar" * 2 * 256 * 1024 - data3 = b"baz" * 3 * 256 * 1024 - data4 = b"egg" * 7 * 256 * 1024 - with open_fs("temp://") as src_fs: - src_fs.writebytes("foo", data1) - src_fs.writebytes("bar", data2) - src_fs.makedir("dir1").writebytes("baz", data3) - src_fs.makedirs("dir2/dir3").writebytes("egg", data4) + src_fs.writebytes("foo", self.data1) + src_fs.writebytes("bar", self.data2) + src_fs.makedir("dir1").writebytes("baz", self.data3) + src_fs.makedirs("dir2/dir3").writebytes("egg", self.data4) dst_fs = self.fs fs.copy.copy_fs(src_fs, dst_fs, workers=workers) - self.assertEqual(dst_fs.readbytes("foo"), data1) - self.assertEqual(dst_fs.readbytes("bar"), data2) - self.assertEqual(dst_fs.readbytes("dir1/baz"), data3) - self.assertEqual(dst_fs.readbytes("dir2/dir3/egg"), data4) + self.assertEqual(dst_fs.readbytes("foo"), self.data1) + self.assertEqual(dst_fs.readbytes("bar"), self.data2) + self.assertEqual(dst_fs.readbytes("dir1/baz"), self.data3) + self.assertEqual(dst_fs.readbytes("dir2/dir3/egg"), self.data4) def test_upload_0(self): self._test_upload(0) @@ -1227,21 +1222,17 @@ def test_upload_4(self): def _test_download(self, workers): """Test fs.copy with varying number of worker threads.""" - data1 = b"foo" * 256 * 1024 - data2 = b"bar" * 2 * 256 * 1024 - data3 = b"baz" * 3 * 256 * 1024 - data4 = b"egg" * 7 * 256 * 1024 src_fs = self.fs with open_fs("temp://") as dst_fs: - src_fs.writebytes("foo", data1) - src_fs.writebytes("bar", data2) - src_fs.makedir("dir1").writebytes("baz", data3) - src_fs.makedirs("dir2/dir3").writebytes("egg", data4) + src_fs.writebytes("foo", self.data1) + src_fs.writebytes("bar", self.data2) + src_fs.makedir("dir1").writebytes("baz", self.data3) + src_fs.makedirs("dir2/dir3").writebytes("egg", self.data4) fs.copy.copy_fs(src_fs, dst_fs, workers=workers) - self.assertEqual(dst_fs.readbytes("foo"), data1) - self.assertEqual(dst_fs.readbytes("bar"), data2) - self.assertEqual(dst_fs.readbytes("dir1/baz"), data3) - self.assertEqual(dst_fs.readbytes("dir2/dir3/egg"), data4) + self.assertEqual(dst_fs.readbytes("foo"), self.data1) + self.assertEqual(dst_fs.readbytes("bar"), self.data2) + self.assertEqual(dst_fs.readbytes("dir1/baz"), self.data3) + self.assertEqual(dst_fs.readbytes("dir2/dir3/egg"), self.data4) def test_download_0(self): self._test_download(0) @@ -1494,7 +1485,7 @@ def test_upload(self): with self.fs.open("foo", "rb") as f: data = f.read() self.assertEqual(data, b"bar") - + # upload to non-existing path (/spam/eggs) with self.assertRaises(errors.ResourceNotFound): self.fs.upload("/spam/eggs", bytes_file) diff --git a/fs/time.py b/fs/time.py index 5af60578..f1638aa3 100644 --- a/fs/time.py +++ b/fs/time.py @@ -16,13 +16,11 @@ def datetime_to_epoch(d): # type: (datetime) -> int - """Convert datetime to epoch. - """ + """Convert datetime to epoch.""" return timegm(d.utctimetuple()) def epoch_to_datetime(t): # type: (int) -> datetime - """Convert epoch time to a UTC datetime. - """ + """Convert epoch time to a UTC datetime.""" return utclocalize(utcfromtimestamp(t)) if t is not None else None diff --git a/fs/tree.py b/fs/tree.py index faee5472..0f3142fe 100644 --- a/fs/tree.py +++ b/fs/tree.py @@ -79,8 +79,7 @@ def render( def write(line): # type: (Text) -> None - """Write a line to the output. - """ + """Write a line to the output.""" print(line, file=file) # FIXME(@althonos): define functions using `with_color` and @@ -88,32 +87,28 @@ def write(line): def format_prefix(prefix): # type: (Text) -> Text - """Format the prefix lines. - """ + """Format the prefix lines.""" if not with_color: return prefix return "\x1b[32m%s\x1b[0m" % prefix def format_dirname(dirname): # type: (Text) -> Text - """Format a directory name. - """ + """Format a directory name.""" if not with_color: return dirname return "\x1b[1;34m%s\x1b[0m" % dirname def format_error(msg): # type: (Text) -> Text - """Format an error. - """ + """Format an error.""" if not with_color: return msg return "\x1b[31m%s\x1b[0m" % msg def format_filename(fname): # type: (Text) -> Text - """Format a filename. - """ + """Format a filename.""" if not with_color: return fname if fname.startswith("."): @@ -122,26 +117,23 @@ def format_filename(fname): def sort_key_dirs_first(info): # type: (Info) -> Tuple[bool, Text] - """Get the info sort function with directories first. - """ + """Get the info sort function with directories first.""" return (not info.is_dir, info.name.lower()) def sort_key(info): # type: (Info) -> Text - """Get the default info sort function using resource name. - """ + """Get the default info sort function using resource name.""" return info.name.lower() counts = {"dirs": 0, "files": 0} def format_directory(path, levels): # type: (Text, List[bool]) -> None - """Recursive directory function. - """ + """Recursive directory function.""" try: directory = sorted( fs.filterdir(path, exclude_dirs=exclude, files=filter), - key=sort_key_dirs_first if dirs_first else sort_key, + key=sort_key_dirs_first if dirs_first else sort_key, # type: ignore ) except Exception as error: prefix = ( diff --git a/fs/walk.py b/fs/walk.py index 3e44537d..0f4adb99 100644 --- a/fs/walk.py +++ b/fs/walk.py @@ -50,34 +50,7 @@ class Walker(object): - """A walker object recursively lists directories in a filesystem. - - Arguments: - ignore_errors (bool): If `True`, any errors reading a - directory will be ignored, otherwise exceptions will - be raised. - on_error (callable, optional): If ``ignore_errors`` is `False`, - then this callable will be invoked for a path and the exception - object. It should return `True` to ignore the error, or `False` - to re-raise it. - search (str): If ``'breadth'`` then the directory will be - walked *top down*. Set to ``'depth'`` to walk *bottom up*. - filter (list, optional): If supplied, this parameter should be - a list of filename patterns, e.g. ``['*.py']``. Files will - only be returned if the final component matches one of the - patterns. - exclude (list, optional): If supplied, this parameter should be - a list of filename patterns, e.g. ``['~*']``. Files matching - any of these patterns will be removed from the walk. - filter_dirs (list, optional): A list of patterns that will be used - to match directories paths. The walk will only open directories - that match at least one of these patterns. - exclude_dirs (list, optional): A list of patterns that will be - used to filter out directories from the walk. e.g. - ``['*.svn', '*.git']``. - max_depth (int, optional): Maximum directory depth to walk. - - """ + """A walker object recursively lists directories in a filesystem.""" def __init__( self, @@ -91,6 +64,34 @@ def __init__( max_depth=None, # type: Optional[int] ): # type: (...) -> None + """Create a new `Walker` instance. + + Arguments: + ignore_errors (bool): If `True`, any errors reading a + directory will be ignored, otherwise exceptions will + be raised. + on_error (callable, optional): If ``ignore_errors`` is `False`, + then this callable will be invoked for a path and the + exception object. It should return `True` to ignore the error, + or `False` to re-raise it. + search (str): If ``"breadth"`` then the directory will be + walked *top down*. Set to ``"depth"`` to walk *bottom up*. + filter (list, optional): If supplied, this parameter should be + a list of filename patterns, e.g. ``["*.py"]``. Files will + only be returned if the final component matches one of the + patterns. + exclude (list, optional): If supplied, this parameter should be + a list of filename patterns, e.g. ``["~*"]``. Files matching + any of these patterns will be removed from the walk. + filter_dirs (list, optional): A list of patterns that will be used + to match directories paths. The walk will only open directories + that match at least one of these patterns. + exclude_dirs (list, optional): A list of patterns that will be + used to filter out directories from the walk. e.g. + ``['*.svn', '*.git']``. + max_depth (int, optional): Maximum directory depth to walk. + + """ if search not in ("breadth", "depth"): raise ValueError("search must be 'breadth' or 'depth'") self.ignore_errors = ignore_errors @@ -114,21 +115,19 @@ def __init__( @classmethod def _ignore_errors(cls, path, error): # type: (Text, Exception) -> bool - """Default on_error callback.""" + """Ignore dir scan errors when called.""" return True @classmethod def _raise_errors(cls, path, error): # type: (Text, Exception) -> bool - """Callback to re-raise dir scan errors.""" + """Re-raise dir scan errors when called.""" return False @classmethod def _calculate_depth(cls, path): # type: (Text) -> int - """Calculate the 'depth' of a directory path (number of - components). - """ + """Calculate the 'depth' of a directory path (i.e. count components).""" _path = path.strip("/") return _path.count("/") + 1 if _path else 0 @@ -198,8 +197,7 @@ def _iter_walk( def _check_open_dir(self, fs, path, info): # type: (FS, Text, Info) -> bool - """Check if a directory should be considered in the walk. - """ + """Check if a directory should be considered in the walk.""" if self.exclude_dirs is not None and fs.match(self.exclude_dirs, info.name): return False if self.filter_dirs is not None and not fs.match(self.filter_dirs, info.name): @@ -263,7 +261,6 @@ def check_file(self, fs, info): bool: `True` if the file should be included. """ - if self.exclude is not None and fs.match(self.exclude, info.name): return False return fs.match(self.filter, info.name) @@ -411,8 +408,7 @@ def _walk_breadth( namespaces=None, # type: Optional[Collection[Text]] ): # type: (...) -> Iterator[Tuple[Text, Optional[Info]]] - """Walk files using a *breadth first* search. - """ + """Walk files using a *breadth first* search.""" queue = deque([path]) push = queue.appendleft pop = queue.pop @@ -447,8 +443,7 @@ def _walk_depth( namespaces=None, # type: Optional[Collection[Text]] ): # type: (...) -> Iterator[Tuple[Text, Optional[Info]]] - """Walk files using a *depth first* search. - """ + """Walk files using a *depth first* search.""" # No recursion! _combine = combine @@ -495,11 +490,6 @@ def _walk_depth( class BoundWalker(typing.Generic[_F]): """A class that binds a `Walker` instance to a `FS` instance. - Arguments: - fs (FS): A filesystem instance. - walker_class (type): A `~fs.walk.WalkerBase` - sub-class. The default uses `~fs.walk.Walker`. - You will typically not need to create instances of this class explicitly. Filesystems have a `~FS.walk` property which returns a `BoundWalker` object. @@ -510,13 +500,21 @@ class BoundWalker(typing.Generic[_F]): >>> home_fs.walk BoundWalker(OSFS('/Users/will', encoding='utf-8')) - A `BoundWalker` is callable. Calling it is an alias for - `~fs.walk.BoundWalker.walk`. + A `BoundWalker` is callable. Calling it is an alias for the + `~fs.walk.BoundWalker.walk` method. """ def __init__(self, fs, walker_class=Walker): # type: (_F, Type[Walker]) -> None + """Create a new walker bound to the given filesystem. + + Arguments: + fs (FS): A filesystem instance. + walker_class (type): A `~fs.walk.WalkerBase` + sub-class. The default uses `~fs.walk.Walker`. + + """ self.fs = fs self.walker_class = walker_class @@ -526,8 +524,7 @@ def __repr__(self): def _make_walker(self, *args, **kwargs): # type: (*Any, **Any) -> Walker - """Create a walker instance. - """ + """Create a walker instance.""" walker = self.walker_class(*args, **kwargs) return walker diff --git a/fs/wildcard.py b/fs/wildcard.py index 6c710cad..a43a84b7 100644 --- a/fs/wildcard.py +++ b/fs/wildcard.py @@ -32,7 +32,7 @@ def match(pattern, name): try: re_pat = _PATTERN_CACHE[(pattern, True)] except KeyError: - res = "(?ms)" + _translate(pattern) + r'\Z' + res = "(?ms)" + _translate(pattern) + r"\Z" _PATTERN_CACHE[(pattern, True)] = re_pat = re.compile(res) return re_pat.match(name) is not None @@ -52,7 +52,7 @@ def imatch(pattern, name): try: re_pat = _PATTERN_CACHE[(pattern, False)] except KeyError: - res = "(?ms)" + _translate(pattern, case_sensitive=False) + r'\Z' + res = "(?ms)" + _translate(pattern, case_sensitive=False) + r"\Z" _PATTERN_CACHE[(pattern, False)] = re_pat = re.compile(res, re.IGNORECASE) return re_pat.match(name) is not None diff --git a/fs/wrap.py b/fs/wrap.py index 7026bcbc..8685e9f6 100644 --- a/fs/wrap.py +++ b/fs/wrap.py @@ -94,7 +94,7 @@ class WrapCachedDir(WrapFS[_F], typing.Generic[_F]): wrap_name = "cached-dir" - def __init__(self, wrap_fs): + def __init__(self, wrap_fs): # noqa: D107 # type: (_F) -> None super(WrapCachedDir, self).__init__(wrap_fs) self._cache = {} # type: Dict[Tuple[Text, frozenset], Dict[Text, Info]] diff --git a/fs/wrapfs.py b/fs/wrapfs.py index c09e9cf3..e40a7a83 100644 --- a/fs/wrapfs.py +++ b/fs/wrapfs.py @@ -60,7 +60,7 @@ class WrapFS(FS, typing.Generic[_F]): wrap_name = None # type: Optional[Text] - def __init__(self, wrap_fs): + def __init__(self, wrap_fs): # noqa: D107 # type: (_F) -> None self._wrap_fs = wrap_fs super(WrapFS, self).__init__() diff --git a/fs/zipfs.py b/fs/zipfs.py index 8feb9e56..d8300a26 100644 --- a/fs/zipfs.py +++ b/fs/zipfs.py @@ -44,7 +44,7 @@ class _ZipExtFile(RawWrapper): - def __init__(self, fs, name): + def __init__(self, fs, name): # noqa: D107 # type: (ReadZipFS, Text) -> None self._zip = _zip = fs._zip self._end = _zip.getinfo(name).file_size @@ -191,15 +191,14 @@ def __init__( compression=zipfile.ZIP_DEFLATED, # type: int encoding="utf-8", # type: Text temp_fs="temp://__ziptemp__", # type: Text - ): + ): # noqa: D107 # type: (...) -> None pass @six.python_2_unicode_compatible class WriteZipFS(WrapFS): - """A writable zip file. - """ + """A writable zip file.""" def __init__( self, @@ -207,7 +206,7 @@ def __init__( compression=zipfile.ZIP_DEFLATED, # type: int encoding="utf-8", # type: Text temp_fs="temp://__ziptemp__", # type: Text - ): + ): # noqa: D107 # type: (...) -> None self._file = file self.compression = compression @@ -276,8 +275,7 @@ def write_zip( @six.python_2_unicode_compatible class ReadZipFS(FS): - """A readable zip file. - """ + """A readable zip file.""" _meta = { "case_insensitive": True, @@ -290,7 +288,7 @@ class ReadZipFS(FS): } @errors.CreateFailed.catch_all - def __init__(self, file, encoding="utf-8"): + def __init__(self, file, encoding="utf-8"): # noqa: D107 # type: (Union[BinaryIO, Text], Text) -> None super(ReadZipFS, self).__init__() self._file = file @@ -308,8 +306,7 @@ def __str__(self): def _path_to_zip_name(self, path): # type: (Text) -> str - """Convert a path to a zip file name. - """ + """Convert a path to a zip file name.""" path = relpath(normpath(path)) if self._directory.isdir(path): path = forcedir(path) @@ -320,8 +317,7 @@ def _path_to_zip_name(self, path): @property def _directory(self): # type: () -> MemoryFS - """`MemoryFS`: a filesystem with the same folder hierarchy as the zip. - """ + """`MemoryFS`: a filesystem with the same folder hierarchy as the zip.""" self.check() with self._lock: if self._directory_fs is None: diff --git a/setup.cfg b/setup.cfg index 24c9f3d7..faad103c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,4 @@ -[bdist_wheel] -universal = 1 +# --- Project configuration ------------------------------------------------- [metadata] version = attr: fs._version.__version__ @@ -60,6 +59,11 @@ exclude = tests [options.package_data] fs = py.typed +[bdist_wheel] +universal = 1 + +# --- Individual linter configuration --------------------------------------- + [pydocstyle] inherit = false ignore = D102,D105,D200,D203,D213,D406,D407 @@ -83,10 +87,29 @@ warn_return_any = false [mypy-fs.test] disallow_untyped_defs = false +[flake8] +extend-ignore = E203,E402,W503 +max-line-length = 88 +per-file-ignores = + fs/__init__.py:F401 + fs/*/__init__.py:F401 + tests/*:E501 + fs/opener/*:F811 + fs/_fscompat.py:F401 + +[isort] +default_section = THIRD_PARTY +known_first_party = fs +known_standard_library = typing +line_length = 88 + +# --- Test and coverage configuration ------------------------------------------ + [coverage:run] branch = true omit = fs/test.py source = fs +relative_files = true [coverage:report] show_missing = true @@ -101,18 +124,63 @@ exclude_lines = markers = slow: marks tests as slow (deselect with '-m "not slow"') -[flake8] -extend-ignore = E203,E402,W503 -max-line-length = 88 -per-file-ignores = - fs/__init__.py:F401 - fs/*/__init__.py:F401 - tests/*:E501 - fs/opener/*:F811 - fs/_fscompat.py:F401 +# --- Tox automation configuration --------------------------------------------- -[isort] -default_section = THIRD_PARTY -known_first_party = fs -known_standard_library = typing -line_length = 88 +[tox:tox] +envlist = py{27,34}{,-scandir}, py{35,36,37,38,39}, pypy{27,36,37}, typecheck, codestyle, docstyle, codeformat +sitepackages = false +skip_missing_interpreters = true +requires = + setuptools >=38.3.0 + +[testenv] +commands = pytest --cov={toxinidir}/fs {posargs} {toxinidir}/tests +deps = + -rtests/requirements.txt + pytest-cov~=2.11 + coverage~=5.0 + py{36,37,38,39}: pytest~=6.2 + py{27,34}: pytest~=4.6 + py{36,37,38,39}: pytest-randomly~=3.5 + py{27,34}: pytest-randomly~=1.2 + scandir: .[scandir] + !scandir: . + +[testenv:typecheck] +commands = mypy --config-file {toxinidir}/setup.cfg {toxinidir}/fs +deps = + . + mypy==0.800 + +[testenv:codestyle] +commands = flake8 --config={toxinidir}/setup.cfg {toxinidir}/fs {toxinidir}/tests +deps = + flake8==3.7.9 + #flake8-builtins==1.5.3 + flake8-bugbear==19.8.0 + flake8-comprehensions==3.1.4 + flake8-mutable==1.2.0 + flake8-tuple==0.4.0 + +[testenv:codeformat] +commands = black --check {toxinidir}/fs +deps = + black==20.8b1 + +[testenv:docstyle] +commands = pydocstyle --config={toxinidir}/setup.cfg {toxinidir}/fs +deps = + pydocstyle==5.1.1 + +[gh-actions] +python = + 2.7: py27, py27-scandir + 3.4: py34, py34-scandir + 3.5: py35 + 3.6: py36 + 3.7: py37 + 3.8: py38 + 3.9: py39 + pypy-2.7: pypy27 + pypy-3.6: pypy36 + pypy-3.7: pypy37 diff --git a/testrequirements.txt b/testrequirements.txt deleted file mode 100644 index bfa0e294..00000000 --- a/testrequirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -pytest==4.6.5 -pytest-cov==2.7.1 -pytest-randomly==1.2.3 ; python_version<"3.5" -pytest-randomly==3.0.0 ; python_version>="3.5" -mock==3.0.5 ; python_version<"3.3" -pyftpdlib==1.5.5 - -# Not directly required. `pyftpdlib` appears to need these but doesn't list them -# as requirements. -psutil -pysendfile diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index b820712f..00000000 --- a/tests/conftest.py +++ /dev/null @@ -1,34 +0,0 @@ -import pytest - -try: - from unittest import mock -except ImportError: - import mock - - -@pytest.fixture -@mock.patch("appdirs.user_data_dir", autospec=True, spec_set=True) -@mock.patch("appdirs.site_data_dir", autospec=True, spec_set=True) -@mock.patch("appdirs.user_config_dir", autospec=True, spec_set=True) -@mock.patch("appdirs.site_config_dir", autospec=True, spec_set=True) -@mock.patch("appdirs.user_cache_dir", autospec=True, spec_set=True) -@mock.patch("appdirs.user_state_dir", autospec=True, spec_set=True) -@mock.patch("appdirs.user_log_dir", autospec=True, spec_set=True) -def mock_appdir_directories( - user_log_dir_mock, - user_state_dir_mock, - user_cache_dir_mock, - site_config_dir_mock, - user_config_dir_mock, - site_data_dir_mock, - user_data_dir_mock, - tmpdir -): - """Mock out every single AppDir directory so tests can't access real ones.""" - user_log_dir_mock.return_value = str(tmpdir.join("user_log").mkdir()) - user_state_dir_mock.return_value = str(tmpdir.join("user_state").mkdir()) - user_cache_dir_mock.return_value = str(tmpdir.join("user_cache").mkdir()) - site_config_dir_mock.return_value = str(tmpdir.join("site_config").mkdir()) - user_config_dir_mock.return_value = str(tmpdir.join("user_config").mkdir()) - site_data_dir_mock.return_value = str(tmpdir.join("site_data").mkdir()) - user_data_dir_mock.return_value = str(tmpdir.join("user_data").mkdir()) diff --git a/tests/mark.py b/tests/mark.py new file mode 100644 index 00000000..4ac89d59 --- /dev/null +++ b/tests/mark.py @@ -0,0 +1,2 @@ +def slow(self): + pass diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 00000000..9e7ece32 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,16 @@ +# the bare requirements for running tests + +# pyftpdlib is needed to spawn a FTP server for the +# FTPFS test suite +pyftpdlib ~=1.5 + +# these are optional dependencies for pyftpdlib that +# are not explicitly listed, we need to install these +# ourselves +psutil ~=5.0 +pysendfile ~=2.0 ; python_version <= "3.3" + +# mock is only available from Python 3.3 onward, and +# mock v4+ doesn't support Python 2.7 anymore +mock ~=3.0 ; python_version < "3.3" + diff --git a/tests/test_appfs.py b/tests/test_appfs.py index a060e97a..acc8a7f7 100644 --- a/tests/test_appfs.py +++ b/tests/test_appfs.py @@ -1,24 +1,87 @@ from __future__ import unicode_literals -import pytest +import shutil +import tempfile +import unittest + import six +try: + from unittest import mock +except ImportError: + import mock + +import fs.test from fs import appfs -@pytest.fixture -def fs(mock_appdir_directories): - """Create a UserDataFS but strictly using a temporary directory.""" - return appfs.UserDataFS("fstest", "willmcgugan", "1.0") +class _TestAppFS(fs.test.FSTestCases): + + AppFS = None + + @classmethod + def setUpClass(cls): + super(_TestAppFS, cls).setUpClass() + cls.tmpdir = tempfile.mkdtemp() + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmpdir) + + def make_fs(self): + with mock.patch( + "appdirs.{}".format(self.AppFS.app_dir), + autospec=True, + spec_set=True, + return_value=tempfile.mkdtemp(dir=self.tmpdir), + ): + return self.AppFS("fstest", "willmcgugan", "1.0") + + if six.PY2: + + def test_repr(self): + self.assertEqual( + repr(self.fs), + "{}(u'fstest', author=u'willmcgugan', version=u'1.0')".format( + self.AppFS.__name__ + ), + ) + + else: + + def test_repr(self): + self.assertEqual( + repr(self.fs), + "{}('fstest', author='willmcgugan', version='1.0')".format( + self.AppFS.__name__ + ), + ) + + def test_str(self): + self.assertEqual( + str(self.fs), "<{} 'fstest'>".format(self.AppFS.__name__.lower()) + ) + + +class TestUserDataFS(_TestAppFS, unittest.TestCase): + AppFS = appfs.UserDataFS + + +class TestUserConfigFS(_TestAppFS, unittest.TestCase): + AppFS = appfs.UserConfigFS + + +class TestUserCacheFS(_TestAppFS, unittest.TestCase): + AppFS = appfs.UserCacheFS + + +class TestSiteDataFS(_TestAppFS, unittest.TestCase): + AppFS = appfs.SiteDataFS -@pytest.mark.skipif(six.PY2, reason="Test requires Python 3 repr") -def test_user_data_repr_py3(fs): - assert repr(fs) == "UserDataFS('fstest', author='willmcgugan', version='1.0')" - assert str(fs) == "" +class TestSiteConfigFS(_TestAppFS, unittest.TestCase): + AppFS = appfs.SiteConfigFS -@pytest.mark.skipif(not six.PY2, reason="Test requires Python 2 repr") -def test_user_data_repr_py2(fs): - assert repr(fs) == "UserDataFS(u'fstest', author=u'willmcgugan', version=u'1.0')" - assert str(fs) == "" +class TestUserLogFS(_TestAppFS, unittest.TestCase): + AppFS = appfs.UserLogFS diff --git a/tests/test_encoding.py b/tests/test_encoding.py index 59659942..0cd91d4c 100644 --- a/tests/test_encoding.py +++ b/tests/test_encoding.py @@ -6,8 +6,6 @@ import tempfile import unittest -import pytest - import six import fs @@ -16,9 +14,7 @@ if platform.system() != "Windows": - @pytest.mark.skipif( - platform.system() == "Darwin", reason="Bad unicode not possible on OSX" - ) + @unittest.skipIf(platform.system() == "Darwin", "Bad unicode not possible on OSX") class TestEncoding(unittest.TestCase): TEST_FILENAME = b"foo\xb1bar" diff --git a/tests/test_errors.py b/tests/test_errors.py index 1ed98c54..5f4d8b8c 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -30,7 +30,7 @@ def test_raise_in_multiprocessing(self): [errors.NoURL, "some_path", "some_purpose"], [errors.Unsupported], [errors.IllegalBackReference, "path"], - [errors.MissingInfoNamespace, "path"] + [errors.MissingInfoNamespace, "path"], ] try: pool = multiprocessing.Pool(1) diff --git a/tests/test_ftpfs.py b/tests/test_ftpfs.py index 7207e9e9..fd34c134 100644 --- a/tests/test_ftpfs.py +++ b/tests/test_ftpfs.py @@ -3,16 +3,15 @@ from __future__ import print_function from __future__ import unicode_literals -import socket import os import platform import shutil +import socket import tempfile import time import unittest import uuid -import pytest from six import text_type from ftplib import error_perm @@ -27,6 +26,10 @@ from fs.subfs import SubFS from fs.test import FSTestCases +try: + from pytest import mark +except ImportError: + from . import mark # Prevent socket timeouts from slowing tests too much socket.setdefaulttimeout(1) @@ -129,7 +132,8 @@ def test_manager_with_host(self): ) -@pytest.mark.slow +@mark.slow +@unittest.skipIf(platform.python_implementation() == "PyPy", "ftp unreliable with PyPy") class TestFTPFS(FSTestCases, unittest.TestCase): user = "user" @@ -279,7 +283,8 @@ def test_features(self): pass -@pytest.mark.slow +@mark.slow +@unittest.skipIf(platform.python_implementation() == "PyPy", "ftp unreliable with PyPy") class TestAnonFTPFS(FSTestCases, unittest.TestCase): user = "anonymous" diff --git a/tests/test_info.py b/tests/test_info.py index 8c5a1d30..f83c1e7b 100644 --- a/tests/test_info.py +++ b/tests/test_info.py @@ -1,4 +1,3 @@ - from __future__ import unicode_literals import datetime diff --git a/tests/test_memoryfs.py b/tests/test_memoryfs.py index c8193fd6..0b26a576 100644 --- a/tests/test_memoryfs.py +++ b/tests/test_memoryfs.py @@ -3,8 +3,6 @@ import posixpath import unittest -import pytest - from fs import memoryfs from fs.test import FSTestCases from fs.test import UNICODE_TEXT @@ -30,8 +28,8 @@ def _create_many_files(self): posixpath.join(parent_dir, str(file_id)), UNICODE_TEXT ) - @pytest.mark.skipif( - not tracemalloc, reason="`tracemalloc` isn't supported on this Python version." + @unittest.skipUnless( + tracemalloc, reason="`tracemalloc` isn't supported on this Python version." ) def test_close_mem_free(self): """Ensure all file memory is freed when calling close(). diff --git a/tests/test_move.py b/tests/test_move.py index bec4d776..d87d2bd6 100644 --- a/tests/test_move.py +++ b/tests/test_move.py @@ -1,4 +1,3 @@ - from __future__ import unicode_literals import unittest diff --git a/tests/test_opener.py b/tests/test_opener.py index fc450751..fde555a2 100644 --- a/tests/test_opener.py +++ b/tests/test_opener.py @@ -1,13 +1,12 @@ from __future__ import unicode_literals import os +import shutil import sys import tempfile import unittest import pkg_resources -import pytest - from fs import open_fs, opener from fs.osfs import OSFS from fs.opener import registry, errors @@ -208,8 +207,13 @@ def test_manage_fs_error(self): self.assertTrue(mem_fs.isclosed()) -@pytest.mark.usefixtures("mock_appdir_directories") class TestOpeners(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.tmpdir) + def test_repr(self): # Check __repr__ works for entry_point in pkg_resources.iter_entry_points("fs.opener"): @@ -260,7 +264,10 @@ def test_open_fs(self): mem_fs_2 = opener.open_fs(mem_fs) self.assertEqual(mem_fs, mem_fs_2) - def test_open_userdata(self): + @mock.patch("appdirs.{}".format(UserDataFS.app_dir), autospec=True, spec_set=True) + def test_open_userdata(self, app_dir): + app_dir.return_value = self.tmpdir + with self.assertRaises(errors.OpenerError): opener.open_fs("userdata://foo:bar:baz:egg") @@ -269,13 +276,19 @@ def test_open_userdata(self): self.assertEqual(app_fs.app_dirs.appauthor, "willmcgugan") self.assertEqual(app_fs.app_dirs.version, "1.0") - def test_open_userdata_no_version(self): + @mock.patch("appdirs.{}".format(UserDataFS.app_dir), autospec=True, spec_set=True) + def test_open_userdata_no_version(self, app_dir): + app_dir.return_value = self.tmpdir + app_fs = opener.open_fs("userdata://fstest:willmcgugan", create=True) self.assertEqual(app_fs.app_dirs.appname, "fstest") self.assertEqual(app_fs.app_dirs.appauthor, "willmcgugan") self.assertEqual(app_fs.app_dirs.version, None) - def test_user_data_opener(self): + @mock.patch("appdirs.{}".format(UserDataFS.app_dir), autospec=True, spec_set=True) + def test_user_data_opener(self, app_dir): + app_dir.return_value = self.tmpdir + user_data_fs = open_fs("userdata://fstest:willmcgugan:1.0", create=True) self.assertIsInstance(user_data_fs, UserDataFS) user_data_fs.makedir("foo", recreate=True) diff --git a/tests/test_osfs.py b/tests/test_osfs.py index f656646c..e43635f4 100644 --- a/tests/test_osfs.py +++ b/tests/test_osfs.py @@ -8,7 +8,6 @@ import tempfile import sys import unittest -import pytest from fs import osfs, open_fs from fs.path import relpath, dirname @@ -88,10 +87,10 @@ def test_expand_vars(self): self.assertIn("TYRIONLANISTER", fs1.getsyspath("/")) self.assertNotIn("TYRIONLANISTER", fs2.getsyspath("/")) - @pytest.mark.skipif(osfs.sendfile is None, reason="sendfile not supported") - @pytest.mark.skipif( + @unittest.skipUnless(osfs.sendfile, "sendfile not supported") + @unittest.skipIf( sys.version_info >= (3, 8), - reason="the copy function uses sendfile in Python 3.8+, " + "the copy function uses sendfile in Python 3.8+, " "making the patched implementation irrelevant", ) def test_copy_sendfile(self): @@ -139,7 +138,7 @@ def test_unicode_paths(self): finally: shutil.rmtree(dir_path) - @pytest.mark.skipif(not hasattr(os, "symlink"), reason="No symlink support") + @unittest.skipUnless(hasattr(os, "symlink"), "No symlink support") def test_symlinks(self): with open(self._get_real_path("foo"), "wb") as f: f.write(b"foobar") diff --git a/tests/test_tarfs.py b/tests/test_tarfs.py index a90dc0ea..fc3f0779 100644 --- a/tests/test_tarfs.py +++ b/tests/test_tarfs.py @@ -7,7 +7,6 @@ import tarfile import tempfile import unittest -import pytest from fs import tarfs from fs.enums import ResourceType @@ -19,6 +18,11 @@ from .test_archives import ArchiveTestCases +try: + from pytest import mark +except ImportError: + from . import mark + class TestWriteReadTarFS(unittest.TestCase): def setUp(self): @@ -94,7 +98,8 @@ def destroy_fs(self, fs): del fs._tar_file -@pytest.mark.skipif(six.PY2, reason="Python2 does not support LZMA") +@mark.slow +@unittest.skipIf(six.PY2, "Python2 does not support LZMA") class TestWriteXZippedTarFS(FSTestCases, unittest.TestCase): def make_fs(self): fh, _tar_file = tempfile.mkstemp() @@ -119,6 +124,7 @@ def assert_is_xz(self, fs): tarfile.open(fs._tar_file, "r:{}".format(other_comps)) +@mark.slow class TestWriteBZippedTarFS(FSTestCases, unittest.TestCase): def make_fs(self): fh, _tar_file = tempfile.mkstemp() @@ -237,8 +243,7 @@ def test_listdir(self): class TestImplicitDirectories(unittest.TestCase): - """Regression tests for #160. - """ + """Regression tests for #160.""" @classmethod def setUpClass(cls): diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 887ecd56..00000000 --- a/tox.ini +++ /dev/null @@ -1,27 +0,0 @@ -[tox] -envlist = {py27,py34,py35,py36,py37}{,-scandir},pypy,typecheck,lint -sitepackages = False -skip_missing_interpreters=True - -[testenv] -deps = -r {toxinidir}/testrequirements.txt -commands = coverage run -m pytest --cov-append {posargs} {toxinidir}/tests - -[testenv:typecheck] -python = python37 -deps = - mypy==0.740 - -r {toxinidir}/testrequirements.txt -commands = make typecheck -whitelist_externals = make - -[testenv:lint] -python = python37 -deps = - flake8 - # flake8-builtins - flake8-bugbear - flake8-comprehensions - # flake8-isort - flake8-mutable -commands = flake8 fs tests