From f885f62fa03e72652b8256afe110ef0a418879f3 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 28 Nov 2019 16:25:15 +0100 Subject: [PATCH 001/108] add conflict options to copy --- organize/actions/copy.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/organize/actions/copy.py b/organize/actions/copy.py index a9c3b256..ce3b139e 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -10,6 +10,9 @@ logger = logging.getLogger(__name__) +CONFLICT_OPTIONS = ("rename_new", "rename_old", "skip", "overwrite") + + class Copy(Action): """ @@ -81,9 +84,15 @@ class Copy(Action): counter_separator: '_' """ - def __init__(self, dest: str, overwrite=False, counter_separator=" ") -> None: + def __init__( + self, dest: str, on_conflict="rename_new", counter_separator=" " + ) -> None: + if on_conflict not in CONFLICT_OPTIONS: + raise ValueError( + "on_conflict must be one of %s" % ", ".join(CONFLICT_OPTIONS) + ) self.dest = dest - self.overwrite = overwrite + self.on_conflict = on_conflict self.counter_separator = counter_separator def pipeline(self, args: Mapping) -> None: From 37899ad1e6120a14c053e09b3233a97fdfb9bb6d Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 17 Jan 2020 12:10:19 +0100 Subject: [PATCH 002/108] add trash option --- organize/actions/copy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/organize/actions/copy.py b/organize/actions/copy.py index ce3b139e..66f8c67d 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -10,7 +10,7 @@ logger = logging.getLogger(__name__) -CONFLICT_OPTIONS = ("rename_new", "rename_old", "skip", "overwrite") +CONFLICT_OPTIONS = ("rename_new", "rename_old", "skip", "trash", "overwrite") class Copy(Action): @@ -126,4 +126,4 @@ def pipeline(self, args: Mapping) -> None: return None def __str__(self) -> str: - return "Copy(dest=%s, overwrite=%s)" % (self.dest, self.overwrite) + return "Copy(dest=%s, on_conflict=%s)" % (self.dest, self.on_conflict) From 66b15fd7e8706cadb529d68e28f8025de90c8182 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 20 Jan 2022 17:11:46 +0100 Subject: [PATCH 003/108] update dependencies --- poetry.lock | 1247 ++++++++++++++++++++++++++++++++---------------- pyproject.toml | 16 +- 2 files changed, 857 insertions(+), 406 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8255623b..c6a3efe7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -24,7 +24,7 @@ python-versions = "*" [[package]] name = "argcomplete" -version = "1.10.0" +version = "1.10.3" description = "Bash tab completion for argparse" category = "main" optional = true @@ -35,16 +35,17 @@ test = ["coverage", "flake8", "pexpect", "wheel"] [[package]] name = "astroid" -version = "2.5.6" +version = "2.9.3" description = "An abstract syntax tree for Python with inference support." category = "dev" optional = false -python-versions = "~=3.6" +python-versions = ">=3.6.2" [package.dependencies] lazy-object-proxy = ">=1.4.0" -typed-ast = {version = ">=1.4.0,<1.5", markers = "implementation_name == \"cpython\" and python_version < \"3.8\""} -wrapt = ">=1.11,<1.13" +typed-ast = {version = ">=1.4.0,<2.0", markers = "implementation_name == \"cpython\" and python_version < \"3.8\""} +typing-extensions = {version = ">=3.10", markers = "python_version < \"3.10\""} +wrapt = ">=1.11,<1.14" [[package]] name = "atomicwrites" @@ -56,21 +57,21 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "20.3.0" +version = "21.4.0" description = "Classes Without Boilerplate" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] -docs = ["furo", "sphinx", "zope.interface"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] [[package]] name = "babel" -version = "2.9.0" +version = "2.9.1" description = "Internationalization utilities" category = "dev" optional = false @@ -87,9 +88,23 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "backports.zoneinfo" +version = "0.2.1" +description = "Backport of the standard library zoneinfo module" +category = "main" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +importlib-resources = {version = "*", markers = "python_version < \"3.7\""} + +[package.extras] +tzdata = ["tzdata"] + [[package]] name = "beautifulsoup4" -version = "4.8.0" +version = "4.8.2" description = "Screen-scraping library" category = "main" optional = true @@ -104,7 +119,7 @@ lxml = ["lxml"] [[package]] name = "certifi" -version = "2020.12.5" +version = "2021.10.8" description = "Python package for providing Mozilla's CA Bundle." category = "dev" optional = false @@ -112,7 +127,7 @@ python-versions = "*" [[package]] name = "cffi" -version = "1.14.5" +version = "1.15.0" description = "Foreign Function Interface for Python calling C code." category = "main" optional = false @@ -126,9 +141,20 @@ name = "chardet" version = "3.0.4" description = "Universal encoding detector for Python 2 and 3" category = "main" -optional = false +optional = true python-versions = "*" +[[package]] +name = "charset-normalizer" +version = "2.0.10" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "dev" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + [[package]] name = "colorama" version = "0.4.4" @@ -137,9 +163,44 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "commonmark" +version = "0.9.1" +description = "Python parser for the CommonMark Markdown spec" +category = "main" +optional = false +python-versions = "*" + +[package.extras] +test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"] + +[[package]] +name = "compressed-rtf" +version = "1.0.6" +description = "Compressed Rich Text Format (RTF) compression and decompression package" +category = "main" +optional = true +python-versions = "*" + +[[package]] +name = "contextlib2" +version = "21.6.0" +description = "Backports and enhancements for the contextlib module" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "dataclasses" +version = "0.8" +description = "A backport of the dataclasses module for Python 3.6" +category = "main" +optional = false +python-versions = ">=3.6, <3.7" + [[package]] name = "decorator" -version = "5.0.7" +version = "5.1.1" description = "Decorators for Humans" category = "dev" optional = false @@ -169,6 +230,14 @@ category = "main" optional = true python-versions = "*" +[[package]] +name = "ebcdic" +version = "1.1.1" +description = "Additional EBCDIC codecs" +category = "main" +optional = true +python-versions = "*" + [[package]] name = "ebooklib" version = "0.17.1" @@ -191,20 +260,22 @@ python-versions = "*" [[package]] name = "extract-msg" -version = "0.23.1" +version = "0.29.0" description = "Extracts emails and attachments saved in Microsoft Outlook's .msg files" category = "main" optional = true python-versions = "*" [package.dependencies] +compressed-rtf = ">=1.0.6" +ebcdic = ">=1.1.1" imapclient = "2.1.0" -olefile = "0.46" -tzlocal = "1.5.1" +olefile = ">=0.46" +tzlocal = ">=2.1" [[package]] name = "flake8" -version = "3.9.1" +version = "3.9.2" description = "the modular source code checker: pep8 pyflakes and co" category = "dev" optional = false @@ -216,17 +287,33 @@ mccabe = ">=0.6.0,<0.7.0" pycodestyle = ">=2.7.0,<2.8.0" pyflakes = ">=2.3.0,<2.4.0" +[[package]] +name = "fs" +version = "2.4.14" +description = "Python's filesystem abstraction layer" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +appdirs = ">=1.4.3,<1.5.0" +pytz = "*" +six = ">=1.10,<2.0" + +[package.extras] +scandir = ["scandir (>=1.5,<2.0)"] + [[package]] name = "idna" -version = "2.10" +version = "3.3" description = "Internationalized Domain Names in Applications (IDNA)" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.5" [[package]] name = "imagesize" -version = "1.2.0" +version = "1.3.0" description = "Getting image size from png/jpeg/jpeg2000/gif file" category = "dev" optional = false @@ -249,7 +336,7 @@ test = ["mock (>=1.3.0)"] [[package]] name = "importlib-metadata" -version = "4.0.1" +version = "4.8.3" description = "Read metadata from Python packages" category = "dev" optional = false @@ -261,7 +348,23 @@ zipp = ">=0.5" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +perf = ["ipython"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] + +[[package]] +name = "importlib-resources" +version = "5.4.0" +description = "Read resources from Python packages" +category = "main" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] [[package]] name = "ipdb" @@ -276,7 +379,7 @@ ipython = {version = ">=5.1.0", markers = "python_version >= \"3.4\""} [[package]] name = "ipython" -version = "7.16.1" +version = "7.16.3" description = "IPython: Productive Interactive Computing" category = "dev" optional = false @@ -287,7 +390,7 @@ appnope = {version = "*", markers = "sys_platform == \"darwin\""} backcall = "*" colorama = {version = "*", markers = "sys_platform == \"win32\""} decorator = "*" -jedi = ">=0.10" +jedi = ">=0.10,<=0.17.2" pexpect = {version = "*", markers = "sys_platform != \"win32\""} pickleshare = "*" prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" @@ -315,57 +418,58 @@ python-versions = "*" [[package]] name = "isort" -version = "5.8.0" +version = "5.10.1" description = "A Python utility / library to sort Python imports." category = "dev" optional = false -python-versions = ">=3.6,<4.0" +python-versions = ">=3.6.1,<4.0" [package.extras] pipfile_deprecated_finder = ["pipreqs", "requirementslib"] requirements_deprecated_finder = ["pipreqs", "pip-api"] colors = ["colorama (>=0.4.3,<0.5.0)"] +plugins = ["setuptools"] [[package]] name = "jedi" -version = "0.18.0" +version = "0.17.2" description = "An autocompletion tool for Python that can be used for text editors." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.dependencies] -parso = ">=0.8.0,<0.9.0" +parso = ">=0.7.0,<0.8.0" [package.extras] -qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] -testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<6.0.0)"] +qa = ["flake8 (==3.7.9)"] +testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"] [[package]] name = "jinja2" -version = "2.11.3" +version = "3.0.3" description = "A very fast and expressive template engine." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" [package.dependencies] -MarkupSafe = ">=0.23" +MarkupSafe = ">=2.0" [package.extras] -i18n = ["Babel (>=0.8)"] +i18n = ["Babel (>=2.7)"] [[package]] name = "lazy-object-proxy" -version = "1.6.0" +version = "1.7.1" description = "A fast and thorough lazy object proxy." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.6" [[package]] name = "lxml" -version = "4.6.3" +version = "4.7.1" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." category = "main" optional = true @@ -391,11 +495,11 @@ xattr = ">=0.9.7,<0.10.0" [[package]] name = "markupsafe" -version = "1.1.1" +version = "2.0.1" description = "Safely add untrusted strings to HTML/XML markup." category = "dev" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +python-versions = ">=3.6" [[package]] name = "mccabe" @@ -407,7 +511,7 @@ python-versions = "*" [[package]] name = "mdfind-wrapper" -version = "0.1.4" +version = "0.1.5" description = "A python library that wraps the mdfind." category = "main" optional = false @@ -415,7 +519,7 @@ python-versions = ">=3.6" [[package]] name = "more-itertools" -version = "8.7.0" +version = "8.12.0" description = "More routines for operating on iterables, beyond itertools" category = "dev" optional = false @@ -455,40 +559,44 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "packaging" -version = "20.9" +version = "21.3" description = "Core utilities for Python packages" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] -pyparsing = ">=2.0.2" +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "parso" -version = "0.8.2" +version = "0.7.1" description = "A Python Parser" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [package.extras] -qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] -testing = ["docopt", "pytest (<6.0.0)"] +testing = ["docopt", "pytest (>=3.0.7)"] [[package]] name = "pdfminer.six" -version = "20181108" +version = "20191110" description = "PDF parser and analyzer" category = "main" optional = true python-versions = "*" [package.dependencies] +chardet = {version = "*", markers = "python_version > \"3.0\""} pycryptodome = "*" six = "*" sortedcontainers = "*" +[package.extras] +dev = ["nose", "tox"] +docs = ["sphinx", "sphinx-argparse"] + [[package]] name = "pendulum" version = "2.1.2" @@ -522,12 +630,24 @@ python-versions = "*" [[package]] name = "pillow" -version = "8.2.0" +version = "8.4.0" description = "Python Imaging Library (Fork)" category = "main" optional = true python-versions = ">=3.6" +[[package]] +name = "platformdirs" +version = "2.4.0" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.extras] +docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] +test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] + [[package]] name = "pluggy" version = "0.13.1" @@ -544,11 +664,11 @@ dev = ["pre-commit", "tox"] [[package]] name = "prompt-toolkit" -version = "3.0.3" +version = "3.0.24" description = "Library for building powerful interactive command lines in Python" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.6.2" [package.dependencies] wcwidth = "*" @@ -563,11 +683,11 @@ python-versions = "*" [[package]] name = "py" -version = "1.10.0" +version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pycodestyle" @@ -579,7 +699,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pycparser" -version = "2.20" +version = "2.21" description = "C parser in Python" category = "main" optional = false @@ -587,7 +707,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pycryptodome" -version = "3.10.1" +version = "3.12.0" description = "Cryptographic library for Python" category = "main" optional = true @@ -603,34 +723,39 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.8.1" +version = "2.11.2" description = "Pygments is a syntax highlighting package written in Python." -category = "dev" +category = "main" optional = false python-versions = ">=3.5" [[package]] name = "pylint" -version = "2.8.2" +version = "2.12.2" description = "python code static checker" category = "dev" optional = false -python-versions = "~=3.6" +python-versions = ">=3.6.2" [package.dependencies] -astroid = ">=2.5.6,<2.7" +astroid = ">=2.9.0,<2.10" colorama = {version = "*", markers = "sys_platform == \"win32\""} isort = ">=4.2.5,<6" mccabe = ">=0.6,<0.7" -toml = ">=0.7.1" +platformdirs = ">=2.2.0" +toml = ">=0.9.2" +typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} [[package]] name = "pyparsing" -version = "2.4.7" +version = "3.0.6" description = "Python parsing module" category = "dev" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" @@ -657,7 +782,7 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "nose", "requests", "mock"] [[package]] name = "python-dateutil" -version = "2.8.1" +version = "2.8.2" description = "Extensions to the standard Python datetime module" category = "main" optional = false @@ -668,7 +793,7 @@ six = ">=1.5" [[package]] name = "python-pptx" -version = "0.6.18" +version = "0.6.21" description = "Generate and manipulate Open XML PowerPoint (.pptx) files" category = "main" optional = true @@ -681,12 +806,24 @@ XlsxWriter = ">=0.5.7" [[package]] name = "pytz" -version = "2021.1" +version = "2021.3" description = "World timezone definitions, modern and historical" category = "main" optional = false python-versions = "*" +[[package]] +name = "pytz-deprecation-shim" +version = "0.1.0.post0" +description = "Shims to make deprecation of pytz easier" +category = "main" +optional = true +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" + +[package.dependencies] +"backports.zoneinfo" = {version = "*", markers = "python_version >= \"3.6\" and python_version < \"3.9\""} +tzdata = {version = "*", markers = "python_version >= \"3.6\""} + [[package]] name = "pytzdata" version = "2020.1" @@ -705,30 +842,64 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] name = "requests" -version = "2.25.1" +version = "2.27.1" description = "Python HTTP for Humans." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [package.dependencies] certifi = ">=2017.4.17" -chardet = ">=3.0.2,<5" -idna = ">=2.5,<3" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} urllib3 = ">=1.21.1,<1.27" [package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + +[[package]] +name = "rich" +version = "11.0.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +category = "main" +optional = false +python-versions = ">=3.6.2,<4.0.0" + +[package.dependencies] +colorama = ">=0.4.0,<0.5.0" +commonmark = ">=0.9.0,<0.10.0" +dataclasses = {version = ">=0.7,<0.9", markers = "python_version < \"3.7\""} +pygments = ">=2.6.0,<3.0.0" +typing-extensions = {version = ">=3.7.4,<5.0", markers = "python_version < \"3.8\""} + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"] + +[[package]] +name = "schema" +version = "0.7.5" +description = "Simple data validation library" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +contextlib2 = ">=0.5.5" [[package]] name = "send2trash" -version = "1.5.0" +version = "1.8.0" description = "Send file to trash natively under Mac OS X, Windows and Linux." category = "main" optional = false python-versions = "*" +[package.extras] +nativelib = ["pyobjc-framework-cocoa", "pywin32"] +objc = ["pyobjc-framework-cocoa"] +win32 = ["pywin32"] + [[package]] name = "simplematch" version = "1.3" @@ -747,7 +918,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*" [[package]] name = "snowballstemmer" -version = "2.1.0" +version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." category = "dev" optional = false @@ -755,7 +926,7 @@ python-versions = "*" [[package]] name = "sortedcontainers" -version = "2.3.0" +version = "2.4.0" description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set" category = "main" optional = true @@ -763,7 +934,7 @@ python-versions = "*" [[package]] name = "soupsieve" -version = "2.2.1" +version = "2.3.1" description = "A modern CSS selector implementation for Beautiful Soup." category = "main" optional = true @@ -849,11 +1020,11 @@ test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" -version = "1.0.3" +version = "2.0.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] @@ -884,7 +1055,7 @@ test = ["pytest"] [[package]] name = "sphinxcontrib-serializinghtml" -version = "1.1.4" +version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." category = "dev" optional = false @@ -896,24 +1067,24 @@ test = ["pytest"] [[package]] name = "textract" -version = "1.6.3" +version = "1.6.4" description = "extract text from any document. no muss. no fuss." category = "main" optional = true python-versions = "*" [package.dependencies] -argcomplete = "1.10.0" -beautifulsoup4 = "4.8.0" -chardet = "3.0.4" -docx2txt = "0.8" -EbookLib = "0.17.1" -extract-msg = "0.23.1" -"pdfminer.six" = "20181108" -python-pptx = "0.6.18" -six = "1.12.0" -SpeechRecognition = "3.8.1" -xlrd = "1.2.0" +argcomplete = ">=1.10.0,<1.11.0" +beautifulsoup4 = ">=4.8.0,<4.9.0" +chardet = ">=3.0.0,<4.0.0" +docx2txt = ">=0.8,<1.0" +EbookLib = "<1.0.0" +extract-msg = "<=0.29" +"pdfminer.six" = "20191110" +python-pptx = ">=0.6.18,<0.7.0" +six = ">=1.12.0,<1.13.0" +SpeechRecognition = ">=3.8.1,<3.9.0" +xlrd = ">=1.2.0,<1.3.0" [package.extras] pocketsphinx = ["pocketsphinx (==0.1.15)"] @@ -952,35 +1123,49 @@ python-versions = "*" [[package]] name = "typing-extensions" -version = "3.7.4.3" -description = "Backported and Experimental Type Hints for Python 3.5+" -category = "dev" +version = "4.0.1" +description = "Backported and Experimental Type Hints for Python 3.6+" +category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" + +[[package]] +name = "tzdata" +version = "2021.5" +description = "Provider of IANA time zone data" +category = "main" +optional = true +python-versions = ">=2" [[package]] name = "tzlocal" -version = "1.5.1" +version = "4.1" description = "tzinfo object for the local timezone" category = "main" optional = true -python-versions = "*" +python-versions = ">=3.6" [package.dependencies] -pytz = "*" +"backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""} +pytz-deprecation-shim = "*" +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["black", "pyroma", "pytest-cov", "zest.releaser"] +test = ["pytest-mock (>=3.3)", "pytest (>=4.3)"] [[package]] name = "urllib3" -version = "1.26.4" +version = "1.26.8" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" [package.extras] +brotli = ["brotlipy (>=0.6.0)"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] -brotli = ["brotlipy (>=0.6.0)"] [[package]] name = "wcwidth" @@ -992,22 +1177,22 @@ python-versions = "*" [[package]] name = "wrapt" -version = "1.12.1" +version = "1.13.3" description = "Module for decorators, wrappers and monkey patching." category = "dev" optional = false -python-versions = "*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [[package]] name = "xattr" -version = "0.9.7" +version = "0.9.9" description = "Python wrapper for extended filesystem attributes" category = "main" optional = false python-versions = "*" [package.dependencies] -cffi = ">=1.0.0" +cffi = ">=1.0" [[package]] name = "xlrd" @@ -1019,31 +1204,31 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "xlsxwriter" -version = "1.4.0" +version = "3.0.2" description = "A Python module for creating Excel XLSX files." category = "main" optional = true -python-versions = "*" +python-versions = ">=3.4" [[package]] name = "zipp" -version = "3.4.1" +version = "3.6.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [extras] textract = ["textract"] [metadata] lock-version = "1.1" -python-versions = "^3.6" -content-hash = "d1e76e5a1ba4b03a7cb81610e1e3b8f4e28202d088a53295a0fe12d5c0ade8e5" +python-versions = "^3.6.2" +content-hash = "12f40637fa7103f22c21d9356b01f257098340f285de63f28393a12c562a93d5" [metadata.files] alabaster = [ @@ -1059,87 +1244,138 @@ appnope = [ {file = "appnope-0.1.2.tar.gz", hash = "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"}, ] argcomplete = [ - {file = "argcomplete-1.10.0-py2.py3-none-any.whl", hash = "sha256:2f2052ea5156eb5cc7edce9c0ddc937e30c49c1097d51b24f34350a08632a264"}, - {file = "argcomplete-1.10.0.tar.gz", hash = "sha256:45836de8cc63d2f6e06b898cef1e4ce1e9907d246ec77ac8e64f23f153d6bec1"}, + {file = "argcomplete-1.10.3-py2.py3-none-any.whl", hash = "sha256:d8ea63ebaec7f59e56e7b2a386b1d1c7f1a7ae87902c9ee17d377eaa557f06fa"}, + {file = "argcomplete-1.10.3.tar.gz", hash = "sha256:a37f522cf3b6a34abddfedb61c4546f60023b3799b22d1cd971eacdc0861530a"}, ] astroid = [ - {file = "astroid-2.5.6-py3-none-any.whl", hash = "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e"}, - {file = "astroid-2.5.6.tar.gz", hash = "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975"}, + {file = "astroid-2.9.3-py3-none-any.whl", hash = "sha256:506daabe5edffb7e696ad82483ad0228245a9742ed7d2d8c9cdb31537decf9f6"}, + {file = "astroid-2.9.3.tar.gz", hash = "sha256:1efdf4e867d4d8ba4a9f6cf9ce07cd182c4c41de77f23814feb27ca93ca9d877"}, ] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, - {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] babel = [ - {file = "Babel-2.9.0-py2.py3-none-any.whl", hash = "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5"}, - {file = "Babel-2.9.0.tar.gz", hash = "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05"}, + {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, + {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] backcall = [ {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, ] +"backports.zoneinfo" = [ + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"}, + {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"}, + {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"}, + {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"}, + {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"}, +] beautifulsoup4 = [ - {file = "beautifulsoup4-4.8.0-py2-none-any.whl", hash = "sha256:05668158c7b85b791c5abde53e50265e16f98ad601c402ba44d70f96c4159612"}, - {file = "beautifulsoup4-4.8.0-py3-none-any.whl", hash = "sha256:f040590be10520f2ea4c2ae8c3dae441c7cfff5308ec9d58a0ec0c1b8f81d469"}, - {file = "beautifulsoup4-4.8.0.tar.gz", hash = "sha256:25288c9e176f354bf277c0a10aa96c782a6a18a17122dba2e8cec4a97e03343b"}, + {file = "beautifulsoup4-4.8.2-py2-none-any.whl", hash = "sha256:e1505eeed31b0f4ce2dbb3bc8eb256c04cc2b3b72af7d551a4ab6efd5cbe5dae"}, + {file = "beautifulsoup4-4.8.2-py3-none-any.whl", hash = "sha256:9fbb4d6e48ecd30bcacc5b63b94088192dcda178513b2ae3c394229f8911b887"}, + {file = "beautifulsoup4-4.8.2.tar.gz", hash = "sha256:05fd825eb01c290877657a56df4c6e4c311b3965bda790c613a3d6fb01a5462a"}, ] certifi = [ - {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, - {file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, ] cffi = [ - {file = "cffi-1.14.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bb89f306e5da99f4d922728ddcd6f7fcebb3241fc40edebcb7284d7514741991"}, - {file = "cffi-1.14.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:34eff4b97f3d982fb93e2831e6750127d1355a923ebaeeb565407b3d2f8d41a1"}, - {file = "cffi-1.14.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99cd03ae7988a93dd00bcd9d0b75e1f6c426063d6f03d2f90b89e29b25b82dfa"}, - {file = "cffi-1.14.5-cp27-cp27m-win32.whl", hash = "sha256:65fa59693c62cf06e45ddbb822165394a288edce9e276647f0046e1ec26920f3"}, - {file = "cffi-1.14.5-cp27-cp27m-win_amd64.whl", hash = "sha256:51182f8927c5af975fece87b1b369f722c570fe169f9880764b1ee3bca8347b5"}, - {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:43e0b9d9e2c9e5d152946b9c5fe062c151614b262fda2e7b201204de0b99e482"}, - {file = "cffi-1.14.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cbde590d4faaa07c72bf979734738f328d239913ba3e043b1e98fe9a39f8b2b6"}, - {file = "cffi-1.14.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:5de7970188bb46b7bf9858eb6890aad302577a5f6f75091fd7cdd3ef13ef3045"}, - {file = "cffi-1.14.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a465da611f6fa124963b91bf432d960a555563efe4ed1cc403ba5077b15370aa"}, - {file = "cffi-1.14.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:d42b11d692e11b6634f7613ad8df5d6d5f8875f5d48939520d351007b3c13406"}, - {file = "cffi-1.14.5-cp35-cp35m-win32.whl", hash = "sha256:72d8d3ef52c208ee1c7b2e341f7d71c6fd3157138abf1a95166e6165dd5d4369"}, - {file = "cffi-1.14.5-cp35-cp35m-win_amd64.whl", hash = "sha256:29314480e958fd8aab22e4a58b355b629c59bf5f2ac2492b61e3dc06d8c7a315"}, - {file = "cffi-1.14.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:3d3dd4c9e559eb172ecf00a2a7517e97d1e96de2a5e610bd9b68cea3925b4892"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5"}, - {file = "cffi-1.14.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132"}, - {file = "cffi-1.14.5-cp36-cp36m-win32.whl", hash = "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53"}, - {file = "cffi-1.14.5-cp36-cp36m-win_amd64.whl", hash = "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813"}, - {file = "cffi-1.14.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1"}, - {file = "cffi-1.14.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49"}, - {file = "cffi-1.14.5-cp37-cp37m-win32.whl", hash = "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62"}, - {file = "cffi-1.14.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4"}, - {file = "cffi-1.14.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e"}, - {file = "cffi-1.14.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827"}, - {file = "cffi-1.14.5-cp38-cp38-win32.whl", hash = "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e"}, - {file = "cffi-1.14.5-cp38-cp38-win_amd64.whl", hash = "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396"}, - {file = "cffi-1.14.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c"}, - {file = "cffi-1.14.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee"}, - {file = "cffi-1.14.5-cp39-cp39-win32.whl", hash = "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396"}, - {file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"}, - {file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"}, + {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, + {file = "cffi-1.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:41d45de54cd277a7878919867c0f08b0cf817605e4eb94093e7516505d3c8d14"}, + {file = "cffi-1.15.0-cp27-cp27m-win32.whl", hash = "sha256:4a306fa632e8f0928956a41fa8e1d6243c71e7eb59ffbd165fc0b41e316b2474"}, + {file = "cffi-1.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:e7022a66d9b55e93e1a845d8c9eba2a1bebd4966cd8bfc25d9cd07d515b33fa6"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:14cd121ea63ecdae71efa69c15c5543a4b5fbcd0bbe2aad864baca0063cecf27"}, + {file = "cffi-1.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d4d692a89c5cf08a8557fdeb329b82e7bf609aadfaed6c0d79f5a449a3c7c023"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0104fb5ae2391d46a4cb082abdd5c69ea4eab79d8d44eaaf79f1b1fd806ee4c2"}, + {file = "cffi-1.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91ec59c33514b7c7559a6acda53bbfe1b283949c34fe7440bcf917f96ac0723e"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f5c7150ad32ba43a07c4479f40241756145a1f03b43480e058cfd862bf5041c7"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:00c878c90cb53ccfaae6b8bc18ad05d2036553e6d9d1d9dbcf323bbe83854ca3"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb9a20a72ac4e0fdb50dae135ba5e77880518e742077ced47eb1499e29a443c"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a5263e363c27b653a90078143adb3d076c1a748ec9ecc78ea2fb916f9b861962"}, + {file = "cffi-1.15.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f54a64f8b0c8ff0b64d18aa76675262e1700f3995182267998c31ae974fbc382"}, + {file = "cffi-1.15.0-cp310-cp310-win32.whl", hash = "sha256:c21c9e3896c23007803a875460fb786118f0cdd4434359577ea25eb556e34c55"}, + {file = "cffi-1.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e069f72d497312b24fcc02073d70cb989045d1c91cbd53979366077959933e0"}, + {file = "cffi-1.15.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:64d4ec9f448dfe041705426000cc13e34e6e5bb13736e9fd62e34a0b0c41566e"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2756c88cbb94231c7a147402476be2c4df2f6078099a6f4a480d239a8817ae39"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b96a311ac60a3f6be21d2572e46ce67f09abcf4d09344c49274eb9e0bf345fc"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e4024375654472cc27e91cbe9eaa08567f7fbdf822638be2814ce059f58032"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:59888172256cac5629e60e72e86598027aca6bf01fa2465bdb676d37636573e8"}, + {file = "cffi-1.15.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:27c219baf94952ae9d50ec19651a687b826792055353d07648a5695413e0c605"}, + {file = "cffi-1.15.0-cp36-cp36m-win32.whl", hash = "sha256:4958391dbd6249d7ad855b9ca88fae690783a6be9e86df65865058ed81fc860e"}, + {file = "cffi-1.15.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f6f824dc3bce0edab5f427efcfb1d63ee75b6fcb7282900ccaf925be84efb0fc"}, + {file = "cffi-1.15.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06c48159c1abed75c2e721b1715c379fa3200c7784271b3c46df01383b593636"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c2051981a968d7de9dd2d7b87bcb9c939c74a34626a6e2f8181455dd49ed69e4"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:fd8a250edc26254fe5b33be00402e6d287f562b6a5b2152dec302fa15bb3e997"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91d77d2a782be4274da750752bb1650a97bfd8f291022b379bb8e01c66b4e96b"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45db3a33139e9c8f7c09234b5784a5e33d31fd6907800b316decad50af323ff2"}, + {file = "cffi-1.15.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:263cc3d821c4ab2213cbe8cd8b355a7f72a8324577dc865ef98487c1aeee2bc7"}, + {file = "cffi-1.15.0-cp37-cp37m-win32.whl", hash = "sha256:17771976e82e9f94976180f76468546834d22a7cc404b17c22df2a2c81db0c66"}, + {file = "cffi-1.15.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3415c89f9204ee60cd09b235810be700e993e343a408693e80ce7f6a40108029"}, + {file = "cffi-1.15.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4238e6dab5d6a8ba812de994bbb0a79bddbdf80994e4ce802b6f6f3142fcc880"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0808014eb713677ec1292301ea4c81ad277b6cdf2fdd90fd540af98c0b101d20"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:57e9ac9ccc3101fac9d6014fba037473e4358ef4e89f8e181f8951a2c0162024"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b6c2ea03845c9f501ed1313e78de148cd3f6cad741a75d43a29b43da27f2e1e"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10dffb601ccfb65262a27233ac273d552ddc4d8ae1bf93b21c94b8511bffe728"}, + {file = "cffi-1.15.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:786902fb9ba7433aae840e0ed609f45c7bcd4e225ebb9c753aa39725bb3e6ad6"}, + {file = "cffi-1.15.0-cp38-cp38-win32.whl", hash = "sha256:da5db4e883f1ce37f55c667e5c0de439df76ac4cb55964655906306918e7363c"}, + {file = "cffi-1.15.0-cp38-cp38-win_amd64.whl", hash = "sha256:181dee03b1170ff1969489acf1c26533710231c58f95534e3edac87fff06c443"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:45e8636704eacc432a206ac7345a5d3d2c62d95a507ec70d62f23cd91770482a"}, + {file = "cffi-1.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:31fb708d9d7c3f49a60f04cf5b119aeefe5644daba1cd2a0fe389b674fd1de37"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6dc2737a3674b3e344847c8686cf29e500584ccad76204efea14f451d4cc669a"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74fdfdbfdc48d3f47148976f49fab3251e550a8720bebc99bf1483f5bfb5db3e"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffaa5c925128e29efbde7301d8ecaf35c8c60ffbcd6a1ffd3a552177c8e5e796"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f7d084648d77af029acb79a0ff49a0ad7e9d09057a9bf46596dac9514dc07df"}, + {file = "cffi-1.15.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef1f279350da2c586a69d32fc8733092fd32cc8ac95139a00377841f59a3f8d8"}, + {file = "cffi-1.15.0-cp39-cp39-win32.whl", hash = "sha256:2a23af14f408d53d5e6cd4e3d9a24ff9e05906ad574822a10563efcef137979a"}, + {file = "cffi-1.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:3773c4d81e6e818df2efbc7dd77325ca0dcb688116050fb2b3011218eda36139"}, + {file = "cffi-1.15.0.tar.gz", hash = "sha256:920f0d66a896c2d99f0adbb391f990a84091179542c205fa53ce5787aff87954"}, ] chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, ] +charset-normalizer = [ + {file = "charset-normalizer-2.0.10.tar.gz", hash = "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd"}, + {file = "charset_normalizer-2.0.10-py3-none-any.whl", hash = "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"}, +] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, +] +commonmark = [ + {file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"}, + {file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"}, +] +compressed-rtf = [ + {file = "compressed_rtf-1.0.6.tar.gz", hash = "sha256:c1c827f1d124d24608981a56e8b8691eb1f2a69a78ccad6440e7d92fde1781dd"}, +] +contextlib2 = [ + {file = "contextlib2-21.6.0-py2.py3-none-any.whl", hash = "sha256:3fbdb64466afd23abaf6c977627b75b6139a5a3e8ce38405c5b413aed7a0471f"}, + {file = "contextlib2-21.6.0.tar.gz", hash = "sha256:ab1e2bfe1d01d968e1b7e8d9023bc51ef3509bba217bb730cee3827e1ee82869"}, +] +dataclasses = [ + {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, + {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, ] decorator = [ - {file = "decorator-5.0.7-py3-none-any.whl", hash = "sha256:945d84890bb20cc4a2f4a31fc4311c0c473af65ea318617f13a7257c9a58bc98"}, - {file = "decorator-5.0.7.tar.gz", hash = "sha256:6f201a6c4dac3d187352661f508b9364ec8091217442c9478f1f83c003a0f060"}, + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, ] docopt = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, @@ -1151,6 +1387,9 @@ docutils = [ docx2txt = [ {file = "docx2txt-0.8.tar.gz", hash = "sha256:2c06d98d7cfe2d3947e5760a57d924e3ff07745b379c8737723922e7009236e5"}, ] +ebcdic = [ + {file = "ebcdic-1.1.1-py2.py3-none-any.whl", hash = "sha256:33b4cb729bc2d0bf46cc1847b0e5946897cb8d3f53520c5b9aa5fa98d7e735f1"}, +] ebooklib = [ {file = "EbookLib-0.17.1.tar.gz", hash = "sha256:fe23e22c28050196c68db3e7b13b257bf39426d927cb395c6f2cc13ac11327f1"}, ] @@ -1159,164 +1398,247 @@ exifread = [ {file = "ExifRead-2.3.2.tar.gz", hash = "sha256:a0f74af5040168d3883bbc980efe26d06c89f026dc86ba28eb34107662d51766"}, ] extract-msg = [ - {file = "extract_msg-0.23.1-py2.py3-none-any.whl", hash = "sha256:0e733743d4b5b7ca62265d1477d4b99f03e44f3202fa53ee97d54f5b2c75b1b3"}, - {file = "extract_msg-0.23.1.tar.gz", hash = "sha256:3746d5f68266740575ef9097516f39c5f601fa031e188cea338a13b66de16ada"}, + {file = "extract_msg-0.29.0-py2.py3-none-any.whl", hash = "sha256:a8885dc385d0c88c4b87fb2a573727c0115cd2ef5157956cf183878f940eef28"}, + {file = "extract_msg-0.29.0.tar.gz", hash = "sha256:ae6ce5f78fddb582350cb49bbf2776eadecdbf3c74b7a305dced42bd187a5401"}, ] flake8 = [ - {file = "flake8-3.9.1-py2.py3-none-any.whl", hash = "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"}, - {file = "flake8-3.9.1.tar.gz", hash = "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378"}, + {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, + {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, +] +fs = [ + {file = "fs-2.4.14-py2.py3-none-any.whl", hash = "sha256:b298013377f51125b3d7f0c86920de4e3e2d4a83731bd5caf1f1e5bddabe7798"}, + {file = "fs-2.4.14.tar.gz", hash = "sha256:9555dc2bc58c58cac03478ac7e9f622d29fe2d20a4384c24c90ab50de2c7b36c"}, ] idna = [ - {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, - {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, ] imagesize = [ - {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, - {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, + {file = "imagesize-1.3.0-py2.py3-none-any.whl", hash = "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c"}, + {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, ] imapclient = [ {file = "IMAPClient-2.1.0-py2.py3-none-any.whl", hash = "sha256:3eeb97b9aa8faab0caa5024d74bfde59408fbd542781246f6960873c7bf0dd01"}, {file = "IMAPClient-2.1.0.zip", hash = "sha256:60ba79758cc9f13ec910d7a3df9acaaf2bb6c458720d9a02ec33a41352fd1b99"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.0.1-py3-none-any.whl", hash = "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d"}, - {file = "importlib_metadata-4.0.1.tar.gz", hash = "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581"}, + {file = "importlib_metadata-4.8.3-py3-none-any.whl", hash = "sha256:65a9576a5b2d58ca44d133c42a241905cc45e34d2c06fd5ba2bafa221e5d7b5e"}, + {file = "importlib_metadata-4.8.3.tar.gz", hash = "sha256:766abffff765960fcc18003801f7044eb6755ffae4521c8e8ce8e83b9c9b0668"}, +] +importlib-resources = [ + {file = "importlib_resources-5.4.0-py3-none-any.whl", hash = "sha256:33a95faed5fc19b4bc16b29a6eeae248a3fe69dd55d4d229d2b480e23eeaad45"}, + {file = "importlib_resources-5.4.0.tar.gz", hash = "sha256:d756e2f85dd4de2ba89be0b21dba2a3bbec2e871a42a3a16719258a11f87506b"}, ] ipdb = [ {file = "ipdb-0.12.3.tar.gz", hash = "sha256:5d9a4a0e3b7027a158fc6f2929934341045b9c3b0b86ed5d7e84e409653f72fd"}, ] ipython = [ - {file = "ipython-7.16.1-py3-none-any.whl", hash = "sha256:2dbcc8c27ca7d3cfe4fcdff7f45b27f9a8d3edfa70ff8024a71c7a8eb5f09d64"}, - {file = "ipython-7.16.1.tar.gz", hash = "sha256:9f4fcb31d3b2c533333893b9172264e4821c1ac91839500f31bd43f2c59b3ccf"}, + {file = "ipython-7.16.3-py3-none-any.whl", hash = "sha256:c0427ed8bc33ac481faf9d3acf7e84e0010cdaada945e0badd1e2e74cc075833"}, + {file = "ipython-7.16.3.tar.gz", hash = "sha256:5ac47dc9af66fc2f5530c12069390877ae372ac905edca75a92a6e363b5d7caa"}, ] ipython-genutils = [ {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"}, ] isort = [ - {file = "isort-5.8.0-py3-none-any.whl", hash = "sha256:2bb1680aad211e3c9944dbce1d4ba09a989f04e238296c87fe2139faa26d655d"}, - {file = "isort-5.8.0.tar.gz", hash = "sha256:0a943902919f65c5684ac4e0154b1ad4fac6dcaa5d9f3426b732f1c8b5419be6"}, + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, ] jedi = [ - {file = "jedi-0.18.0-py2.py3-none-any.whl", hash = "sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93"}, - {file = "jedi-0.18.0.tar.gz", hash = "sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707"}, + {file = "jedi-0.17.2-py2.py3-none-any.whl", hash = "sha256:98cc583fa0f2f8304968199b01b6b4b94f469a1f4a74c1560506ca2a211378b5"}, + {file = "jedi-0.17.2.tar.gz", hash = "sha256:86ed7d9b750603e4ba582ea8edc678657fb4007894a12bcf6f4bb97892f31d20"}, ] jinja2 = [ - {file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"}, - {file = "Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"}, + {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, + {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, ] lazy-object-proxy = [ - {file = "lazy-object-proxy-1.6.0.tar.gz", hash = "sha256:489000d368377571c6f982fba6497f2aa13c6d1facc40660963da62f5c379726"}, - {file = "lazy_object_proxy-1.6.0-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:c6938967f8528b3668622a9ed3b31d145fab161a32f5891ea7b84f6b790be05b"}, - {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win32.whl", hash = "sha256:ebfd274dcd5133e0afae738e6d9da4323c3eb021b3e13052d8cbd0e457b1256e"}, - {file = "lazy_object_proxy-1.6.0-cp27-cp27m-win_amd64.whl", hash = "sha256:ed361bb83436f117f9917d282a456f9e5009ea12fd6de8742d1a4752c3017e93"}, - {file = "lazy_object_proxy-1.6.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d900d949b707778696fdf01036f58c9876a0d8bfe116e8d220cfd4b15f14e741"}, - {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:5743a5ab42ae40caa8421b320ebf3a998f89c85cdc8376d6b2e00bd12bd1b587"}, - {file = "lazy_object_proxy-1.6.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:bf34e368e8dd976423396555078def5cfc3039ebc6fc06d1ae2c5a65eebbcde4"}, - {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win32.whl", hash = "sha256:b579f8acbf2bdd9ea200b1d5dea36abd93cabf56cf626ab9c744a432e15c815f"}, - {file = "lazy_object_proxy-1.6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:4f60460e9f1eb632584c9685bccea152f4ac2130e299784dbaf9fae9f49891b3"}, - {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d7124f52f3bd259f510651450e18e0fd081ed82f3c08541dffc7b94b883aa981"}, - {file = "lazy_object_proxy-1.6.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:22ddd618cefe54305df49e4c069fa65715be4ad0e78e8d252a33debf00f6ede2"}, - {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win32.whl", hash = "sha256:9d397bf41caad3f489e10774667310d73cb9c4258e9aed94b9ec734b34b495fd"}, - {file = "lazy_object_proxy-1.6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:24a5045889cc2729033b3e604d496c2b6f588c754f7a62027ad4437a7ecc4837"}, - {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:17e0967ba374fc24141738c69736da90e94419338fd4c7c7bef01ee26b339653"}, - {file = "lazy_object_proxy-1.6.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:410283732af311b51b837894fa2f24f2c0039aa7f220135192b38fcc42bd43d3"}, - {file = "lazy_object_proxy-1.6.0-cp38-cp38-win32.whl", hash = "sha256:85fb7608121fd5621cc4377a8961d0b32ccf84a7285b4f1d21988b2eae2868e8"}, - {file = "lazy_object_proxy-1.6.0-cp38-cp38-win_amd64.whl", hash = "sha256:d1c2676e3d840852a2de7c7d5d76407c772927addff8d742b9808fe0afccebdf"}, - {file = "lazy_object_proxy-1.6.0-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:b865b01a2e7f96db0c5d12cfea590f98d8c5ba64ad222300d93ce6ff9138bcad"}, - {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4732c765372bd78a2d6b2150a6e99d00a78ec963375f236979c0626b97ed8e43"}, - {file = "lazy_object_proxy-1.6.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:9698110e36e2df951c7c36b6729e96429c9c32b3331989ef19976592c5f3c77a"}, - {file = "lazy_object_proxy-1.6.0-cp39-cp39-win32.whl", hash = "sha256:1fee665d2638491f4d6e55bd483e15ef21f6c8c2095f235fef72601021e64f61"}, - {file = "lazy_object_proxy-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:f5144c75445ae3ca2057faac03fda5a902eff196702b0a24daf1d6ce0650514b"}, + {file = "lazy-object-proxy-1.7.1.tar.gz", hash = "sha256:d609c75b986def706743cdebe5e47553f4a5a1da9c5ff66d76013ef396b5a8a4"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb8c5fd1684d60a9902c60ebe276da1f2281a318ca16c1d0a96db28f62e9166b"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a57d51ed2997e97f3b8e3500c984db50a554bb5db56c50b5dab1b41339b37e36"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd45683c3caddf83abbb1249b653a266e7069a09f486daa8863fb0e7496a9fdb"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8561da8b3dd22d696244d6d0d5330618c993a215070f473b699e00cf1f3f6443"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fccdf7c2c5821a8cbd0a9440a456f5050492f2270bd54e94360cac663398739b"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-win32.whl", hash = "sha256:898322f8d078f2654d275124a8dd19b079080ae977033b713f677afcfc88e2b9"}, + {file = "lazy_object_proxy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:85b232e791f2229a4f55840ed54706110c80c0a210d076eee093f2b2e33e1bfd"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:46ff647e76f106bb444b4533bb4153c7370cdf52efc62ccfc1a28bdb3cc95442"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12f3bb77efe1367b2515f8cb4790a11cffae889148ad33adad07b9b55e0ab22c"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c19814163728941bb871240d45c4c30d33b8a2e85972c44d4e63dd7107faba44"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:e40f2013d96d30217a51eeb1db28c9ac41e9d0ee915ef9d00da639c5b63f01a1"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:2052837718516a94940867e16b1bb10edb069ab475c3ad84fd1e1a6dd2c0fcfc"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-win32.whl", hash = "sha256:6a24357267aa976abab660b1d47a34aaf07259a0c3859a34e536f1ee6e76b5bb"}, + {file = "lazy_object_proxy-1.7.1-cp36-cp36m-win_amd64.whl", hash = "sha256:6aff3fe5de0831867092e017cf67e2750c6a1c7d88d84d2481bd84a2e019ec35"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6a6e94c7b02641d1311228a102607ecd576f70734dc3d5e22610111aeacba8a0"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ce15276a1a14549d7e81c243b887293904ad2d94ad767f42df91e75fd7b5b6"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e368b7f7eac182a59ff1f81d5f3802161932a41dc1b1cc45c1f757dc876b5d2c"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6ecbb350991d6434e1388bee761ece3260e5228952b1f0c46ffc800eb313ff42"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:553b0f0d8dbf21890dd66edd771f9b1b5f51bd912fa5f26de4449bfc5af5e029"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-win32.whl", hash = "sha256:c7a683c37a8a24f6428c28c561c80d5f4fd316ddcf0c7cab999b15ab3f5c5c69"}, + {file = "lazy_object_proxy-1.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:df2631f9d67259dc9620d831384ed7732a198eb434eadf69aea95ad18c587a28"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07fa44286cda977bd4803b656ffc1c9b7e3bc7dff7d34263446aec8f8c96f88a"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dca6244e4121c74cc20542c2ca39e5c4a5027c81d112bfb893cf0790f96f57e"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91ba172fc5b03978764d1df5144b4ba4ab13290d7bab7a50f12d8117f8630c38"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:043651b6cb706eee4f91854da4a089816a6606c1428fd391573ef8cb642ae4f7"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b9e89b87c707dd769c4ea91f7a31538888aad05c116a59820f28d59b3ebfe25a"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-win32.whl", hash = "sha256:9d166602b525bf54ac994cf833c385bfcc341b364e3ee71e3bf5a1336e677b55"}, + {file = "lazy_object_proxy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:8f3953eb575b45480db6568306893f0bd9d8dfeeebd46812aa09ca9579595148"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dd7ed7429dbb6c494aa9bc4e09d94b778a3579be699f9d67da7e6804c422d3de"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70ed0c2b380eb6248abdef3cd425fc52f0abd92d2b07ce26359fcbc399f636ad"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7096a5e0c1115ec82641afbdd70451a144558ea5cf564a896294e346eb611be1"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f769457a639403073968d118bc70110e7dce294688009f5c24ab78800ae56dc8"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:39b0e26725c5023757fc1ab2a89ef9d7ab23b84f9251e28f9cc114d5b59c1b09"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-win32.whl", hash = "sha256:2130db8ed69a48a3440103d4a520b89d8a9405f1b06e2cc81640509e8bf6548f"}, + {file = "lazy_object_proxy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:677ea950bef409b47e51e733283544ac3d660b709cfce7b187f5ace137960d61"}, + {file = "lazy_object_proxy-1.7.1-pp37.pp38-none-any.whl", hash = "sha256:d66906d5785da8e0be7360912e99c9188b70f52c422f9fc18223347235691a84"}, ] lxml = [ - {file = "lxml-4.6.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:df7c53783a46febb0e70f6b05df2ba104610f2fb0d27023409734a3ecbb78fb2"}, - {file = "lxml-4.6.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1b7584d421d254ab86d4f0b13ec662a9014397678a7c4265a02a6d7c2b18a75f"}, - {file = "lxml-4.6.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:079f3ae844f38982d156efce585bc540c16a926d4436712cf4baee0cce487a3d"}, - {file = "lxml-4.6.3-cp27-cp27m-win32.whl", hash = "sha256:bc4313cbeb0e7a416a488d72f9680fffffc645f8a838bd2193809881c67dd106"}, - {file = "lxml-4.6.3-cp27-cp27m-win_amd64.whl", hash = "sha256:8157dadbb09a34a6bd95a50690595e1fa0af1a99445e2744110e3dca7831c4ee"}, - {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7728e05c35412ba36d3e9795ae8995e3c86958179c9770e65558ec3fdfd3724f"}, - {file = "lxml-4.6.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4bff24dfeea62f2e56f5bab929b4428ae6caba2d1eea0c2d6eb618e30a71e6d4"}, - {file = "lxml-4.6.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:74f7d8d439b18fa4c385f3f5dfd11144bb87c1da034a466c5b5577d23a1d9b51"}, - {file = "lxml-4.6.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f90ba11136bfdd25cae3951af8da2e95121c9b9b93727b1b896e3fa105b2f586"}, - {file = "lxml-4.6.3-cp35-cp35m-win32.whl", hash = "sha256:f2380a6376dfa090227b663f9678150ef27543483055cc327555fb592c5967e2"}, - {file = "lxml-4.6.3-cp35-cp35m-win_amd64.whl", hash = "sha256:c4f05c5a7c49d2fb70223d0d5bcfbe474cf928310ac9fa6a7c6dddc831d0b1d4"}, - {file = "lxml-4.6.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d2e35d7bf1c1ac8c538f88d26b396e73dd81440d59c1ef8522e1ea77b345ede4"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:289e9ca1a9287f08daaf796d96e06cb2bc2958891d7911ac7cae1c5f9e1e0ee3"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bccbfc27563652de7dc9bdc595cb25e90b59c5f8e23e806ed0fd623755b6565d"}, - {file = "lxml-4.6.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:820628b7b3135403540202e60551e741f9b6d3304371712521be939470b454ec"}, - {file = "lxml-4.6.3-cp36-cp36m-win32.whl", hash = "sha256:5a0a14e264069c03e46f926be0d8919f4105c1623d620e7ec0e612a2e9bf1c04"}, - {file = "lxml-4.6.3-cp36-cp36m-win_amd64.whl", hash = "sha256:92e821e43ad382332eade6812e298dc9701c75fe289f2a2d39c7960b43d1e92a"}, - {file = "lxml-4.6.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:efd7a09678fd8b53117f6bae4fa3825e0a22b03ef0a932e070c0bdbb3a35e654"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:efac139c3f0bf4f0939f9375af4b02c5ad83a622de52d6dfa8e438e8e01d0eb0"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0fbcf5565ac01dff87cbfc0ff323515c823081c5777a9fc7703ff58388c258c3"}, - {file = "lxml-4.6.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:122fba10466c7bd4178b07dba427aa516286b846b2cbd6f6169141917283aae2"}, - {file = "lxml-4.6.3-cp37-cp37m-win32.whl", hash = "sha256:3439c71103ef0e904ea0a1901611863e51f50b5cd5e8654a151740fde5e1cade"}, - {file = "lxml-4.6.3-cp37-cp37m-win_amd64.whl", hash = "sha256:4289728b5e2000a4ad4ab8da6e1db2e093c63c08bdc0414799ee776a3f78da4b"}, - {file = "lxml-4.6.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b007cbb845b28db4fb8b6a5cdcbf65bacb16a8bd328b53cbc0698688a68e1caa"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:76fa7b1362d19f8fbd3e75fe2fb7c79359b0af8747e6f7141c338f0bee2f871a"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:26e761ab5b07adf5f555ee82fb4bfc35bf93750499c6c7614bd64d12aaa67927"}, - {file = "lxml-4.6.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:66e575c62792c3f9ca47cb8b6fab9e35bab91360c783d1606f758761810c9791"}, - {file = "lxml-4.6.3-cp38-cp38-win32.whl", hash = "sha256:89b8b22a5ff72d89d48d0e62abb14340d9e99fd637d046c27b8b257a01ffbe28"}, - {file = "lxml-4.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:2a9d50e69aac3ebee695424f7dbd7b8c6d6eb7de2a2eb6b0f6c7db6aa41e02b7"}, - {file = "lxml-4.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ce256aaa50f6cc9a649c51be3cd4ff142d67295bfc4f490c9134d0f9f6d58ef0"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:7610b8c31688f0b1be0ef882889817939490a36d0ee880ea562a4e1399c447a1"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f8380c03e45cf09f8557bdaa41e1fa7c81f3ae22828e1db470ab2a6c96d8bc23"}, - {file = "lxml-4.6.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:884ab9b29feaca361f7f88d811b1eea9bfca36cf3da27768d28ad45c3ee6f969"}, - {file = "lxml-4.6.3-cp39-cp39-win32.whl", hash = "sha256:33bb934a044cf32157c12bfcfbb6649807da20aa92c062ef51903415c704704f"}, - {file = "lxml-4.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:542d454665a3e277f76954418124d67516c5f88e51a900365ed54a9806122b83"}, - {file = "lxml-4.6.3.tar.gz", hash = "sha256:39b78571b3b30645ac77b95f7c69d1bffc4cf8c3b157c435a34da72e78c82468"}, + {file = "lxml-4.7.1-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:d546431636edb1d6a608b348dd58cc9841b81f4116745857b6cb9f8dadb2725f"}, + {file = "lxml-4.7.1-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6308062534323f0d3edb4e702a0e26a76ca9e0e23ff99be5d82750772df32a9e"}, + {file = "lxml-4.7.1-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f76dbe44e31abf516114f6347a46fa4e7c2e8bceaa4b6f7ee3a0a03c8eba3c17"}, + {file = "lxml-4.7.1-cp27-cp27m-win32.whl", hash = "sha256:d5618d49de6ba63fe4510bdada62d06a8acfca0b4b5c904956c777d28382b419"}, + {file = "lxml-4.7.1-cp27-cp27m-win_amd64.whl", hash = "sha256:9393a05b126a7e187f3e38758255e0edf948a65b22c377414002d488221fdaa2"}, + {file = "lxml-4.7.1-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:50d3dba341f1e583265c1a808e897b4159208d814ab07530202b6036a4d86da5"}, + {file = "lxml-4.7.1-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:44f552e0da3c8ee3c28e2eb82b0b784200631687fc6a71277ea8ab0828780e7d"}, + {file = "lxml-4.7.1-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:e662c6266e3a275bdcb6bb049edc7cd77d0b0f7e119a53101d367c841afc66dc"}, + {file = "lxml-4.7.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:4c093c571bc3da9ebcd484e001ba18b8452903cd428c0bc926d9b0141bcb710e"}, + {file = "lxml-4.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:3e26ad9bc48d610bf6cc76c506b9e5ad9360ed7a945d9be3b5b2c8535a0145e3"}, + {file = "lxml-4.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a5f623aeaa24f71fce3177d7fee875371345eb9102b355b882243e33e04b7175"}, + {file = "lxml-4.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7b5e2acefd33c259c4a2e157119c4373c8773cf6793e225006a1649672ab47a6"}, + {file = "lxml-4.7.1-cp310-cp310-win32.whl", hash = "sha256:67fa5f028e8a01e1d7944a9fb616d1d0510d5d38b0c41708310bd1bc45ae89f6"}, + {file = "lxml-4.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:b1d381f58fcc3e63fcc0ea4f0a38335163883267f77e4c6e22d7a30877218a0e"}, + {file = "lxml-4.7.1-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:38d9759733aa04fb1697d717bfabbedb21398046bd07734be7cccc3d19ea8675"}, + {file = "lxml-4.7.1-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:dfd0d464f3d86a1460683cd742306d1138b4e99b79094f4e07e1ca85ee267fe7"}, + {file = "lxml-4.7.1-cp35-cp35m-win32.whl", hash = "sha256:534e946bce61fd162af02bad7bfd2daec1521b71d27238869c23a672146c34a5"}, + {file = "lxml-4.7.1-cp35-cp35m-win_amd64.whl", hash = "sha256:6ec829058785d028f467be70cd195cd0aaf1a763e4d09822584ede8c9eaa4b03"}, + {file = "lxml-4.7.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:ade74f5e3a0fd17df5782896ddca7ddb998845a5f7cd4b0be771e1ffc3b9aa5b"}, + {file = "lxml-4.7.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:41358bfd24425c1673f184d7c26c6ae91943fe51dfecc3603b5e08187b4bcc55"}, + {file = "lxml-4.7.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6e56521538f19c4a6690f439fefed551f0b296bd785adc67c1777c348beb943d"}, + {file = "lxml-4.7.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5b0f782f0e03555c55e37d93d7a57454efe7495dab33ba0ccd2dbe25fc50f05d"}, + {file = "lxml-4.7.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:490712b91c65988012e866c411a40cc65b595929ececf75eeb4c79fcc3bc80a6"}, + {file = "lxml-4.7.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:34c22eb8c819d59cec4444d9eebe2e38b95d3dcdafe08965853f8799fd71161d"}, + {file = "lxml-4.7.1-cp36-cp36m-win32.whl", hash = "sha256:2a906c3890da6a63224d551c2967413b8790a6357a80bf6b257c9a7978c2c42d"}, + {file = "lxml-4.7.1-cp36-cp36m-win_amd64.whl", hash = "sha256:36b16fecb10246e599f178dd74f313cbdc9f41c56e77d52100d1361eed24f51a"}, + {file = "lxml-4.7.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:a5edc58d631170de90e50adc2cc0248083541affef82f8cd93bea458e4d96db8"}, + {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:87c1b0496e8c87ec9db5383e30042357b4839b46c2d556abd49ec770ce2ad868"}, + {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:0a5f0e4747f31cff87d1eb32a6000bde1e603107f632ef4666be0dc065889c7a"}, + {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:bf6005708fc2e2c89a083f258b97709559a95f9a7a03e59f805dd23c93bc3986"}, + {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc15874816b9320581133ddc2096b644582ab870cf6a6ed63684433e7af4b0d3"}, + {file = "lxml-4.7.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:0b5e96e25e70917b28a5391c2ed3ffc6156513d3db0e1476c5253fcd50f7a944"}, + {file = "lxml-4.7.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ec9027d0beb785a35aa9951d14e06d48cfbf876d8ff67519403a2522b181943b"}, + {file = "lxml-4.7.1-cp37-cp37m-win32.whl", hash = "sha256:9fbc0dee7ff5f15c4428775e6fa3ed20003140560ffa22b88326669d53b3c0f4"}, + {file = "lxml-4.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:1104a8d47967a414a436007c52f533e933e5d52574cab407b1e49a4e9b5ddbd1"}, + {file = "lxml-4.7.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:fc9fb11b65e7bc49f7f75aaba1b700f7181d95d4e151cf2f24d51bfd14410b77"}, + {file = "lxml-4.7.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:317bd63870b4d875af3c1be1b19202de34c32623609ec803b81c99193a788c1e"}, + {file = "lxml-4.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:610807cea990fd545b1559466971649e69302c8a9472cefe1d6d48a1dee97440"}, + {file = "lxml-4.7.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:09b738360af8cb2da275998a8bf79517a71225b0de41ab47339c2beebfff025f"}, + {file = "lxml-4.7.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6a2ab9d089324d77bb81745b01f4aeffe4094306d939e92ba5e71e9a6b99b71e"}, + {file = "lxml-4.7.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:eed394099a7792834f0cb4a8f615319152b9d801444c1c9e1b1a2c36d2239f9e"}, + {file = "lxml-4.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:735e3b4ce9c0616e85f302f109bdc6e425ba1670a73f962c9f6b98a6d51b77c9"}, + {file = "lxml-4.7.1-cp38-cp38-win32.whl", hash = "sha256:772057fba283c095db8c8ecde4634717a35c47061d24f889468dc67190327bcd"}, + {file = "lxml-4.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:13dbb5c7e8f3b6a2cf6e10b0948cacb2f4c9eb05029fe31c60592d08ac63180d"}, + {file = "lxml-4.7.1-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:718d7208b9c2d86aaf0294d9381a6acb0158b5ff0f3515902751404e318e02c9"}, + {file = "lxml-4.7.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:5bee1b0cbfdb87686a7fb0e46f1d8bd34d52d6932c0723a86de1cc532b1aa489"}, + {file = "lxml-4.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:e410cf3a2272d0a85526d700782a2fa92c1e304fdcc519ba74ac80b8297adf36"}, + {file = "lxml-4.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:585ea241ee4961dc18a95e2f5581dbc26285fcf330e007459688096f76be8c42"}, + {file = "lxml-4.7.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a555e06566c6dc167fbcd0ad507ff05fd9328502aefc963cb0a0547cfe7f00db"}, + {file = "lxml-4.7.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:adaab25be351fff0d8a691c4f09153647804d09a87a4e4ea2c3f9fe9e8651851"}, + {file = "lxml-4.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:82d16a64236970cb93c8d63ad18c5b9f138a704331e4b916b2737ddfad14e0c4"}, + {file = "lxml-4.7.1-cp39-cp39-win32.whl", hash = "sha256:59e7da839a1238807226f7143c68a479dee09244d1b3cf8c134f2fce777d12d0"}, + {file = "lxml-4.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:a1bbc4efa99ed1310b5009ce7f3a1784698082ed2c1ef3895332f5df9b3b92c2"}, + {file = "lxml-4.7.1-pp37-pypy37_pp73-macosx_10_14_x86_64.whl", hash = "sha256:0607ff0988ad7e173e5ddf7bf55ee65534bd18a5461183c33e8e41a59e89edf4"}, + {file = "lxml-4.7.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:6c198bfc169419c09b85ab10cb0f572744e686f40d1e7f4ed09061284fc1303f"}, + {file = "lxml-4.7.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:a58d78653ae422df6837dd4ca0036610b8cb4962b5cfdbd337b7b24de9e5f98a"}, + {file = "lxml-4.7.1-pp38-pypy38_pp73-macosx_10_14_x86_64.whl", hash = "sha256:e18281a7d80d76b66a9f9e68a98cf7e1d153182772400d9a9ce855264d7d0ce7"}, + {file = "lxml-4.7.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:8e54945dd2eeb50925500957c7c579df3cd07c29db7810b83cf30495d79af267"}, + {file = "lxml-4.7.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:447d5009d6b5447b2f237395d0018901dcc673f7d9f82ba26c1b9f9c3b444b60"}, + {file = "lxml-4.7.1.tar.gz", hash = "sha256:a1613838aa6b89af4ba10a0f3a972836128801ed008078f8c1244e65958f1b24"}, ] macos-tags = [ {file = "macos-tags-1.5.1.tar.gz", hash = "sha256:f144c5bc05d01573966d8aca2483cb345b20b76a5b32e9967786e086a38712e7"}, {file = "macos_tags-1.5.1-py3-none-any.whl", hash = "sha256:56419233af32242b703dd35bcf38c9f198abd969faddbe986eb8aaa6d95349cf"}, ] markupsafe = [ - {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, - {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, + {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] mdfind-wrapper = [ - {file = "mdfind-wrapper-0.1.4.tar.gz", hash = "sha256:7b8f37e6e5037fea9722821f6d26c538abd1a08385a20820ab73158d70267653"}, - {file = "mdfind_wrapper-0.1.4-py3-none-any.whl", hash = "sha256:8100c30333a7c82fd3af897cf84f6cecaa300fd6bb2ec762884d4ca6affe7a3c"}, + {file = "mdfind-wrapper-0.1.5.tar.gz", hash = "sha256:c0dbd5bc99c6d1fb4678bfa1841a3380ccac61e9b43a26a8d658aa9cafe27441"}, + {file = "mdfind_wrapper-0.1.5-py3-none-any.whl", hash = "sha256:fd00e65684b47f2d286eb7394eb172f4766f2926d95eddff6eb948352f620cbc"}, ] more-itertools = [ - {file = "more-itertools-8.7.0.tar.gz", hash = "sha256:c5d6da9ca3ff65220c3bfd2a8db06d698f05d4d2b9be57e1deb2be5a45019713"}, - {file = "more_itertools-8.7.0-py3-none-any.whl", hash = "sha256:5652a9ac72209ed7df8d9c15daf4e1aa0e3d2ccd3c87f8265a0673cd9cbc9ced"}, + {file = "more-itertools-8.12.0.tar.gz", hash = "sha256:7dc6ad46f05f545f900dd59e8dfb4e84a4827b97b3cfecb175ea0c7d247f6064"}, + {file = "more_itertools-8.12.0-py3-none-any.whl", hash = "sha256:43e6dd9942dffd72661a2c4ef383ad7da1e6a3e968a927ad7a6083ab410a688b"}, ] mypy = [ {file = "mypy-0.812-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49"}, @@ -1350,17 +1672,16 @@ olefile = [ {file = "olefile-0.46.zip", hash = "sha256:133b031eaf8fd2c9399b78b8bc5b8fcbe4c31e85295749bb17a87cba8f3c3964"}, ] packaging = [ - {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, - {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] parso = [ - {file = "parso-0.8.2-py2.py3-none-any.whl", hash = "sha256:a8c4922db71e4fdb90e0d0bc6e50f9b273d3397925e5e60a717e719201778d22"}, - {file = "parso-0.8.2.tar.gz", hash = "sha256:12b83492c6239ce32ff5eed6d3639d6a536170723c6f3f1506869f1ace413398"}, + {file = "parso-0.7.1-py2.py3-none-any.whl", hash = "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea"}, + {file = "parso-0.7.1.tar.gz", hash = "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9"}, ] "pdfminer.six" = [ - {file = "pdfminer.six-20181108-py2-none-any.whl", hash = "sha256:d12653375fcc00615d76dbd48fc551a2d5ffd6f572c11660d417ccf91a600f9b"}, - {file = "pdfminer.six-20181108-py2.py3-none-any.whl", hash = "sha256:f04d029d1d3e58c87da51bdefef2e9a1dbf2d7b63f727dd2a3e36054f5ae96ea"}, - {file = "pdfminer.six-20181108.tar.gz", hash = "sha256:9cc58857cf0a360213008061d903282462abee55cdcc7e0b6e08d6834e55050d"}, + {file = "pdfminer.six-20191110-py2.py3-none-any.whl", hash = "sha256:ca2ca58f3ac66a486bce53a6ddba95dc2b27781612915fa41c444790ba9cd2a8"}, + {file = "pdfminer.six-20191110.tar.gz", hash = "sha256:141a53ec491bee6d45bf9b2c7f82601426fb5d32636bcf6b9c8a8f3b6431fea6"}, ] pendulum = [ {file = "pendulum-2.1.2-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:b6c352f4bd32dff1ea7066bd31ad0f71f8d8100b9ff709fb343f3b86cee43efe"}, @@ -1394,126 +1715,142 @@ pickleshare = [ {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, ] pillow = [ - {file = "Pillow-8.2.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:dc38f57d8f20f06dd7c3161c59ca2c86893632623f33a42d592f097b00f720a9"}, - {file = "Pillow-8.2.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a013cbe25d20c2e0c4e85a9daf438f85121a4d0344ddc76e33fd7e3965d9af4b"}, - {file = "Pillow-8.2.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:8bb1e155a74e1bfbacd84555ea62fa21c58e0b4e7e6b20e4447b8d07990ac78b"}, - {file = "Pillow-8.2.0-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c5236606e8570542ed424849f7852a0ff0bce2c4c8d0ba05cc202a5a9c97dee9"}, - {file = "Pillow-8.2.0-cp36-cp36m-win32.whl", hash = "sha256:12e5e7471f9b637762453da74e390e56cc43e486a88289995c1f4c1dc0bfe727"}, - {file = "Pillow-8.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:5afe6b237a0b81bd54b53f835a153770802f164c5570bab5e005aad693dab87f"}, - {file = "Pillow-8.2.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:cb7a09e173903541fa888ba010c345893cd9fc1b5891aaf060f6ca77b6a3722d"}, - {file = "Pillow-8.2.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0d19d70ee7c2ba97631bae1e7d4725cdb2ecf238178096e8c82ee481e189168a"}, - {file = "Pillow-8.2.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:083781abd261bdabf090ad07bb69f8f5599943ddb539d64497ed021b2a67e5a9"}, - {file = "Pillow-8.2.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:c6b39294464b03457f9064e98c124e09008b35a62e3189d3513e5148611c9388"}, - {file = "Pillow-8.2.0-cp37-cp37m-win32.whl", hash = "sha256:01425106e4e8cee195a411f729cff2a7d61813b0b11737c12bd5991f5f14bcd5"}, - {file = "Pillow-8.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:3b570f84a6161cf8865c4e08adf629441f56e32f180f7aa4ccbd2e0a5a02cba2"}, - {file = "Pillow-8.2.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:031a6c88c77d08aab84fecc05c3cde8414cd6f8406f4d2b16fed1e97634cc8a4"}, - {file = "Pillow-8.2.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:66cc56579fd91f517290ab02c51e3a80f581aba45fd924fcdee01fa06e635812"}, - {file = "Pillow-8.2.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c32cc3145928c4305d142ebec682419a6c0a8ce9e33db900027ddca1ec39178"}, - {file = "Pillow-8.2.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:624b977355cde8b065f6d51b98497d6cd5fbdd4f36405f7a8790e3376125e2bb"}, - {file = "Pillow-8.2.0-cp38-cp38-win32.whl", hash = "sha256:5cbf3e3b1014dddc45496e8cf38b9f099c95a326275885199f427825c6522232"}, - {file = "Pillow-8.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:463822e2f0d81459e113372a168f2ff59723e78528f91f0bd25680ac185cf797"}, - {file = "Pillow-8.2.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:95d5ef984eff897850f3a83883363da64aae1000e79cb3c321915468e8c6add5"}, - {file = "Pillow-8.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b91c36492a4bbb1ee855b7d16fe51379e5f96b85692dc8210831fbb24c43e484"}, - {file = "Pillow-8.2.0-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d68cb92c408261f806b15923834203f024110a2e2872ecb0bd2a110f89d3c602"}, - {file = "Pillow-8.2.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f217c3954ce5fd88303fc0c317af55d5e0204106d86dea17eb8205700d47dec2"}, - {file = "Pillow-8.2.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:5b70110acb39f3aff6b74cf09bb4169b167e2660dabc304c1e25b6555fa781ef"}, - {file = "Pillow-8.2.0-cp39-cp39-win32.whl", hash = "sha256:a7d5e9fad90eff8f6f6106d3b98b553a88b6f976e51fce287192a5d2d5363713"}, - {file = "Pillow-8.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:238c197fc275b475e87c1453b05b467d2d02c2915fdfdd4af126145ff2e4610c"}, - {file = "Pillow-8.2.0-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:0e04d61f0064b545b989126197930807c86bcbd4534d39168f4aa5fda39bb8f9"}, - {file = "Pillow-8.2.0-pp36-pypy36_pp73-manylinux2010_i686.whl", hash = "sha256:63728564c1410d99e6d1ae8e3b810fe012bc440952168af0a2877e8ff5ab96b9"}, - {file = "Pillow-8.2.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c03c07ed32c5324939b19e36ae5f75c660c81461e312a41aea30acdd46f93a7c"}, - {file = "Pillow-8.2.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:4d98abdd6b1e3bf1a1cbb14c3895226816e666749ac040c4e2554231068c639b"}, - {file = "Pillow-8.2.0-pp37-pypy37_pp73-manylinux2010_i686.whl", hash = "sha256:aac00e4bc94d1b7813fe882c28990c1bc2f9d0e1aa765a5f2b516e8a6a16a9e4"}, - {file = "Pillow-8.2.0-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:22fd0f42ad15dfdde6c581347eaa4adb9a6fc4b865f90b23378aa7914895e120"}, - {file = "Pillow-8.2.0-pp37-pypy37_pp73-win32.whl", hash = "sha256:e98eca29a05913e82177b3ba3d198b1728e164869c613d76d0de4bde6768a50e"}, - {file = "Pillow-8.2.0.tar.gz", hash = "sha256:a787ab10d7bb5494e5f76536ac460741788f1fbce851068d73a87ca7c35fc3e1"}, + {file = "Pillow-8.4.0-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:81f8d5c81e483a9442d72d182e1fb6dcb9723f289a57e8030811bac9ea3fef8d"}, + {file = "Pillow-8.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3f97cfb1e5a392d75dd8b9fd274d205404729923840ca94ca45a0af57e13dbe6"}, + {file = "Pillow-8.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eb9fc393f3c61f9054e1ed26e6fe912c7321af2f41ff49d3f83d05bacf22cc78"}, + {file = "Pillow-8.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d82cdb63100ef5eedb8391732375e6d05993b765f72cb34311fab92103314649"}, + {file = "Pillow-8.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc1afda735a8d109007164714e73771b499768b9bb5afcbbee9d0ff374b43f"}, + {file = "Pillow-8.4.0-cp310-cp310-win32.whl", hash = "sha256:e3dacecfbeec9a33e932f00c6cd7996e62f53ad46fbe677577394aaa90ee419a"}, + {file = "Pillow-8.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:620582db2a85b2df5f8a82ddeb52116560d7e5e6b055095f04ad828d1b0baa39"}, + {file = "Pillow-8.4.0-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:1bc723b434fbc4ab50bb68e11e93ce5fb69866ad621e3c2c9bdb0cd70e345f55"}, + {file = "Pillow-8.4.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72cbcfd54df6caf85cc35264c77ede902452d6df41166010262374155947460c"}, + {file = "Pillow-8.4.0-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70ad9e5c6cb9b8487280a02c0ad8a51581dcbbe8484ce058477692a27c151c0a"}, + {file = "Pillow-8.4.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25a49dc2e2f74e65efaa32b153527fc5ac98508d502fa46e74fa4fd678ed6645"}, + {file = "Pillow-8.4.0-cp36-cp36m-win32.whl", hash = "sha256:93ce9e955cc95959df98505e4608ad98281fff037350d8c2671c9aa86bcf10a9"}, + {file = "Pillow-8.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:2e4440b8f00f504ee4b53fe30f4e381aae30b0568193be305256b1462216feff"}, + {file = "Pillow-8.4.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:8c803ac3c28bbc53763e6825746f05cc407b20e4a69d0122e526a582e3b5e153"}, + {file = "Pillow-8.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8a17b5d948f4ceeceb66384727dde11b240736fddeda54ca740b9b8b1556b29"}, + {file = "Pillow-8.4.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1394a6ad5abc838c5cd8a92c5a07535648cdf6d09e8e2d6df916dfa9ea86ead8"}, + {file = "Pillow-8.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:792e5c12376594bfcb986ebf3855aa4b7c225754e9a9521298e460e92fb4a488"}, + {file = "Pillow-8.4.0-cp37-cp37m-win32.whl", hash = "sha256:d99ec152570e4196772e7a8e4ba5320d2d27bf22fdf11743dd882936ed64305b"}, + {file = "Pillow-8.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:7b7017b61bbcdd7f6363aeceb881e23c46583739cb69a3ab39cb384f6ec82e5b"}, + {file = "Pillow-8.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:d89363f02658e253dbd171f7c3716a5d340a24ee82d38aab9183f7fdf0cdca49"}, + {file = "Pillow-8.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0a0956fdc5defc34462bb1c765ee88d933239f9a94bc37d132004775241a7585"}, + {file = "Pillow-8.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b7bb9de00197fb4261825c15551adf7605cf14a80badf1761d61e59da347779"}, + {file = "Pillow-8.4.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72b9e656e340447f827885b8d7a15fc8c4e68d410dc2297ef6787eec0f0ea409"}, + {file = "Pillow-8.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5a4532a12314149d8b4e4ad8ff09dde7427731fcfa5917ff16d0291f13609df"}, + {file = "Pillow-8.4.0-cp38-cp38-win32.whl", hash = "sha256:82aafa8d5eb68c8463b6e9baeb4f19043bb31fefc03eb7b216b51e6a9981ae09"}, + {file = "Pillow-8.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:066f3999cb3b070a95c3652712cffa1a748cd02d60ad7b4e485c3748a04d9d76"}, + {file = "Pillow-8.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:5503c86916d27c2e101b7f71c2ae2cddba01a2cf55b8395b0255fd33fa4d1f1a"}, + {file = "Pillow-8.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4acc0985ddf39d1bc969a9220b51d94ed51695d455c228d8ac29fcdb25810e6e"}, + {file = "Pillow-8.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b052a619a8bfcf26bd8b3f48f45283f9e977890263e4571f2393ed8898d331b"}, + {file = "Pillow-8.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:493cb4e415f44cd601fcec11c99836f707bb714ab03f5ed46ac25713baf0ff20"}, + {file = "Pillow-8.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8831cb7332eda5dc89b21a7bce7ef6ad305548820595033a4b03cf3091235ed"}, + {file = "Pillow-8.4.0-cp39-cp39-win32.whl", hash = "sha256:5e9ac5f66616b87d4da618a20ab0a38324dbe88d8a39b55be8964eb520021e02"}, + {file = "Pillow-8.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:3eb1ce5f65908556c2d8685a8f0a6e989d887ec4057326f6c22b24e8a172c66b"}, + {file = "Pillow-8.4.0-pp36-pypy36_pp73-macosx_10_10_x86_64.whl", hash = "sha256:ddc4d832a0f0b4c52fff973a0d44b6c99839a9d016fe4e6a1cb8f3eea96479c2"}, + {file = "Pillow-8.4.0-pp36-pypy36_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a3e5ddc44c14042f0844b8cf7d2cd455f6cc80fd7f5eefbe657292cf601d9ad"}, + {file = "Pillow-8.4.0-pp36-pypy36_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c70e94281588ef053ae8998039610dbd71bc509e4acbc77ab59d7d2937b10698"}, + {file = "Pillow-8.4.0-pp37-pypy37_pp73-macosx_10_10_x86_64.whl", hash = "sha256:3862b7256046fcd950618ed22d1d60b842e3a40a48236a5498746f21189afbbc"}, + {file = "Pillow-8.4.0-pp37-pypy37_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4901622493f88b1a29bd30ec1a2f683782e57c3c16a2dbc7f2595ba01f639df"}, + {file = "Pillow-8.4.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84c471a734240653a0ec91dec0996696eea227eafe72a33bd06c92697728046b"}, + {file = "Pillow-8.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:244cf3b97802c34c41905d22810846802a3329ddcb93ccc432870243211c79fc"}, + {file = "Pillow-8.4.0.tar.gz", hash = "sha256:b8e2f83c56e141920c39464b852de3719dfbfb6e3c99a2d8da0edf4fb33176ed"}, +] +platformdirs = [ + {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, + {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, ] pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] prompt-toolkit = [ - {file = "prompt_toolkit-3.0.3-py3-none-any.whl", hash = "sha256:c93e53af97f630f12f5f62a3274e79527936ed466f038953dfa379d4941f651a"}, - {file = "prompt_toolkit-3.0.3.tar.gz", hash = "sha256:a402e9bf468b63314e37460b68ba68243d55b2f8c4d0192f85a019af3945050e"}, + {file = "prompt_toolkit-3.0.24-py3-none-any.whl", hash = "sha256:e56f2ff799bacecd3e88165b1e2f5ebf9bcd59e80e06d395fa0cc4b8bd7bb506"}, + {file = "prompt_toolkit-3.0.24.tar.gz", hash = "sha256:1bb05628c7d87b645974a1bad3f17612be0c29fa39af9f7688030163f680bad6"}, ] ptyprocess = [ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, ] py = [ - {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, - {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] pycodestyle = [ {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] pycparser = [ - {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, - {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] pycryptodome = [ - {file = "pycryptodome-3.10.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1c5e1ca507de2ad93474be5cfe2bfa76b7cf039a1a32fc196f40935944871a06"}, - {file = "pycryptodome-3.10.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:6260e24d41149268122dd39d4ebd5941e9d107f49463f7e071fd397e29923b0c"}, - {file = "pycryptodome-3.10.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:3f840c49d38986f6e17dbc0673d37947c88bc9d2d9dba1c01b979b36f8447db1"}, - {file = "pycryptodome-3.10.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:2dea65df54349cdfa43d6b2e8edb83f5f8d6861e5cf7b1fbc3e34c5694c85e27"}, - {file = "pycryptodome-3.10.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:e61e363d9a5d7916f3a4ce984a929514c0df3daf3b1b2eb5e6edbb131ee771cf"}, - {file = "pycryptodome-3.10.1-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:2603c98ae04aac675fefcf71a6c87dc4bb74a75e9071ae3923bbc91a59f08d35"}, - {file = "pycryptodome-3.10.1-cp27-cp27m-win32.whl", hash = "sha256:38661348ecb71476037f1e1f553159b80d256c00f6c0b00502acac891f7116d9"}, - {file = "pycryptodome-3.10.1-cp27-cp27m-win_amd64.whl", hash = "sha256:1723ebee5561628ce96748501cdaa7afaa67329d753933296321f0be55358dce"}, - {file = "pycryptodome-3.10.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:77997519d8eb8a4adcd9a47b9cec18f9b323e296986528186c0e9a7a15d6a07e"}, - {file = "pycryptodome-3.10.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:99b2f3fc51d308286071d0953f92055504a6ffe829a832a9fc7a04318a7683dd"}, - {file = "pycryptodome-3.10.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:e0a4d5933a88a2c98bbe19c0c722f5483dc628d7a38338ac2cb64a7dbd34064b"}, - {file = "pycryptodome-3.10.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d3d6958d53ad307df5e8469cc44474a75393a434addf20ecd451f38a72fe29b8"}, - {file = "pycryptodome-3.10.1-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:a8eb8b6ea09ec1c2535bf39914377bc8abcab2c7d30fa9225eb4fe412024e427"}, - {file = "pycryptodome-3.10.1-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:31c1df17b3dc5f39600a4057d7db53ac372f492c955b9b75dd439f5d8b460129"}, - {file = "pycryptodome-3.10.1-cp35-abi3-manylinux1_i686.whl", hash = "sha256:a3105a0eb63eacf98c2ecb0eb4aa03f77f40fbac2bdde22020bb8a536b226bb8"}, - {file = "pycryptodome-3.10.1-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:a92d5c414e8ee1249e850789052608f582416e82422502dc0ac8c577808a9067"}, - {file = "pycryptodome-3.10.1-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:60386d1d4cfaad299803b45a5bc2089696eaf6cdd56f9fc17479a6f89595cfc8"}, - {file = "pycryptodome-3.10.1-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:501ab36aae360e31d0ec370cf5ce8ace6cb4112060d099b993bc02b36ac83fb6"}, - {file = "pycryptodome-3.10.1-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:fc7489a50323a0df02378bc2fff86eb69d94cc5639914346c736be981c6a02e7"}, - {file = "pycryptodome-3.10.1-cp35-abi3-win32.whl", hash = "sha256:9b6f711b25e01931f1c61ce0115245a23cdc8b80bf8539ac0363bdcf27d649b6"}, - {file = "pycryptodome-3.10.1-cp35-abi3-win_amd64.whl", hash = "sha256:7fd519b89585abf57bf47d90166903ec7b43af4fe23c92273ea09e6336af5c07"}, - {file = "pycryptodome-3.10.1-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:09c1555a3fa450e7eaca41ea11cd00afe7c91fef52353488e65663777d8524e0"}, - {file = "pycryptodome-3.10.1-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:758949ca62690b1540dfb24ad773c6da9cd0e425189e83e39c038bbd52b8e438"}, - {file = "pycryptodome-3.10.1-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:e3bf558c6aeb49afa9f0c06cee7fb5947ee5a1ff3bd794b653d39926b49077fa"}, - {file = "pycryptodome-3.10.1-pp27-pypy_73-win32.whl", hash = "sha256:f977cdf725b20f6b8229b0c87acb98c7717e742ef9f46b113985303ae12a99da"}, - {file = "pycryptodome-3.10.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:6d2df5223b12437e644ce0a3be7809471ffa71de44ccd28b02180401982594a6"}, - {file = "pycryptodome-3.10.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:98213ac2b18dc1969a47bc65a79a8fca02a414249d0c8635abb081c7f38c91b6"}, - {file = "pycryptodome-3.10.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:12222a5edc9ca4a29de15fbd5339099c4c26c56e13c2ceddf0b920794f26165d"}, - {file = "pycryptodome-3.10.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:6bbf7fee7b7948b29d7e71fcacf48bac0c57fb41332007061a933f2d996f9713"}, - {file = "pycryptodome-3.10.1.tar.gz", hash = "sha256:3e2e3a06580c5f190df843cdb90ea28d61099cf4924334d5297a995de68e4673"}, + {file = "pycryptodome-3.12.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:90ad3381ccdc6a24cc2841e295706a168f32abefe64c679695712acac71fd5da"}, + {file = "pycryptodome-3.12.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e80f7469b0b3ea0f694230477d8501dc5a30a717e94fddd4821e6721f3053eae"}, + {file = "pycryptodome-3.12.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:b91404611767a7485837a6f1fd20cf9a5ae0ad362040a022cd65827ecb1b0d00"}, + {file = "pycryptodome-3.12.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:db66ccda65d5d20c17b00768e462a86f6f540f9aea8419a7f76cc7d9effd82cd"}, + {file = "pycryptodome-3.12.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:dc88355c4b261ed259268e65705b28b44d99570337694d593f06e3b1698eaaf3"}, + {file = "pycryptodome-3.12.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:6f8f5b7b53516da7511951910ab458e799173722c91fea54e2ba2f56d102e4aa"}, + {file = "pycryptodome-3.12.0-cp27-cp27m-win32.whl", hash = "sha256:93acad54a72d81253242eb0a15064be559ec9d989e5173286dc21cad19f01765"}, + {file = "pycryptodome-3.12.0-cp27-cp27m-win_amd64.whl", hash = "sha256:5a8c24d39d4a237dbfe181ea6593792bf9b5582c7fcfa7b8e0e12fda5eec07af"}, + {file = "pycryptodome-3.12.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:32d15da81959faea6cbed95df2bb44f7f796211c110cf90b5ad3b2aeeb97fc8e"}, + {file = "pycryptodome-3.12.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:aed7eb4b64c600fbc5e6d4238991ad1b4179a558401f203d1fcbd24883748982"}, + {file = "pycryptodome-3.12.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:341c6bbf932c406b4f3ee2372e8589b67ac0cf4e99e7dc081440f43a3cde9f0f"}, + {file = "pycryptodome-3.12.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:de0b711d673904dd6c65307ead36cb76622365a393569bf880895cba21195b7a"}, + {file = "pycryptodome-3.12.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:3558616f45d8584aee3eba27559bc6fd0ba9be6c076610ed3cc62bd5229ffdc3"}, + {file = "pycryptodome-3.12.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:a78e4324e566b5fbc2b51e9240950d82fa9e1c7eb77acdf27f58712f65622c1d"}, + {file = "pycryptodome-3.12.0-cp35-abi3-manylinux1_i686.whl", hash = "sha256:3f2f3dd596c6128d91314e60a6bcf4344610ef0e97f4ae4dd1770f86dd0748d8"}, + {file = "pycryptodome-3.12.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:e05f994f30f1cda3cbe57441f41220d16731cf99d868bb02a8f6484c454c206b"}, + {file = "pycryptodome-3.12.0-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:4cded12e13785bbdf4ba1ff5fb9d261cd98162145f869e4fbc4a4b9083392f0b"}, + {file = "pycryptodome-3.12.0-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:1181c90d1a6aee68a84826825548d0db1b58d8541101f908d779d601d1690586"}, + {file = "pycryptodome-3.12.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:6bb0d340c93bcb674ea8899e2f6408ec64c6c21731a59481332b4b2a8143cc60"}, + {file = "pycryptodome-3.12.0-cp35-abi3-win32.whl", hash = "sha256:39da5807aa1ff820799c928f745f89432908bf6624b9e981d2d7f9e55d91b860"}, + {file = "pycryptodome-3.12.0-cp35-abi3-win_amd64.whl", hash = "sha256:212c7f7fe11cad9275fbcff50ca977f1c6643f13560d081e7b0f70596df447b8"}, + {file = "pycryptodome-3.12.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:b07a4238465eb8c65dd5df2ab8ba6df127e412293c0ed7656c003336f557a100"}, + {file = "pycryptodome-3.12.0-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:a6e1bcd9d5855f1a3c0f8d585f44c81b08f39a02754007f374fb8db9605ba29c"}, + {file = "pycryptodome-3.12.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:aceb1d217c3a025fb963849071446cf3aca1353282fe1c3cb7bd7339a4d47947"}, + {file = "pycryptodome-3.12.0-pp27-pypy_73-win32.whl", hash = "sha256:f699360ae285fcae9c8f53ca6acf33796025a82bb0ccd7c1c551b04c1726def3"}, + {file = "pycryptodome-3.12.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d845c587ceb82ac7cbac7d0bf8c62a1a0fe7190b028b322da5ca65f6e5a18b9e"}, + {file = "pycryptodome-3.12.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:d8083de50f6dec56c3c6f270fb193590999583a1b27c9c75bc0b5cac22d438cc"}, + {file = "pycryptodome-3.12.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:9ea2f6674c803602a7c0437fccdc2ea036707e60456974fe26ca263bd501ec45"}, + {file = "pycryptodome-3.12.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:5d4264039a2087977f50072aaff2346d1c1c101cb359f9444cf92e3d1f42b4cd"}, + {file = "pycryptodome-3.12.0.zip", hash = "sha256:12c7343aec5a3b3df5c47265281b12b611f26ec9367b6129199d67da54b768c1"}, ] pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pygments = [ - {file = "Pygments-2.8.1-py3-none-any.whl", hash = "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"}, - {file = "Pygments-2.8.1.tar.gz", hash = "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94"}, + {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, + {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, ] pylint = [ - {file = "pylint-2.8.2-py3-none-any.whl", hash = "sha256:f7e2072654a6b6afdf5e2fb38147d3e2d2d43c89f648637baab63e026481279b"}, - {file = "pylint-2.8.2.tar.gz", hash = "sha256:586d8fa9b1891f4b725f587ef267abe2a1bad89d6b184520c7f07a253dd6e217"}, + {file = "pylint-2.12.2-py3-none-any.whl", hash = "sha256:daabda3f7ed9d1c60f52d563b1b854632fd90035bcf01443e234d3dc794e3b74"}, + {file = "pylint-2.12.2.tar.gz", hash = "sha256:9d945a73640e1fec07ee34b42f5669b770c759acd536ec7b16d7e4b87a9c9ff9"}, ] pyparsing = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, + {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, + {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, ] pytest = [ {file = "pytest-4.6.11-py2.py3-none-any.whl", hash = "sha256:a00a7d79cbbdfa9d21e7d0298392a8dd4123316bfac545075e6f8f24c94d8c97"}, {file = "pytest-4.6.11.tar.gz", hash = "sha256:50fa82392f2120cc3ec2ca0a75ee615be4c479e66669789771f1758332be4353"}, ] python-dateutil = [ - {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, - {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] python-pptx = [ - {file = "python-pptx-0.6.18.tar.gz", hash = "sha256:a857d69e52d7e8a8fb32fca8182fdd4a3c68c689de8d4e4460e9b4a95efa7bc4"}, + {file = "python-pptx-0.6.21.tar.gz", hash = "sha256:7798a2aaf89563565b3c7120c0acfe9aff775db0db3580544e3bf4840c2e378f"}, ] pytz = [ - {file = "pytz-2021.1-py2.py3-none-any.whl", hash = "sha256:eb10ce3e7736052ed3623d49975ce333bcd712c7bb19a58b9e2089d4057d0798"}, - {file = "pytz-2021.1.tar.gz", hash = "sha256:83a4a90894bf38e243cf052c8b58f381bfe9a7a483f6a9cab140bc7f702ac4da"}, + {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, + {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, +] +pytz-deprecation-shim = [ + {file = "pytz_deprecation_shim-0.1.0.post0-py2.py3-none-any.whl", hash = "sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6"}, + {file = "pytz_deprecation_shim-0.1.0.post0.tar.gz", hash = "sha256:af097bae1b616dde5c5744441e2ddc69e74dfdcb0c263129610d85b87445a59d"}, ] pytzdata = [ {file = "pytzdata-2020.1-py2.py3-none-any.whl", hash = "sha256:e1e14750bcf95016381e4d472bad004eef710f2d6417240904070b3d6654485f"}, @@ -1551,12 +1888,20 @@ pyyaml = [ {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] requests = [ - {file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, - {file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, + {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, + {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, +] +rich = [ + {file = "rich-11.0.0-py3-none-any.whl", hash = "sha256:d7a8086aa1fa7e817e3bba544eee4fd82047ef59036313147759c11475f0dafd"}, + {file = "rich-11.0.0.tar.gz", hash = "sha256:c32a8340b21c75931f157466fefe81ae10b92c36a5ea34524dff3767238774a4"}, +] +schema = [ + {file = "schema-0.7.5-py2.py3-none-any.whl", hash = "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c"}, + {file = "schema-0.7.5.tar.gz", hash = "sha256:f06717112c61895cabc4707752b88716e8420a8819d71404501e114f91043197"}, ] send2trash = [ - {file = "Send2Trash-1.5.0-py3-none-any.whl", hash = "sha256:f1691922577b6fa12821234aeb57599d887c4900b9ca537948d2dac34aea888b"}, - {file = "Send2Trash-1.5.0.tar.gz", hash = "sha256:60001cc07d707fe247c94f74ca6ac0d3255aabcb930529690897ca2a39db28b2"}, + {file = "Send2Trash-1.8.0-py3-none-any.whl", hash = "sha256:f20eaadfdb517eaca5ce077640cb261c7d2698385a6a0f072a4a5447fd49fa08"}, + {file = "Send2Trash-1.8.0.tar.gz", hash = "sha256:d2c24762fd3759860a0aff155e45871447ea58d2be6bdd39b5c8f966a0c99c2d"}, ] simplematch = [ {file = "simplematch-1.3-py3-none-any.whl", hash = "sha256:be1d9a7e5055aaf9b35d16f565d6fc198d03e2b5804e954557e1c972d2f868f9"}, @@ -1567,16 +1912,16 @@ six = [ {file = "six-1.12.0.tar.gz", hash = "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"}, ] snowballstemmer = [ - {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, - {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] sortedcontainers = [ - {file = "sortedcontainers-2.3.0-py2.py3-none-any.whl", hash = "sha256:37257a32add0a3ee490bb170b599e93095eed89a55da91fa9f48753ea12fd73f"}, - {file = "sortedcontainers-2.3.0.tar.gz", hash = "sha256:59cc937650cf60d677c16775597c89a960658a09cf7c1a668f86e1e4464b10a1"}, + {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, + {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, ] soupsieve = [ - {file = "soupsieve-2.2.1-py3-none-any.whl", hash = "sha256:c2c1c2d44f158cdbddab7824a9af8c4f83c76b1e23e049479aa432feb6c4c23b"}, - {file = "soupsieve-2.2.1.tar.gz", hash = "sha256:052774848f448cf19c7e959adf5566904d525f33a3f8b6ba6f6f8f26ec7de0cc"}, + {file = "soupsieve-2.3.1-py3-none-any.whl", hash = "sha256:1a3cca2617c6b38c0343ed661b1fa5de5637f257d4fe22bd9f1338010a1efefb"}, + {file = "soupsieve-2.3.1.tar.gz", hash = "sha256:b8d49b1cd4f037c7082a9683dfa1801aa2597fb11c3a1155b7a5b94829b4f1f9"}, ] speechrecognition = [ {file = "SpeechRecognition-3.8.1-py2.py3-none-any.whl", hash = "sha256:4d8f73a0c05ec70331c3bacaa89ecc06dfa8d9aba0899276664cda06ab597e8e"}, @@ -1598,8 +1943,8 @@ sphinxcontrib-devhelp = [ {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, ] sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-1.0.3.tar.gz", hash = "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"}, - {file = "sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f"}, + {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, + {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, ] sphinxcontrib-jsmath = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, @@ -1610,12 +1955,11 @@ sphinxcontrib-qthelp = [ {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, ] sphinxcontrib-serializinghtml = [ - {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"}, - {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"}, + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, ] textract = [ - {file = "textract-1.6.3-py3-none-any.whl", hash = "sha256:ff2f4c61d720d3291e2deb870d3b24d0c63397cb4c094966e96c1bdb2f89df38"}, - {file = "textract-1.6.3.tar.gz", hash = "sha256:6213b2f923b85af8e5e380241db9361e3f5dbd444a74108745fd4121ae151310"}, + {file = "textract-1.6.4.tar.gz", hash = "sha256:35ac0302e2dbe53eb8d513b4cf0741264ea89a695fd89a3d48e3bd94d517cef6"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, @@ -1658,39 +2002,144 @@ typed-ast = [ {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, ] typing-extensions = [ - {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, - {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, - {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, + {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, + {file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"}, +] +tzdata = [ + {file = "tzdata-2021.5-py2.py3-none-any.whl", hash = "sha256:3eee491e22ebfe1e5cfcc97a4137cd70f092ce59144d81f8924a844de05ba8f5"}, + {file = "tzdata-2021.5.tar.gz", hash = "sha256:68dbe41afd01b867894bbdfd54fa03f468cfa4f0086bfb4adcd8de8f24f3ee21"}, ] tzlocal = [ - {file = "tzlocal-1.5.1.tar.gz", hash = "sha256:4ebeb848845ac898da6519b9b31879cf13b6626f7184c496037b818e238f2c4e"}, + {file = "tzlocal-4.1-py3-none-any.whl", hash = "sha256:28ba8d9fcb6c9a782d6e0078b4f6627af1ea26aeaa32b4eab5324abc7df4149f"}, + {file = "tzlocal-4.1.tar.gz", hash = "sha256:0f28015ac68a5c067210400a9197fc5d36ba9bc3f8eaf1da3cbd59acdfed9e09"}, ] urllib3 = [ - {file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"}, - {file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, + {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, + {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] wrapt = [ - {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, + {file = "wrapt-1.13.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e05e60ff3b2b0342153be4d1b597bbcfd8330890056b9619f4ad6b8d5c96a81a"}, + {file = "wrapt-1.13.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:85148f4225287b6a0665eef08a178c15097366d46b210574a658c1ff5b377489"}, + {file = "wrapt-1.13.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:2dded5496e8f1592ec27079b28b6ad2a1ef0b9296d270f77b8e4a3a796cf6909"}, + {file = "wrapt-1.13.3-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:e94b7d9deaa4cc7bac9198a58a7240aaf87fe56c6277ee25fa5b3aa1edebd229"}, + {file = "wrapt-1.13.3-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:498e6217523111d07cd67e87a791f5e9ee769f9241fcf8a379696e25806965af"}, + {file = "wrapt-1.13.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ec7e20258ecc5174029a0f391e1b948bf2906cd64c198a9b8b281b811cbc04de"}, + {file = "wrapt-1.13.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:87883690cae293541e08ba2da22cacaae0a092e0ed56bbba8d018cc486fbafbb"}, + {file = "wrapt-1.13.3-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:f99c0489258086308aad4ae57da9e8ecf9e1f3f30fa35d5e170b4d4896554d80"}, + {file = "wrapt-1.13.3-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6a03d9917aee887690aa3f1747ce634e610f6db6f6b332b35c2dd89412912bca"}, + {file = "wrapt-1.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:936503cb0a6ed28dbfa87e8fcd0a56458822144e9d11a49ccee6d9a8adb2ac44"}, + {file = "wrapt-1.13.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f9c51d9af9abb899bd34ace878fbec8bf357b3194a10c4e8e0a25512826ef056"}, + {file = "wrapt-1.13.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:220a869982ea9023e163ba915077816ca439489de6d2c09089b219f4e11b6785"}, + {file = "wrapt-1.13.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0877fe981fd76b183711d767500e6b3111378ed2043c145e21816ee589d91096"}, + {file = "wrapt-1.13.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:43e69ffe47e3609a6aec0fe723001c60c65305784d964f5007d5b4fb1bc6bf33"}, + {file = "wrapt-1.13.3-cp310-cp310-win32.whl", hash = "sha256:78dea98c81915bbf510eb6a3c9c24915e4660302937b9ae05a0947164248020f"}, + {file = "wrapt-1.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:ea3e746e29d4000cd98d572f3ee2a6050a4f784bb536f4ac1f035987fc1ed83e"}, + {file = "wrapt-1.13.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:8c73c1a2ec7c98d7eaded149f6d225a692caa1bd7b2401a14125446e9e90410d"}, + {file = "wrapt-1.13.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:086218a72ec7d986a3eddb7707c8c4526d677c7b35e355875a0fe2918b059179"}, + {file = "wrapt-1.13.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:e92d0d4fa68ea0c02d39f1e2f9cb5bc4b4a71e8c442207433d8db47ee79d7aa3"}, + {file = "wrapt-1.13.3-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:d4a5f6146cfa5c7ba0134249665acd322a70d1ea61732723c7d3e8cc0fa80755"}, + {file = "wrapt-1.13.3-cp35-cp35m-win32.whl", hash = "sha256:8aab36778fa9bba1a8f06a4919556f9f8c7b33102bd71b3ab307bb3fecb21851"}, + {file = "wrapt-1.13.3-cp35-cp35m-win_amd64.whl", hash = "sha256:944b180f61f5e36c0634d3202ba8509b986b5fbaf57db3e94df11abee244ba13"}, + {file = "wrapt-1.13.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:2ebdde19cd3c8cdf8df3fc165bc7827334bc4e353465048b36f7deeae8ee0918"}, + {file = "wrapt-1.13.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:610f5f83dd1e0ad40254c306f4764fcdc846641f120c3cf424ff57a19d5f7ade"}, + {file = "wrapt-1.13.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5601f44a0f38fed36cc07db004f0eedeaadbdcec90e4e90509480e7e6060a5bc"}, + {file = "wrapt-1.13.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:e6906d6f48437dfd80464f7d7af1740eadc572b9f7a4301e7dd3d65db285cacf"}, + {file = "wrapt-1.13.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:766b32c762e07e26f50d8a3468e3b4228b3736c805018e4b0ec8cc01ecd88125"}, + {file = "wrapt-1.13.3-cp36-cp36m-win32.whl", hash = "sha256:5f223101f21cfd41deec8ce3889dc59f88a59b409db028c469c9b20cfeefbe36"}, + {file = "wrapt-1.13.3-cp36-cp36m-win_amd64.whl", hash = "sha256:f122ccd12fdc69628786d0c947bdd9cb2733be8f800d88b5a37c57f1f1d73c10"}, + {file = "wrapt-1.13.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:46f7f3af321a573fc0c3586612db4decb7eb37172af1bc6173d81f5b66c2e068"}, + {file = "wrapt-1.13.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:778fd096ee96890c10ce96187c76b3e99b2da44e08c9e24d5652f356873f6709"}, + {file = "wrapt-1.13.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0cb23d36ed03bf46b894cfec777eec754146d68429c30431c99ef28482b5c1df"}, + {file = "wrapt-1.13.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:96b81ae75591a795d8c90edc0bfaab44d3d41ffc1aae4d994c5aa21d9b8e19a2"}, + {file = "wrapt-1.13.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7dd215e4e8514004c8d810a73e342c536547038fb130205ec4bba9f5de35d45b"}, + {file = "wrapt-1.13.3-cp37-cp37m-win32.whl", hash = "sha256:47f0a183743e7f71f29e4e21574ad3fa95676136f45b91afcf83f6a050914829"}, + {file = "wrapt-1.13.3-cp37-cp37m-win_amd64.whl", hash = "sha256:fd76c47f20984b43d93de9a82011bb6e5f8325df6c9ed4d8310029a55fa361ea"}, + {file = "wrapt-1.13.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b73d4b78807bd299b38e4598b8e7bd34ed55d480160d2e7fdaabd9931afa65f9"}, + {file = "wrapt-1.13.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ec9465dd69d5657b5d2fa6133b3e1e989ae27d29471a672416fd729b429eb554"}, + {file = "wrapt-1.13.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dd91006848eb55af2159375134d724032a2d1d13bcc6f81cd8d3ed9f2b8e846c"}, + {file = "wrapt-1.13.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ae9de71eb60940e58207f8e71fe113c639da42adb02fb2bcbcaccc1ccecd092b"}, + {file = "wrapt-1.13.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:51799ca950cfee9396a87f4a1240622ac38973b6df5ef7a41e7f0b98797099ce"}, + {file = "wrapt-1.13.3-cp38-cp38-win32.whl", hash = "sha256:4b9c458732450ec42578b5642ac53e312092acf8c0bfce140ada5ca1ac556f79"}, + {file = "wrapt-1.13.3-cp38-cp38-win_amd64.whl", hash = "sha256:7dde79d007cd6dfa65afe404766057c2409316135cb892be4b1c768e3f3a11cb"}, + {file = "wrapt-1.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:981da26722bebb9247a0601e2922cedf8bb7a600e89c852d063313102de6f2cb"}, + {file = "wrapt-1.13.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:705e2af1f7be4707e49ced9153f8d72131090e52be9278b5dbb1498c749a1e32"}, + {file = "wrapt-1.13.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:25b1b1d5df495d82be1c9d2fad408f7ce5ca8a38085e2da41bb63c914baadff7"}, + {file = "wrapt-1.13.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:77416e6b17926d953b5c666a3cb718d5945df63ecf922af0ee576206d7033b5e"}, + {file = "wrapt-1.13.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:865c0b50003616f05858b22174c40ffc27a38e67359fa1495605f96125f76640"}, + {file = "wrapt-1.13.3-cp39-cp39-win32.whl", hash = "sha256:0a017a667d1f7411816e4bf214646d0ad5b1da2c1ea13dec6c162736ff25a374"}, + {file = "wrapt-1.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:81bd7c90d28a4b2e1df135bfbd7c23aee3050078ca6441bead44c42483f9ebfb"}, + {file = "wrapt-1.13.3.tar.gz", hash = "sha256:1fea9cd438686e6682271d36f3481a9f3636195578bab9ca3382e2f5f01fc185"}, ] xattr = [ - {file = "xattr-0.9.7-cp27-cp27m-macosx_10_13_x86_64.whl", hash = "sha256:1b2cd125150aa9bbfb02929627101b3303920a68487e9c865ddd170188ddd796"}, - {file = "xattr-0.9.7-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:e2c72a3a501bac715489180ca2b646e48a1ca3a794c1103dd6f0f987d43f570c"}, - {file = "xattr-0.9.7-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:1e11ba8ab86dfe74419704c53722ea9b5915833db07416e7c10db5dfb02218bb"}, - {file = "xattr-0.9.7.tar.gz", hash = "sha256:b0bbca828e04ef2d484a6522ae7b3a7ccad5e43fa1c6f54d78e24bb870f49d44"}, + {file = "xattr-0.9.9-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:58a9fb4fd19b467e88f4b75b5243706caa57e312d3aee757b53b57c7fd0f4ba9"}, + {file = "xattr-0.9.9-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e71efca59705c7abde5b7f76323ebe00ed2977f10cba4204b9421dada036b5ca"}, + {file = "xattr-0.9.9-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:1aad96b6603961c3d1ca1aaa8369b1a8d684a7b37357b2428087c286bf0e561c"}, + {file = "xattr-0.9.9-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:46cb74f98d31d9d70f975ec3e6554360a9bdcbb4b9fb50a69fabe54f9f928c97"}, + {file = "xattr-0.9.9-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:80c2db56058a687d7439be041f916cbeb2943fbe2623e53d5da721a4552d8991"}, + {file = "xattr-0.9.9-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:c360d1cc42e885b64d84f64de3c501dd7bce576248327ef583b4625ee63aa023"}, + {file = "xattr-0.9.9-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:debd87afe6bdf88c3689bde52eecf2b166388b13ef7388259d23223374db417d"}, + {file = "xattr-0.9.9-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:4280c9f33a8678828f1bbc3d3dc8b823b5e4a113ee5ecb0fb98bff60cc2b9ad1"}, + {file = "xattr-0.9.9-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:e0916ec1656d2071cd3139d1f52426825985d8ed076f981ef7f0bc13dfa8e96c"}, + {file = "xattr-0.9.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a517916fbf2f58a3222bb2048fe1eeff4e23e07a4ce6228a27de004c80bf53ab"}, + {file = "xattr-0.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e886c882b3b28c7a684c3e3daf46347da5428a46b88bc6d62c4867d574b90c54"}, + {file = "xattr-0.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:373e3d1fd9258438fc38d1438142d3659f36743f374a20457346ef26741ed441"}, + {file = "xattr-0.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a7beeb54ca140273b2f6320bb98b701ec30628af2ebe4eb30f7051419eb4ef3"}, + {file = "xattr-0.9.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef3ca29cdaae9c47c625d84bb6c9046f7275cccde0ea805caa23ca58d3671f3f"}, + {file = "xattr-0.9.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c381d890931cd18b137ce3fb5c5f08b672c3c61e2e47b1a7442ee46e827abfe"}, + {file = "xattr-0.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:59c5783ccf57cf2700ce57d51a92134900ed26f6ab20d209f383fb898903fea6"}, + {file = "xattr-0.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:966b885b69d95362e2a12d39f84889cf857090e57263b5ac33409498aa00c160"}, + {file = "xattr-0.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efaaf0cb1ea8e9febb7baad301ae8cc9ad7a96fdfc5c6399d165e7a19e3e61ce"}, + {file = "xattr-0.9.9-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f19fa75ed1e9db86354efab29869cb2be6976d456bd7c89e67b118d5384a1d98"}, + {file = "xattr-0.9.9-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ca28ad06828244b315214ee35388f57e81e90aac2ceac3f32e42ae394e31b9c"}, + {file = "xattr-0.9.9-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:532c7f1656dd2fe937116b9e210229f716d7fc7ac142f9cdace7da92266d32e8"}, + {file = "xattr-0.9.9-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11c28033c17e98c67e0def9d6ebd415ad3c006a7bc3fee6bad79c5e52d0dff49"}, + {file = "xattr-0.9.9-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:473cabb30e544ea08c8c01c1ef18053147cdc8552d443ac97815e46fbb13c7d4"}, + {file = "xattr-0.9.9-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:c4a308522b444d090fbd66a385c9519b6b977818226921b0d2fc403667c93564"}, + {file = "xattr-0.9.9-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:82493434488aca72d88b5129dac8f212e7b8bdca7ceffe7bb977c850f2452e4e"}, + {file = "xattr-0.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e41d289706c7e8940f4d08e865da6a8ae988123e40a44f9a97ddc09e67795d7d"}, + {file = "xattr-0.9.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef08698e360cf43688dca3db3421b156b29948a714d5d089348073f463c11646"}, + {file = "xattr-0.9.9-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4eb10ac16ca8d534c0395425d52121e0c1981f808e1b3f577f6a5ec33d3853e4"}, + {file = "xattr-0.9.9-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5605fec07b0e964bd980cc70ec335b9eb1b7ac7c6f314c7c2d8f54b09104fe4c"}, + {file = "xattr-0.9.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:974e7d577ddb15e4552fb0ec10a4cfe09bdf6267365aa2b8394bb04637785aad"}, + {file = "xattr-0.9.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:ad6777de922c638bfa87a0d7faebc5722ddef04a1210b2a8909289b58b769af0"}, + {file = "xattr-0.9.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:3887e70873ebf0efbde32f9929ec1c7e45ec0013561743e2cc0406a91e51113b"}, + {file = "xattr-0.9.9-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:83caa8e93a45a0f25f91b92d9b45f490c87bff74f02555df6312efeba0dacc31"}, + {file = "xattr-0.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e33ec0a1d913d946d1ab7509f37ee37306c45af735347f13b963df34ffe6e029"}, + {file = "xattr-0.9.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:263c58dca83372260c5c195e0b59959e38e1f107f0b7350de82e3db38479036c"}, + {file = "xattr-0.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:125dfb9905428162349d3b8b825d9a18280893f0cb0db2a2467d5ef253fa6ce2"}, + {file = "xattr-0.9.9-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e243524e0dde16d7a2e1b52512ad2c6964df2143dd1c79b820dcb4c6c0822c20"}, + {file = "xattr-0.9.9-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01ec07d24a14406bdc6a123041c63a88e1c4a3f820e4a7d30f7609d57311b499"}, + {file = "xattr-0.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:85c1df5f1d209345ea96de137419e886a27bb55076b3ae01faacf35aafcf3a61"}, + {file = "xattr-0.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ca74d3eff92d6dc16e271fbad9cbab547fb9a0c983189c4031c3ff3d150dd871"}, + {file = "xattr-0.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7d17505e49ac70c0e71939c5aac96417a863583fb30a2d6304d5ac881230548f"}, + {file = "xattr-0.9.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1ae47a6398d3c04623fa386a4aa2f66e5cd3cdb1a7e69d1bfaeb8c73983bf271"}, + {file = "xattr-0.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:809e2537d0aff9fca97dacf3245cbbaf711bbced5d1b0235a8d1906b04e26114"}, + {file = "xattr-0.9.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de3af84364f06d67b3662ccf7c1a73e1d389d8d274394e952651e7bf1bbd2718"}, + {file = "xattr-0.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b62cdad232d2d2dedd39b543701db8e3883444ec0d57ce3fab8f75e5f8b0301"}, + {file = "xattr-0.9.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6b11d2eda397d47f7075743409683c233519ca52aa1dac109b413a4d8c15b740"}, + {file = "xattr-0.9.9-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661c0a939aefdf071887121f534bb10588d69c7b2dfca5c486af2fc81a0786e8"}, + {file = "xattr-0.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5db7c2db320a8d5264d437d71f1eb7270a7e4a6545296e7766161d17752590b7"}, + {file = "xattr-0.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:83203e60cbaca9536d297e5039b285a600ff84e6e9e8536fe2d521825eeeb437"}, + {file = "xattr-0.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:42bfb4e4da06477e739770ac6942edbdc71e9fc3b497b67db5fba712fa8109c2"}, + {file = "xattr-0.9.9-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:67047d04d1c56ad4f0f5886085e91b0077238ab3faaec6492c3c21920c6566eb"}, + {file = "xattr-0.9.9-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:885782bc82ded1a3f684d54a1af259ae9fcc347fa54b5a05b8aad82b8a42044c"}, + {file = "xattr-0.9.9-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bc84ccec618b5aa089e7cee8b07fcc92d4069aac4053da604c8143a0d6b1381"}, + {file = "xattr-0.9.9-pp37-pypy37_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baeff3e5dda8ea7e9424cfaee51829f46afe3836c30d02f343f9049c685681ca"}, + {file = "xattr-0.9.9.tar.gz", hash = "sha256:09cb7e1efb3aa1b4991d6be4eb25b73dc518b4fe894f0915f5b0dcede972f346"}, ] xlrd = [ {file = "xlrd-1.2.0-py2.py3-none-any.whl", hash = "sha256:e551fb498759fa3a5384a94ccd4c3c02eb7c00ea424426e212ac0c57be9dfbde"}, {file = "xlrd-1.2.0.tar.gz", hash = "sha256:546eb36cee8db40c3eaa46c351e67ffee6eeb5fa2650b71bc4c758a29a1b29b2"}, ] xlsxwriter = [ - {file = "XlsxWriter-1.4.0-py2.py3-none-any.whl", hash = "sha256:1a6dd98892e8010d3e089d1cb61385baa8f76fa547598df2c221cc37238c72d3"}, - {file = "XlsxWriter-1.4.0.tar.gz", hash = "sha256:82be5a58c09bdc2ff8afc25acc815c465275239ddfc56d6e7b2a7e6c5d2e213b"}, + {file = "XlsxWriter-3.0.2-py3-none-any.whl", hash = "sha256:1aa65166697c42284e82f5bf9a33c2e913341eeef2b262019c3f5b5334768765"}, + {file = "XlsxWriter-3.0.2.tar.gz", hash = "sha256:53005f03e8eb58f061ebf41d5767c7495ee0772c2396fe26b7e0ca22fa9c2570"}, ] zipp = [ - {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, - {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"}, + {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, + {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, ] diff --git a/pyproject.toml b/pyproject.toml index d6ef558e..8497e50a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,22 +26,24 @@ classifiers = [ organize = "organize.cli:main" [tool.poetry.dependencies] -python = "^3.6" -appdirs = "^1.4.4" +python = "^3.6.2" +fs = "^2.4.14" +rich = "^11.0.0" docopt = "^0.6.2" PyYAML = "^5.4.1" -Send2Trash = "^1.5.0" -colorama = "^0.4.4" -exifread = "^2.1" -textract = { version = "^1.6.3", optional = true } -pendulum = "^2.0.5" +Send2Trash = "^1.8.0" +ExifRead = "^2.3.2" +textract = { version = "^1.6.4", optional = true } +pendulum = "^2.1.2" simplematch = "^1.3" macos-tags = { version = "^1.5.1", markers = "sys_platform == 'darwin'"} +schema = "^0.7.5" [tool.poetry.extras] textract = ["textract"] [tool.poetry.dev-dependencies] +pip = "^21.3.1" pytest = "^4.6" pylint = "^2.3" ipdb = "^0.12.0" From 834e111aa14cf7777d5962724f2275d0d674b43a Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 20 Jan 2022 19:27:59 +0100 Subject: [PATCH 004/108] start rewrite --- organize/_old_config.py | 205 +++++++++++++++++++++ organize/_oldcore.py | 204 +++++++++++++++++++++ organize/actions/__init__.py | 12 ++ organize/actions/action.py | 20 ++- organize/cli.py | 12 +- organize/config.py | 238 ++++++------------------- organize/core.py | 333 ++++++++++++++--------------------- organize/filters/__init__.py | 14 ++ organize/filters/filter.py | 28 ++- 9 files changed, 663 insertions(+), 403 deletions(-) create mode 100644 organize/_old_config.py create mode 100644 organize/_oldcore.py diff --git a/organize/_old_config.py b/organize/_old_config.py new file mode 100644 index 00000000..8c65044a --- /dev/null +++ b/organize/_old_config.py @@ -0,0 +1,205 @@ +import inspect +import logging +import textwrap +from typing import Generator, List, Mapping, NamedTuple, Sequence + +import yaml + +from . import actions, filters +from .actions.action import Action +from pathlib import Path +from .filters.filter import Filter +from .utils import first_key, flatten + +logger = logging.getLogger(__name__) +Rule = NamedTuple( + "Rule", + [ + ("filters", Sequence[Filter]), + ("actions", Sequence[Action]), + ("folders", Sequence[str]), + ("subfolders", bool), + ("system_files", bool), + ], +) + +# disable yaml constructors for strings starting with exclamation marks +# https://stackoverflow.com/a/13281292/300783 +def default_yaml_cnst(loader, tag_suffix, node): + return str(node.tag) + + +yaml.add_multi_constructor("", default_yaml_cnst, Loader=yaml.SafeLoader) + + +class Config: + def __init__(self, config: dict) -> None: + self.config = config + self.filter_by_name = { + name.lower(): getattr(filters, name) + for name, _ in inspect.getmembers(filters, inspect.isclass) + } + self.action_by_name = { + name.lower(): getattr(actions, name) + for name, _ in inspect.getmembers(actions, inspect.isclass) + } + + @classmethod + def from_string(cls, config: str) -> "Config": + dedented_config = textwrap.dedent(config) + try: + return cls(yaml.load(dedented_config, Loader=yaml.SafeLoader)) + except yaml.YAMLError as e: + raise cls.ParsingError(e) + + @classmethod + def from_file(cls, path: Path) -> "Config": + with path.open(encoding="utf-8") as f: + return cls.from_string(f.read()) + + def yaml(self) -> str: + if not (self.config and "rules" in self.config): + raise self.NoRulesFoundError() + data = {"rules": self.config["rules"]} + yaml.Dumper.ignore_aliases = lambda self, data: True # type: ignore + return yaml.dump( + data, allow_unicode=True, default_flow_style=False, default_style="'" + ) + + @staticmethod + def parse_folders(rule_item) -> Generator[str, None, None]: + # the folder list is flattened so we can use encapsulated list + # definitions in the config file. + yield from flatten(rule_item["folders"]) + + @staticmethod + def sanitize_key(key): + return key.lower().replace("_", "") + + def _get_filter_class_by_name(self, name): + try: + return self.filter_by_name[self.sanitize_key(name)] + except AttributeError as e: + raise self.Error("%s is no valid filter" % name) from e + + def _get_action_class_by_name(self, name): + try: + return self.action_by_name[self.sanitize_key(name)] + except AttributeError as e: + raise self.Error("%s is no valid action" % name) from e + + @staticmethod + def _class_instance_with_args(Cls, args): + if args is None: + return Cls() + elif isinstance(args, list): + return Cls(*args) + elif isinstance(args, dict): + return Cls(**args) + return Cls(args) + + def instantiate_filters(self, rule_item: Mapping) -> Generator[Filter, None, None]: + # filter list can be empty + try: + filter_list = rule_item["filters"] + except KeyError: + return + if not filter_list: + return + if not isinstance(filter_list, list): + raise self.FiltersNoListError() + + for filter_item in flatten(filter_list): + if filter_item is None: + # TODO: don't know what this should be + continue + # filter with arguments + elif isinstance(filter_item, dict): + name = first_key(filter_item) + args = filter_item[name] + filter_class = self._get_filter_class_by_name(name) + yield self._class_instance_with_args(filter_class, args) + # only given filter name without args + elif isinstance(filter_item, str): + name = filter_item + filter_class = self._get_filter_class_by_name(name) + yield filter_class() + else: + raise self.Error("Unknown filter: %s" % filter_item) + + def instantiate_actions(self, rule_item: Mapping) -> Generator[Action, None, None]: + action_list = rule_item["actions"] + if not isinstance(action_list, list): + raise self.ActionsNoListError() + + for action_item in flatten(action_list): + if isinstance(action_item, dict): + name = first_key(action_item) + args = action_item[name] + action_class = self._get_action_class_by_name(name) + yield self._class_instance_with_args(action_class, args) + elif isinstance(action_item, str): + name = action_item + action_class = self._get_action_class_by_name(name) + yield action_class() + else: + raise self.Error("Unknown action: %s" % action_item) + + @property + def rules(self) -> List[Rule]: + """:returns: A list of instantiated Rules""" + if not (self.config and "rules" in self.config): + raise self.NoRulesFoundError() + result = [] + for i, rule_item in enumerate(self.config["rules"]): + # skip disabled rules + if not rule_item.get("enabled", True): + continue + + rule_folders = list(self.parse_folders(rule_item)) + rule_filters = list(self.instantiate_filters(rule_item)) + rule_actions = list(self.instantiate_actions(rule_item)) + + if not rule_folders: + logger.warning("No folders given for rule %s!", i + 1) + if not rule_filters: + logger.warning("No filters given for rule %s!", i + 1) + if not rule_actions: + logger.warning("No actions given for rule %s!", i + 1) + + rule = Rule( + folders=rule_folders, + filters=rule_filters, + actions=rule_actions, + subfolders=rule_item.get("subfolders", False), + system_files=rule_item.get("system_files", False), + ) + result.append(rule) + return result + + class Error(Exception): + pass + + class NoRulesFoundError(Error): + def __str__(self): + return "No rules found in configuration file" + + class ParsingError(Error): + pass + + class NoFoldersFoundError(Error): + pass + + class NoFiltersFoundError(Error): + pass + + class NoActionsFoundError(Error): + pass + + class FiltersNoListError(Error): + def __str__(self): + return "Please specify your filters as a YAML list" + + class ActionsNoListError(Error): + def __str__(self): + return "Please specify your actions as a YAML list" diff --git a/organize/_oldcore.py b/organize/_oldcore.py new file mode 100644 index 00000000..eaaa7192 --- /dev/null +++ b/organize/_oldcore.py @@ -0,0 +1,204 @@ +import logging +import os +import shutil +from copy import deepcopy +from datetime import datetime +from textwrap import indent +from typing import Generator, Iterable, List, NamedTuple, Optional, Sequence, Set, Tuple + +from colorama import Fore, Style # type: ignore + +from .actions.action import Action +from pathlib import Path +from .config import Rule +from .filters.filter import Filter +from .utils import DotDict, splitglob + +logger = logging.getLogger(__name__) +SYSTEM_FILES = ("thumbs.db", "desktop.ini", ".DS_Store") + +Job = NamedTuple( + "Job", + [ + ("folderstr", str), + ("basedir", Path), + ("path", Path), + ("filters", Sequence[Filter]), + ("actions", Sequence[Action]), + ], +) +Job.__doc__ = """ + :param str folderstr: the original folder definition specified in the config + :param Path basedir: the job's base folder + :param Path path: the path of the file to handle + :param list filters: the filters that apply to the path + :param list actions: the actions which should be executed +""" + + +class OutputHelper: + """ + class to track the current folder / file and print only changes. + This is needed because we only want to output the current folder and file if the + filter or action prints something. + """ + + def __init__(self) -> None: + self.not_found = set() # type: Set[str] + self.curr_folder = None # type: Optional[Path] + self.curr_path = None # type: Optional[Path] + self.prev_folder = None # type: Optional[Path] + self.prev_path = None # type: Optional[Path] + + def set_location(self, folder: Path, path: Path) -> None: + self.curr_folder = folder + self.curr_path = path + + def pre_print(self) -> None: + """ + pre-print hook that is called everytime the moment before a filter or action is + about to print something to the cli + """ + if self.curr_folder != self.prev_folder: + if self.prev_folder is not None: + print() # ensure newline between folders + print("Folder %s%s:" % (Style.BRIGHT, self.curr_folder)) + self.prev_folder = self.curr_folder + + if self.curr_path != self.prev_path: + print(indent("File %s%s:" % (Style.BRIGHT, self.curr_path), " " * 2)) + self.prev_path = self.curr_path + + def print_path_not_found(self, folderstr: str) -> None: + if folderstr not in self.not_found: + self.not_found.add(folderstr) + msg = "Path not found: {}".format(folderstr) + print(Fore.YELLOW + Style.BRIGHT + msg) + logger.warning(msg) + + +output_helper = OutputHelper() + + +def execute_rules(rules: Iterable[Rule], simulate: bool) -> None: + cols, _ = shutil.get_terminal_size(fallback=(79, 20)) + simulation_msg = Fore.GREEN + Style.BRIGHT + " SIMULATION ".center(cols, "~") + + jobs = create_jobs(rules=rules) + + if simulate: + print(simulation_msg) + + failed, succeded = run_jobs(jobs=jobs, simulate=simulate) + if succeded == failed == 0: + msg = "Nothing to do." + logger.info(msg) + print(msg) + + if simulate: + print(simulation_msg) + + +def create_jobs(rules: Iterable[Rule]) -> Generator[Job, None, None]: + """ creates `Job` data structures for every path handled in each rule """ + for rule in rules: + for folderstr, basedir, path in all_files_for_rule(rule): + yield Job( + folderstr=folderstr, + basedir=basedir, + path=path, + filters=rule.filters, + actions=rule.actions, + ) + + +def all_files_for_rule(rule: Rule) -> Generator[Tuple[str, Path, Path], None, None]: + files = dict() + for folderstr in rule.folders: + folderstr = folderstr.strip() + + # check whether the file / folder is prefixed with `!` to be excluded + exclude_flag = folderstr.startswith("!") + + # assemble glob expression + basedir, globstr = splitglob(folderstr.lstrip("!").strip()) + if basedir.is_dir(): + if not globstr: + globstr = "**/*" if rule.subfolders else "*" + elif basedir.is_file(): + # this allows specifying single files + globstr = basedir.name + basedir = basedir.parent + else: + output_helper.print_path_not_found(str(basedir)) + continue + + # iterate files in basedir and add to / remove from result dict + for path in basedir.glob(globstr): + if path.is_file() and (rule.system_files or path.name not in SYSTEM_FILES): + if not exclude_flag: + files[path] = (folderstr, basedir) + elif path in files: + del files[path] + + for path, (folderstr, basedir) in files.items(): + yield (folderstr, basedir, path) + + +def run_jobs(jobs: Iterable[Job], simulate: bool) -> List[int]: + """ :returns: The number of successfully handled files """ + count = [0, 0] + Action.pre_print_hook = output_helper.pre_print + Filter.pre_print_hook = output_helper.pre_print + + for job in sorted(jobs, key=lambda x: (x.folderstr, x.basedir, x.path)): + args = DotDict( + path=job.path, + basedir=job.basedir, + simulate=simulate, + relative_path=job.path.relative_to(job.basedir), + env=os.environ, + now=datetime.now(), + ) + + output_helper.set_location(job.basedir, args.relative_path) + match = filter_pipeline(filters=job.filters, args=args) + if match: + success = action_pipeline(actions=job.actions, args=args) + count[success] += 1 + return count + + +def filter_pipeline(filters: Iterable[Filter], args: DotDict) -> bool: + """ + run the filter pipeline. + Returns True on a match, False otherwise and updates `args` in the process. + """ + for filter_ in filters: + try: + result = filter_.pipeline(deepcopy(args)) + if isinstance(result, dict): + args.update(result) + elif not result: + # filters might return a simple True / False. + # Exit early if a filter does not match. + return False + except Exception as e: # pylint: disable=broad-except + logger.exception(e) + filter_.print(Fore.RED + Style.BRIGHT + "ERROR! %s" % e) + return False + return True + + +def action_pipeline(actions: Iterable[Action], args: DotDict) -> bool: + for action in actions: + try: + updates = action.pipeline(deepcopy(args)) + # jobs may return a dict with updates that should be merged into args + if updates is not None: + args.update(updates) + except Exception as e: # pylint: disable=broad-except + logger.exception(e) + action.print(Fore.RED + Style.BRIGHT + "ERROR! %s" % e) + return False + return True diff --git a/organize/actions/__init__.py b/organize/actions/__init__.py index 7b92a89d..28fa8a33 100644 --- a/organize/actions/__init__.py +++ b/organize/actions/__init__.py @@ -7,3 +7,15 @@ from .rename import Rename from .shell import Shell from .trash import Trash + +ALL = { + "copy": Copy, + "delete": Delete, + "echo": Echo, + "macos_tags": MacOSTags, + "move": Move, + "python": Python, + "rename": Rename, + "shell": Shell, + "trash": Trash, +} diff --git a/organize/actions/action.py b/organize/actions/action.py index dfdbaccf..6bc3da5e 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -13,7 +13,23 @@ class TemplateAttributeError(Error): class Action: - pre_print_hook = None # type: Optional[Callable] + pre_print_hook = None # type: Optional[Callable] + + @classmethod + def name(cls): + return cls.__name__ + + @classmethod + def schema(cls): + from schema import Schema, Optional, Or + + return { + Optional(cls.name().lower()): Or( + str, + [str], + Schema({}, ignore_extra_keys=True), + ), + } def run(self, **kwargs) -> Optional[Mapping[str, Any]]: return self.pipeline(DotDict(kwargs)) @@ -22,7 +38,7 @@ def pipeline(self, args: DotDict) -> Optional[Mapping[str, Any]]: raise NotImplementedError def print(self, msg) -> None: - """ print a message for the user """ + """print a message for the user""" if callable(self.pre_print_hook): self.pre_print_hook() # pylint: disable=not-callable print(indent("- [%s] %s" % (self.__class__.__name__, msg), " " * 4)) diff --git a/organize/cli.py b/organize/cli.py index 2be90b2c..28a5ec80 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -43,7 +43,7 @@ def main(argv=None): - """ entry point for the command line interface """ + """entry point for the command line interface""" args = docopt(__doc__, argv=argv, version=__version__, help=True) # override default config file path @@ -89,7 +89,7 @@ def main(argv=None): def config_edit(config_path: Path) -> None: - """ open the config file in $EDITOR or default text editor """ + """open the config file in $EDITOR or default text editor""" # attention: the env variable might contain command line arguments. # https://github.com/tfeldmann/organize/issues/24 editor = os.getenv("EDITOR") @@ -100,15 +100,15 @@ def config_edit(config_path: Path) -> None: def open_in_filemanager(path: Path) -> None: - """ opens the given path in file manager, using the default application """ + """opens the given path in file manager, using the default application""" import webbrowser # pylint: disable=import-outside-toplevel webbrowser.open(path.as_uri()) def config_debug(config_path: Path) -> None: - """ prints the config with resolved yaml aliases, checks rules syntax and checks - whether the given folders exist + """prints the config with resolved yaml aliases, checks rules syntax and checks + whether the given folders exist """ print(str(config_path)) haserr = False @@ -138,7 +138,7 @@ def config_debug(config_path: Path) -> None: def list_actions_and_filters() -> None: - """ Prints a list of available actions and filters """ + """Prints a list of available actions and filters""" import inspect # pylint: disable=import-outside-toplevel from organize import filters, actions # pylint: disable=import-outside-toplevel diff --git a/organize/config.py b/organize/config.py index ffdd0690..de727564 100644 --- a/organize/config.py +++ b/organize/config.py @@ -1,26 +1,44 @@ -import inspect -import logging import textwrap -from typing import Generator, List, Mapping, NamedTuple, Sequence import yaml - -from . import actions, filters -from .actions.action import Action -from pathlib import Path -from .filters.filter import Filter -from .utils import first_key, flatten - -logger = logging.getLogger(__name__) -Rule = NamedTuple( - "Rule", - [ - ("filters", Sequence[Filter]), - ("actions", Sequence[Action]), - ("folders", Sequence[str]), - ("subfolders", bool), - ("system_files", bool), - ], +from rich.console import Console +from schema import And, Optional, Or, Schema, SchemaError + +from organize.actions import ALL as ACTIONS +from organize.filters import ALL as FILTERS + +console = Console() + +CONFIG_SCHEMA = Schema( + { + Optional("version"): int, + "rules": [ + { + "name": And(str, len), + "targets": Or("dirs", "files"), + "locations": [ + Or( + str, + { + "path": And(str, len), + Optional("filesystem"): str, + Optional("max_depth"): Or(int, None), + Optional("search"): Or("depth", "breadth"), + Optional("exclude_files"): [str], + Optional("exclude_dirs"): [str], + Optional("system_exlude_files"): [str], + Optional("system_exclude_dirs"): [str], + Optional("ignore_errors"): bool, + Optional("filter"): [str], + Optional("filter_dirs"): [str], + }, + ), + ], + Optional("filters"): [FILTER.schema() for FILTER in FILTERS.values()], + "actions": [ACTION.schema() for ACTION in ACTIONS.values()], + } + ], + } ) # disable yaml constructors for strings starting with exclamation marks @@ -32,174 +50,20 @@ def default_yaml_cnst(loader, tag_suffix, node): yaml.add_multi_constructor("", default_yaml_cnst, Loader=yaml.SafeLoader) -class Config: - def __init__(self, config: dict) -> None: - self.config = config - self.filter_by_name = { - name.lower(): getattr(filters, name) - for name, _ in inspect.getmembers(filters, inspect.isclass) - } - self.action_by_name = { - name.lower(): getattr(actions, name) - for name, _ in inspect.getmembers(actions, inspect.isclass) - } - - @classmethod - def from_string(cls, config: str) -> "Config": - dedented_config = textwrap.dedent(config) - try: - return cls(yaml.load(dedented_config, Loader=yaml.SafeLoader)) - except yaml.YAMLError as e: - raise cls.ParsingError(e) - - @classmethod - def from_file(cls, path: Path) -> "Config": - with path.open(encoding="utf-8") as f: - return cls.from_string(f.read()) - - def yaml(self) -> str: - if not (self.config and "rules" in self.config): - raise self.NoRulesFoundError() - data = {"rules": self.config["rules"]} - yaml.Dumper.ignore_aliases = lambda self, data: True # type: ignore - return yaml.dump( - data, allow_unicode=True, default_flow_style=False, default_style="'" - ) - - @staticmethod - def parse_folders(rule_item) -> Generator[str, None, None]: - # the folder list is flattened so we can use encapsulated list - # definitions in the config file. - yield from flatten(rule_item["folders"]) - - @staticmethod - def sanitize_key(key): - return key.lower().replace("_", "") - - def _get_filter_class_by_name(self, name): - try: - return self.filter_by_name[self.sanitize_key(name)] - except AttributeError as e: - raise self.Error("%s is no valid filter" % name) from e - - def _get_action_class_by_name(self, name): - try: - return self.action_by_name[self.sanitize_key(name)] - except AttributeError as e: - raise self.Error("%s is no valid action" % name) from e - - @staticmethod - def _class_instance_with_args(Cls, args): - if args is None: - return Cls() - elif isinstance(args, list): - return Cls(*args) - elif isinstance(args, dict): - return Cls(**args) - return Cls(args) - - def instantiate_filters(self, rule_item: Mapping) -> Generator[Filter, None, None]: - # filter list can be empty - try: - filter_list = rule_item["filters"] - except KeyError: - return - if not filter_list: - return - if not isinstance(filter_list, list): - raise self.FiltersNoListError() - - for filter_item in flatten(filter_list): - if filter_item is None: - # TODO: don't know what this should be - continue - # filter with arguments - elif isinstance(filter_item, dict): - name = first_key(filter_item) - args = filter_item[name] - filter_class = self._get_filter_class_by_name(name) - yield self._class_instance_with_args(filter_class, args) - # only given filter name without args - elif isinstance(filter_item, str): - name = filter_item - filter_class = self._get_filter_class_by_name(name) - yield filter_class() - else: - raise self.Error("Unknown filter: %s" % filter_item) +def load_from_string(config): + dedented_config = textwrap.dedent(config) + return yaml.load(dedented_config, Loader=yaml.SafeLoader) - def instantiate_actions(self, rule_item: Mapping) -> Generator[Action, None, None]: - action_list = rule_item["actions"] - if not isinstance(action_list, list): - raise self.ActionsNoListError() - for action_item in flatten(action_list): - if isinstance(action_item, dict): - name = first_key(action_item) - args = action_item[name] - action_class = self._get_action_class_by_name(name) - yield self._class_instance_with_args(action_class, args) - elif isinstance(action_item, str): - name = action_item - action_class = self._get_action_class_by_name(name) - yield action_class() - else: - raise self.Error("Unknown action: %s" % action_item) +def load_from_file(path): + with open(path, "r", encoding="utf-8") as f: + return load_from_string(f.read()) - @property - def rules(self) -> List[Rule]: - """ :returns: A list of instantiated Rules """ - if not (self.config and "rules" in self.config): - raise self.NoRulesFoundError() - result = [] - for i, rule_item in enumerate(self.config["rules"]): - # skip disabled rules - if not rule_item.get("enabled", True): - continue - rule_folders = list(self.parse_folders(rule_item)) - rule_filters = list(self.instantiate_filters(rule_item)) - rule_actions = list(self.instantiate_actions(rule_item)) - - if not rule_folders: - logger.warning("No folders given for rule %s!", i + 1) - if not rule_filters: - logger.warning("No filters given for rule %s!", i + 1) - if not rule_actions: - logger.warning("No actions given for rule %s!", i + 1) - - rule = Rule( - folders=rule_folders, - filters=rule_filters, - actions=rule_actions, - subfolders=rule_item.get("subfolders", False), - system_files=rule_item.get("system_files", False), - ) - result.append(rule) - return result - - class Error(Exception): - pass - - class NoRulesFoundError(Error): - def __str__(self): - return "No rules found in configuration file" - - class ParsingError(Error): - pass - - class NoFoldersFoundError(Error): - pass - - class NoFiltersFoundError(Error): - pass - - class NoActionsFoundError(Error): - pass - - class FiltersNoListError(Error): - def __str__(self): - return "Please specify your filters as a YAML list" - - class ActionsNoListError(Error): - def __str__(self): - return "Please specify your actions as a YAML list" +conf = load_from_file( + "/Users/thomasfeldmann/Library/Application Support/organize/config.yaml" +) +try: + CONFIG_SCHEMA.validate(conf) +except SchemaError as e: + console.print(str(e.autos[-1])) diff --git a/organize/core.py b/organize/core.py index eaaa7192..54702cc6 100644 --- a/organize/core.py +++ b/organize/core.py @@ -1,204 +1,131 @@ -import logging -import os -import shutil -from copy import deepcopy -from datetime import datetime -from textwrap import indent -from typing import Generator, Iterable, List, NamedTuple, Optional, Sequence, Set, Tuple - -from colorama import Fore, Style # type: ignore - -from .actions.action import Action -from pathlib import Path -from .config import Rule -from .filters.filter import Filter -from .utils import DotDict, splitglob - -logger = logging.getLogger(__name__) -SYSTEM_FILES = ("thumbs.db", "desktop.ini", ".DS_Store") - -Job = NamedTuple( - "Job", - [ - ("folderstr", str), - ("basedir", Path), - ("path", Path), - ("filters", Sequence[Filter]), - ("actions", Sequence[Action]), +import fs +from rich.console import Console + +console = Console() + + +def walker_args_from_options(options): + excludes = options.get( + "system_exlude_files", + [ + "thumbs.db", + "desktop.ini", + "~$*", + ".DS_Store", + ".localized", + ], + ) + excludes.extend(options.get("exclude_files", [])) + exclude_dirs = options.get( + "system_exclude_dirs", + [ + "*.git", + "*.svn", + ".venv", + ".pio", + ], + ) + exclude_dirs.extend(options.get("exclude_dirs", [])) + + return { + "ignore_errors": options.get("ignore_errors", False), + "on_error": options.get("on_error", None), + "search": options.get("search", "depth"), + "exclude": excludes, + "exclude_dirs": exclude_dirs, + "max_depth": options.get("max_depth", None), + "filter": None, + "filter_dirs": None, + } + + +config = { + "version": 1, + "rules": [ + { + "name": "Fixup old pdfs", + "targets": "files", + "locations": [ + { + "path": "~/Desktop", + "max_depth": 3, + }, + ], + "filters": [ + { + "extension": "pdf", + }, + ], + "actions": [ + { + "copy": "~/Dir", + }, + ], + }, + { + "name": "Find some folders", + "targets": "dirs", + "locations": [ + { + "path": "~/Desktop", + "max_depth": 10, + }, + { + "path": "~/Desktop/Inbox", + "max_depth": None, + }, + ], + "filters": [ + { + "extension": "pdf", + }, + ], + "actions": [ + { + "copy": "~/Dir", + }, + ], + }, ], -) -Job.__doc__ = """ - :param str folderstr: the original folder definition specified in the config - :param Path basedir: the job's base folder - :param Path path: the path of the file to handle - :param list filters: the filters that apply to the path - :param list actions: the actions which should be executed -""" - - -class OutputHelper: - """ - class to track the current folder / file and print only changes. - This is needed because we only want to output the current folder and file if the - filter or action prints something. - """ - - def __init__(self) -> None: - self.not_found = set() # type: Set[str] - self.curr_folder = None # type: Optional[Path] - self.curr_path = None # type: Optional[Path] - self.prev_folder = None # type: Optional[Path] - self.prev_path = None # type: Optional[Path] - - def set_location(self, folder: Path, path: Path) -> None: - self.curr_folder = folder - self.curr_path = path - - def pre_print(self) -> None: - """ - pre-print hook that is called everytime the moment before a filter or action is - about to print something to the cli - """ - if self.curr_folder != self.prev_folder: - if self.prev_folder is not None: - print() # ensure newline between folders - print("Folder %s%s:" % (Style.BRIGHT, self.curr_folder)) - self.prev_folder = self.curr_folder - - if self.curr_path != self.prev_path: - print(indent("File %s%s:" % (Style.BRIGHT, self.curr_path), " " * 2)) - self.prev_path = self.curr_path - - def print_path_not_found(self, folderstr: str) -> None: - if folderstr not in self.not_found: - self.not_found.add(folderstr) - msg = "Path not found: {}".format(folderstr) - print(Fore.YELLOW + Style.BRIGHT + msg) - logger.warning(msg) - - -output_helper = OutputHelper() - - -def execute_rules(rules: Iterable[Rule], simulate: bool) -> None: - cols, _ = shutil.get_terminal_size(fallback=(79, 20)) - simulation_msg = Fore.GREEN + Style.BRIGHT + " SIMULATION ".center(cols, "~") - - jobs = create_jobs(rules=rules) - - if simulate: - print(simulation_msg) - - failed, succeded = run_jobs(jobs=jobs, simulate=simulate) - if succeded == failed == 0: - msg = "Nothing to do." - logger.info(msg) - print(msg) - - if simulate: - print(simulation_msg) - - -def create_jobs(rules: Iterable[Rule]) -> Generator[Job, None, None]: - """ creates `Job` data structures for every path handled in each rule """ - for rule in rules: - for folderstr, basedir, path in all_files_for_rule(rule): - yield Job( - folderstr=folderstr, - basedir=basedir, - path=path, - filters=rule.filters, - actions=rule.actions, - ) - - -def all_files_for_rule(rule: Rule) -> Generator[Tuple[str, Path, Path], None, None]: - files = dict() - for folderstr in rule.folders: - folderstr = folderstr.strip() - - # check whether the file / folder is prefixed with `!` to be excluded - exclude_flag = folderstr.startswith("!") - - # assemble glob expression - basedir, globstr = splitglob(folderstr.lstrip("!").strip()) - if basedir.is_dir(): - if not globstr: - globstr = "**/*" if rule.subfolders else "*" - elif basedir.is_file(): - # this allows specifying single files - globstr = basedir.name - basedir = basedir.parent - else: - output_helper.print_path_not_found(str(basedir)) - continue - - # iterate files in basedir and add to / remove from result dict - for path in basedir.glob(globstr): - if path.is_file() and (rule.system_files or path.name not in SYSTEM_FILES): - if not exclude_flag: - files[path] = (folderstr, basedir) - elif path in files: - del files[path] - - for path, (folderstr, basedir) in files.items(): - yield (folderstr, basedir, path) - - -def run_jobs(jobs: Iterable[Job], simulate: bool) -> List[int]: - """ :returns: The number of successfully handled files """ - count = [0, 0] - Action.pre_print_hook = output_helper.pre_print - Filter.pre_print_hook = output_helper.pre_print - - for job in sorted(jobs, key=lambda x: (x.folderstr, x.basedir, x.path)): - args = DotDict( - path=job.path, - basedir=job.basedir, - simulate=simulate, - relative_path=job.path.relative_to(job.basedir), - env=os.environ, - now=datetime.now(), - ) - - output_helper.set_location(job.basedir, args.relative_path) - match = filter_pipeline(filters=job.filters, args=args) - if match: - success = action_pipeline(actions=job.actions, args=args) - count[success] += 1 - return count - - -def filter_pipeline(filters: Iterable[Filter], args: DotDict) -> bool: - """ - run the filter pipeline. - Returns True on a match, False otherwise and updates `args` in the process. - """ - for filter_ in filters: - try: - result = filter_.pipeline(deepcopy(args)) - if isinstance(result, dict): - args.update(result) - elif not result: - # filters might return a simple True / False. - # Exit early if a filter does not match. - return False - except Exception as e: # pylint: disable=broad-except - logger.exception(e) - filter_.print(Fore.RED + Style.BRIGHT + "ERROR! %s" % e) - return False - return True - - -def action_pipeline(actions: Iterable[Action], args: DotDict) -> bool: - for action in actions: - try: - updates = action.pipeline(deepcopy(args)) - # jobs may return a dict with updates that should be merged into args - if updates is not None: - args.update(updates) - except Exception as e: # pylint: disable=broad-except - logger.exception(e) - action.print(Fore.RED + Style.BRIGHT + "ERROR! %s" % e) - return False - return True +} + + +def instantiate_entry(d, classes): + key, value = list(d.items())[0] + if isinstance(key, str): + Class = classes[key.lower()] + if isinstance(value, dict): + return Class(**value) + return Class(value) + return {key: value} + + +def instantiate_in_place(config): + for rule in config["rules"]: + rule["filters"] = [instantiate_entry(x, FILTERS) for x in rule["filters"]] + rule["actions"] = [instantiate_entry(x, ACTIONS) for x in rule["actions"]] + + +def run(config): + for rule in config["rules"]: + target = rule.get("targets", "files") + console.print(rule["name"], style="bold") + with console.status("[bold green]organizing...") as status: + for location in rule["locations"]: + path = location["path"] + folder_fs = fs.open_fs(path) + walker_args = walker_args_from_options(location) + if target == "files": + for path in folder_fs.walk.files(**walker_args): + if ".html" in path: + console.print(folder_fs, path) + elif target == "dirs": + for path in folder_fs.walk.dirs(**walker_args): + if "PrintQueue" in path: + console.print(folder_fs, path) + + +if __name__ == "__main__": + instantiate_in_place(config) + print(config) + run(config) diff --git a/organize/filters/__init__.py b/organize/filters/__init__.py index 23067f3f..d5e86378 100644 --- a/organize/filters/__init__.py +++ b/organize/filters/__init__.py @@ -9,3 +9,17 @@ from .mimetype import MimeType from .python import Python from .regex import Regex + +ALL = { + "created": Created, + "duplicate": Duplicate, + "exif": Exif, + "extension": Extension, + "file_content": FileContent, + "filename": Filename, + "filesize": FileSize, + "last_modified": LastModified, + "mimetype": MimeType, + "python": Python, + "regex": Regex, +} diff --git a/organize/filters/filter.py b/organize/filters/filter.py index a2c6ee87..87f81888 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -1,5 +1,6 @@ +from schema import Schema, Optional, Or from textwrap import indent -from typing import Any, Callable, Dict, Optional, Union +from typing import Any, Dict, Union from organize.utils import DotDict @@ -7,7 +8,24 @@ class Filter: - pre_print_hook = None # type: Optional[Callable] + pre_print_hook = None + + @classmethod + def name(cls): + return cls.__name__.lower() + + @classmethod + def schema(cls): + return Or( + cls.name(), + { + Optional(cls.name()): Or( + str, + [str], + Schema({}, ignore_extra_keys=True), + ), + }, + ) def run(self, **kwargs: Dict) -> FilterResult: return self.pipeline(DotDict(kwargs)) @@ -16,14 +34,14 @@ def pipeline(self, args: DotDict) -> FilterResult: raise NotImplementedError def print(self, msg: str) -> None: - """ print a message for the user """ + """print a message for the user""" if callable(self.pre_print_hook): self.pre_print_hook() # pylint: disable=not-callable print(indent("- (%s) %s" % (self.__class__.__name__, msg), " " * 4)) def __str__(self) -> str: - """ Return filter name and properties """ - return self.__class__.__name__ + """Return filter name and properties""" + return self.name() def __repr__(self) -> str: return "<%s>" % str(self) From 6c016c3ea6480feb9f256c85cd07d99a718f25de Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 21 Jan 2022 12:05:33 +0100 Subject: [PATCH 005/108] integrate the pipelines --- organize/actions/echo.py | 2 +- organize/config.py | 10 +- organize/core.py | 233 ++++++++++++++++++++-------------- organize/filters/extension.py | 11 +- organize/filters/filename.py | 2 +- organize/output.py | 73 +++++++++++ organize/testconf.json | 49 +++++++ organize/testconf.yaml | 21 +++ 8 files changed, 291 insertions(+), 110 deletions(-) create mode 100644 organize/output.py create mode 100644 organize/testconf.json create mode 100644 organize/testconf.yaml diff --git a/organize/actions/echo.py b/organize/actions/echo.py index 8c502ba7..adf816bf 100644 --- a/organize/actions/echo.py +++ b/organize/actions/echo.py @@ -72,7 +72,7 @@ def __init__(self, msg) -> None: self.msg = msg self.log = logging.getLogger(__name__) - def pipeline(self, args) -> None: + def pipeline(self, args: dict, simulate: bool) -> None: path = args["path"] logger.debug('Echo msg "%s", path: "%s", args: "%s"', self.msg, path, args) full_msg = self.fill_template_tags(self.msg, args) diff --git a/organize/config.py b/organize/config.py index de727564..cc14926e 100644 --- a/organize/config.py +++ b/organize/config.py @@ -2,7 +2,7 @@ import yaml from rich.console import Console -from schema import And, Optional, Or, Schema, SchemaError +from schema import And, Optional, Or, Schema, SchemaError, Literal from organize.actions import ALL as ACTIONS from organize.filters import ALL as FILTERS @@ -14,8 +14,8 @@ Optional("version"): int, "rules": [ { - "name": And(str, len), - "targets": Or("dirs", "files"), + Optional("name", description="The name of the rule"): And(str, len), + Optional("targets"): Or("dirs", "files"), "locations": [ Or( str, @@ -60,9 +60,7 @@ def load_from_file(path): return load_from_string(f.read()) -conf = load_from_file( - "/Users/thomasfeldmann/Library/Application Support/organize/config.yaml" -) +conf = load_from_file("organize/testconf.yaml") try: CONFIG_SCHEMA.validate(conf) except SchemaError as e: diff --git a/organize/core.py b/organize/core.py index 54702cc6..18d57f0a 100644 --- a/organize/core.py +++ b/organize/core.py @@ -1,32 +1,42 @@ +import logging +from collections import namedtuple +from typing import Iterable + import fs -from rich.console import Console +from fs.walk import Walker -console = Console() +from .actions import ALL as ACTIONS +from .actions.action import Action +from .filters import ALL as FILTERS +from .filters.filter import Filter +from .output import RichOutput, console +from .utils import DotDict +logger = logging.getLogger(__name__) +Location = namedtuple("Location", "walker base_fs path") +output_helper = RichOutput() -def walker_args_from_options(options): - excludes = options.get( - "system_exlude_files", - [ - "thumbs.db", - "desktop.ini", - "~$*", - ".DS_Store", - ".localized", - ], - ) +DEFAULT_SYSTEM_EXCLUDE_FILES = [ + "thumbs.db", + "desktop.ini", + "~$*", + ".DS_Store", + ".localized", +] + +DEFAULT_SYSTEM_EXCLUDE_DIRS = [ + ".git", + ".svn", +] + + +def walker_args_from_location_options(options): + # combine system_exclude and exclude into a single list + excludes = options.get("system_exlude_files", DEFAULT_SYSTEM_EXCLUDE_FILES) excludes.extend(options.get("exclude_files", [])) - exclude_dirs = options.get( - "system_exclude_dirs", - [ - "*.git", - "*.svn", - ".venv", - ".pio", - ], - ) + exclude_dirs = options.get("system_exclude_dirs", DEFAULT_SYSTEM_EXCLUDE_DIRS) exclude_dirs.extend(options.get("exclude_dirs", [])) - + # return all the default options return { "ignore_errors": options.get("ignore_errors", False), "on_error": options.get("on_error", None), @@ -39,93 +49,122 @@ def walker_args_from_options(options): } -config = { - "version": 1, - "rules": [ - { - "name": "Fixup old pdfs", - "targets": "files", - "locations": [ - { - "path": "~/Desktop", - "max_depth": 3, - }, - ], - "filters": [ - { - "extension": "pdf", - }, - ], - "actions": [ - { - "copy": "~/Dir", - }, - ], - }, - { - "name": "Find some folders", - "targets": "dirs", - "locations": [ - { - "path": "~/Desktop", - "max_depth": 10, - }, - { - "path": "~/Desktop/Inbox", - "max_depth": None, - }, - ], - "filters": [ - { - "extension": "pdf", - }, - ], - "actions": [ - { - "copy": "~/Dir", - }, - ], - }, - ], -} - - -def instantiate_entry(d, classes): +def instantiate_location(loc): + if isinstance(loc, str): + loc = {"path": loc} + + if "walker" not in loc: + args = walker_args_from_location_options(loc) + walker = Walker(**args) + else: + walker = loc["walker"] + + if "fs" in loc: + base_fs = fs.open_fs(loc["fs"]) + path = loc.get("path", "/") + else: + base_fs = fs.open_fs(loc["path"]) + path = "/" + + return Location( + walker=walker, + base_fs=base_fs, + path=path, + ) + + +def instantiate_by_name(d, classes): key, value = list(d.items())[0] if isinstance(key, str): - Class = classes[key.lower()] + Class = classes[key] if isinstance(value, dict): return Class(**value) return Class(value) - return {key: value} + return d -def instantiate_in_place(config): +def replace_with_instances(config): for rule in config["rules"]: - rule["filters"] = [instantiate_entry(x, FILTERS) for x in rule["filters"]] - rule["actions"] = [instantiate_entry(x, ACTIONS) for x in rule["actions"]] + rule["locations"] = [instantiate_location(loc) for loc in rule["locations"]] + rule["filters"] = [instantiate_by_name(x, FILTERS) for x in rule["filters"]] + rule["actions"] = [instantiate_by_name(x, ACTIONS) for x in rule["actions"]] + + +def filter_pipeline(filters: Iterable[Filter], args: DotDict, simulate: bool) -> bool: + """ + run the filter pipeline. + Returns True on a match, False otherwise and updates `args` in the process. + """ + for filter_ in filters: + try: + result = filter_.pipeline(args, simulate=simulate) + if isinstance(result, dict): + args.update(result) + elif not result: + # filters might return a simple True / False. + # Exit early if a filter does not match. + return False + except Exception as e: # pylint: disable=broad-except + logger.exception(e) + console.print_exception() + # filter_.print(Fore.RED + Style.BRIGHT + "ERROR! %s" % e) + return False + return True -def run(config): +def action_pipeline(actions: Iterable[Action], args: DotDict, simulate: bool) -> bool: + for action in actions: + try: + updates = action.pipeline(args, simulate=simulate) + # jobs may return a dict with updates that should be merged into args + if updates is not None: + args.update(updates) + except Exception as e: # pylint: disable=broad-except + logger.exception(e) + console.print_exception() + # action.print(Fore.RED + Style.BRIGHT + "ERROR! %s" % e) + return False + return True + + +def run(config, simulate: bool = True): + count = [0, 0] + Action.pre_print_hook = output_helper.pre_print + Filter.pre_print_hook = output_helper.pre_print + for rule in config["rules"]: target = rule.get("targets", "files") console.print(rule["name"], style="bold") - with console.status("[bold green]organizing...") as status: - for location in rule["locations"]: - path = location["path"] - folder_fs = fs.open_fs(path) - walker_args = walker_args_from_options(location) - if target == "files": - for path in folder_fs.walk.files(**walker_args): - if ".html" in path: - console.print(folder_fs, path) - elif target == "dirs": - for path in folder_fs.walk.dirs(**walker_args): - if "PrintQueue" in path: - console.print(folder_fs, path) + + status_verb = "simulating" if simulate else "organizing" + with console.status("[bold green]%s..." % status_verb) as status: + for walker, base_fs, base_path in rule["locations"]: + walk = walker.files if target == "files" else walker.dirs + for path in walk(fs=base_fs, path=base_path): + args = { + "simulate": simulate, + "base_fs": base_fs, + "path": path, + } + output_helper.set_location(base_fs, path) + match = filter_pipeline( + filters=rule["filters"], + args=args, + simulate=simulate, + ) + if match: + success = action_pipeline( + actions=rule["actions"], + args=args, + simulate=simulate, + ) + count[success] += 1 if __name__ == "__main__": - instantiate_in_place(config) - print(config) - run(config) + from .config import load_from_file + + conf = load_from_file("organize/testconf.yaml") + replace_with_instances(conf) + console.print(conf) + run(conf) diff --git a/organize/filters/extension.py b/organize/filters/extension.py index f8f74946..b4118e0d 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -70,7 +70,7 @@ class Extension(Filter): rules: - folders: '~/Desktop' filters: - - Extension + - extension actions: - rename: '{path.stem}.{extension.lower}' @@ -104,7 +104,7 @@ def __init__(self, *extensions) -> None: @staticmethod def normalize_extension(ext: str) -> str: - """ strip colon and convert to lowercase """ + """strip colon and convert to lowercase""" if ext.startswith("."): return ext[1:].lower() else: @@ -117,9 +117,10 @@ def matches(self, path: Path) -> Union[bool, str]: return False return self.normalize_extension(path.suffix) in self.extensions - def pipeline(self, args: DotDict) -> Optional[Dict[str, ExtensionResult]]: - if self.matches(args.path): - result = ExtensionResult(args.path.suffix) + def pipeline(self, args: dict, simulate: bool): + path = Path(args["path"]) + if self.matches(path): + result = ExtensionResult(path.suffix) return {"extension": result} return None diff --git a/organize/filters/filename.py b/organize/filters/filename.py index a5a9125c..7187d9c0 100644 --- a/organize/filters/filename.py +++ b/organize/filters/filename.py @@ -103,7 +103,7 @@ def matches(self, path: Path) -> bool: ) return is_match - def pipeline(self, args: Dict) -> Optional[Dict[str, Any]]: + def pipeline(self, simulate: bool, args: Dict) -> Optional[Dict[str, Any]]: path = args["path"] result = self.matches(path) if result: diff --git a/organize/output.py b/organize/output.py new file mode 100644 index 00000000..48df2b5c --- /dev/null +++ b/organize/output.py @@ -0,0 +1,73 @@ +from rich.console import Console +from textwrap import indent +import logging + +logger = logging.getLogger(__name__) +console = Console() + + +class Output: + """ + class to track the current folder / file and print only changes. + This is needed because we only want to output the current folder and file if the + filter or action prints something. + """ + + def __init__(self) -> None: + self.not_found = set() + self.curr_folder = None + self.curr_path = None + self.prev_folder = None + self.prev_path = None + + def set_location(self, folder, path) -> None: + self.curr_folder = folder + self.curr_path = path + + def pre_print(self) -> None: + """ + pre-print hook that is called everytime the moment before a filter or action is + about to print something to the cli + """ + if self.curr_folder != self.prev_folder: + if self.prev_folder is not None: + self.folder_spacer() + self.print_folder(self.curr_folder) + self.prev_folder = self.curr_folder + + if self.curr_path != self.prev_path: + self.print_path(self.curr_path) + self.prev_path = self.curr_path + + def path_not_found(self, folderstr: str) -> None: + if folderstr not in self.not_found: + self.not_found.add(folderstr) + self.print_not_found(folderstr) + logger.warning("Path not found: %s", folderstr) + + def print_folder_spacer(self): + raise NotImplementedError + + def print_folder(self, folder): + raise NotImplementedError + + def print_path(self, path): + raise NotImplementedError + + def print_not_found(self, path): + raise NotImplementedError + + +class RichOutput(Output): + def print_folder_spacer(self): + console.print() + + def print_folder(self, folder): + console.print(str(folder), style="bold") + + def print_path(self, path): + console.print(indent(str(path), " " * 2), style="purple bold") + + def print_not_found(self, path): + msg = "Path not found: {}".format(path) + console.print(msg, style="bold yellow") diff --git a/organize/testconf.json b/organize/testconf.json new file mode 100644 index 00000000..f9ae6671 --- /dev/null +++ b/organize/testconf.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "rules": [ + { + "name": "Fixup old pdfs", + "targets": "files", + "locations": [ + { + "path": "~/Desktop", + "max_depth": 3 + } + ], + "filters": [ + { + "extension": "pdf" + } + ], + "actions": [ + { + "copy": "~/Dir" + } + ] + }, + { + "name": "Find some folders", + "targets": "dirs", + "locations": [ + { + "path": "~/Desktop", + "max_depth": 10 + }, + { + "path": "~/Desktop/Inbox", + "max_depth": null + } + ], + "filters": [ + { + "extension": "pdf" + } + ], + "actions": [ + { + "copy": "~/Dir" + } + ] + } + ] +} diff --git a/organize/testconf.yaml b/organize/testconf.yaml new file mode 100644 index 00000000..f2eea2ea --- /dev/null +++ b/organize/testconf.yaml @@ -0,0 +1,21 @@ +rules: + - name: "Test" + targets: files + locations: + - path: ~/Desktop + max_depth: 3 + filters: + - extension: pdf + actions: + - echo: "hallo" + + # - name: Find some folders + # targets: dirs + # locations: + # - ~/Desktop + # - path: ~/Desktop/Inbox + # max_depth: null + # filters: + # - extension: pdf + # actions: + # - copy: ~/Dir From c901c90800a26bce83cd322313adf56728b0ff74 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 21 Jan 2022 12:27:16 +0100 Subject: [PATCH 006/108] format changelog --- CHANGELOG.md | 167 +++++++++++++++++++++++++++++---------------------- 1 file changed, 96 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9b0711c..1a79e9a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,122 +1,147 @@ # Changelog ## v1.10.1 (2021-04-21) -- Action `macos_tags` now supports colors and placeholders. -- Show full expanded path if folder is not found. + +- Action `macos_tags` now supports colors and placeholders. +- Show full expanded path if folder is not found. ## v1.10.0 (2021-04-20) -- Add filter `mimetype` -- Add action `macos_tags` -- Support [`simplematch`](https://github.com/tfeldmann/simplematch) syntax in - `filename`-filter. -- Updated dependencies -- Because installing `textract` is quite hard on some platforms it is now an optional - dependency. Install it with `pip install organize-tool[textract]` -- This version needs python 3.6 minimum. Some dependencies that were simply backports - (pathlib2, typing) are removed. -- Add timezones in created and last_modified filters (Thank you, @win0err!) + +- Add filter `mimetype` +- Add action `macos_tags` +- Support [`simplematch`](https://github.com/tfeldmann/simplematch) syntax in + `filename`-filter. +- Updated dependencies +- Because installing `textract` is quite hard on some platforms it is now an optional + dependency. Install it with `pip install organize-tool[textract]` +- This version needs python 3.6 minimum. Some dependencies that were simply backports + (pathlib2, typing) are removed. +- Add timezones in created and last_modified filters (Thank you, @win0err!) ## v1.9.1 (2020-11-10) -- Add {env} variable -- Add {now} variable + +- Add {env} variable +- Add {now} variable ## v1.9 (2020-06-12) -- Add filter `Duplicate`. + +- Add filter `Duplicate`. ## v1.8.2 (2020-04-03) -- Fix a bug in the filename filter config parsing algorithm with digits-only filenames. + +- Fix a bug in the filename filter config parsing algorithm with digits-only filenames. ## v1.8.1 (2020-03-28) -- Flatten filter and action lists to allow enhanced config file configuration (Thanks to @rawdamedia!) -- Add support for multiline content filters (Thanks to @zor-el!) + +- Flatten filter and action lists to allow enhanced config file configuration (Thanks to @rawdamedia!) +- Add support for multiline content filters (Thanks to @zor-el!) ## v1.8.0 (2020-03-04) -- Added action `Delete`. -- Added filter `FileContent`. -- Python 3.4 is officially deprecated and no longer supported. -- `--config-file` command line option now supports `~` for user folder and expansion - of environment variables -- Added `years`, `months`, `weeks` and `seconds` parameter to filter `created` and - `lastmodified` + +- Added action `Delete`. +- Added filter `FileContent`. +- Python 3.4 is officially deprecated and no longer supported. +- `--config-file` command line option now supports `~` for user folder and expansion + of environment variables +- Added `years`, `months`, `weeks` and `seconds` parameter to filter `created` and + `lastmodified` ## v1.7.0 (2019-11-26) -- Added filter `Exif` to filter by image exif data. -- Placeholder variable properties are now case insensitve. + +- Added filter `Exif` to filter by image exif data. +- Placeholder variable properties are now case insensitve. ## v1.6.2 (2019-11-22) -- Fix `Rename` action (`'PosixPath' object has no attribute 'items'`). -- Use type hints everywhere. + +- Fix `Rename` action (`'PosixPath' object has no attribute 'items'`). +- Use type hints everywhere. ## v1.6.1 (2019-10-25) -- Shows a warning for missing folders instead of raising an exception. + +- Shows a warning for missing folders instead of raising an exception. ## v1.6 (2019-08-19) -- Added filter: `Python` -- Added filter: `FileSize` -- The organize module can now be run directly: `python3 -m organize` -- Various code simplifications and speedups. -- Fixes an issue with globstring file exclusion. -- Remove `clint` dependency as it is no longer maintained. -- Added various integration tests -- The "~~ SIMULATION ~~"-banner now takes up the whole terminal width + +- Added filter: `Python` +- Added filter: `FileSize` +- The organize module can now be run directly: `python3 -m organize` +- Various code simplifications and speedups. +- Fixes an issue with globstring file exclusion. +- Remove `clint` dependency as it is no longer maintained. +- Added various integration tests +- The "~~ SIMULATION ~~"-banner now takes up the whole terminal width ## v1.5.3 (2019-08-01) -- Filename filter now supports lists. + +- Filename filter now supports lists. ## v1.5.2 (2019-07-29) -- Environment variables in folder pathes are now expanded (syntax `$name` or `${name}` - and additionally `%name%` on windows). - For example this allows the usage of e.g. `%public/Desktop%` in windows. + +- Environment variables in folder pathes are now expanded (syntax `$name` or `${name}` + and additionally `%name%` on windows). + For example this allows the usage of e.g. `%public/Desktop%` in windows. ## v1.5.1 (2019-07-23) -- New filter "Created" to filter by creation date. -- Fixes issue #39 where globstrings don't work most of the time. -- Integration test for issue #39 -- Support indented config files + +- New filter "Created" to filter by creation date. +- Fixes issue #39 where globstrings don't work most of the time. +- Integration test for issue #39 +- Support indented config files ## v1.5 (2019-07-17) -- Fixes issue #31 where the {path} variable always resolves to the source path -- Updated dependencies -- Exclude changelog and readme from published wheel + +- Fixes issue #31 where the {path} variable always resolves to the source path +- Updated dependencies +- Exclude changelog and readme from published wheel ## v1.4.5 (2019-07-03) -- Filter and Actions names are now case-insensitive + +- Filter and Actions names are now case-insensitive ## v1.4.4 (2019-07-02) -- Fixes issues #36 with umlauts in config file on windows + +- Fixes issues #36 with umlauts in config file on windows ## v1.4.3 (2019-06-05) -- Use safe YAML loader to fix a deprecation warning. (Thanks mope1!) -- Better error message if a folder does not exist. (Again thanks mope1!) -- Fix example code in documentation for LastModified filter. -- Custom config file locations (given by cmd line argument or environment variable). -- `config --debug` now shows the full path to the config file. + +- Use safe YAML loader to fix a deprecation warning. (Thanks mope1!) +- Better error message if a folder does not exist. (Again thanks mope1!) +- Fix example code in documentation for LastModified filter. +- Custom config file locations (given by cmd line argument or environment variable). +- `config --debug` now shows the full path to the config file. ## v1.4.2 (2018-11-14) -- Fixes a bug with command line arguments in the ``$EDITOR`` environment - variable. -- Fixes a bug where an empty config wouldn't show the correct error message. -- Fix binary wheel creation in setup.py by using environment markers + +- Fixes a bug with command line arguments in the `$EDITOR` environment + variable. +- Fixes a bug where an empty config wouldn't show the correct error message. +- Fix binary wheel creation in setup.py by using environment markers ## v1.4.1 (2018-10-05) -- A custom separator ``counter_separator`` can now be set in the actions Move, - Copy and Rename. + +- A custom separator `counter_separator` can now be set in the actions Move, + Copy and Rename. ## v1.4 (2018-09-21) -- Fixes a bug where glob wildcards are not detected correctly -- Adds support for excluding folders and files via glob syntax. -- Makes sure that files are only handled once per rule. + +- Fixes a bug where glob wildcards are not detected correctly +- Adds support for excluding folders and files via glob syntax. +- Makes sure that files are only handled once per rule. ## v1.3 (2018-07-06) -- Glob support in folder configuration. -- New variable {relative_path} is now available in actions. + +- Glob support in folder configuration. +- New variable {relative_path} is now available in actions. ## v1.2 (2018-03-19) -- Shows the relative path to files in subfolders. + +- Shows the relative path to files in subfolders. ## v1.1 (2018-03-13) -- Removes the colon from extension filter output so `{extension.lower}` now - returns `'png'` instead of `'.png'`. + +- Removes the colon from extension filter output so `{extension.lower}` now + returns `'png'` instead of `'.png'`. ## v1.0 (2018-03-13) -- Initial release. + +- Initial release. From a48b7aded0668b4a7cc2ae39d9062d54cfb14eb5 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 21 Jan 2022 13:45:44 +0100 Subject: [PATCH 007/108] add simulate arg --- .editorconfig | 4 + CHANGELOG.md | 147 ++++++++++++------------ README.md | 136 +++++++++++----------- organize/actions/action.py | 5 +- organize/actions/copy.py | 3 +- organize/actions/delete.py | 3 +- organize/actions/macos_tags.py | 3 +- organize/actions/move.py | 3 +- organize/actions/python.py | 3 +- organize/actions/rename.py | 3 +- organize/actions/shell.py | 4 +- organize/actions/trash.py | 3 +- organize/config.py | 12 +- organize/core.py | 20 ++-- organize/filters/created.py | 2 +- organize/filters/extension.py | 2 +- organize/filters/filename.py | 2 +- organize/filters/filter.py | 3 +- organize/{ => migration}/_old_config.py | 0 organize/{ => migration}/_oldcore.py | 4 +- organize/output.py | 17 ++- organize/testconf.json | 49 -------- organize/testconf.yaml => testconf.yaml | 0 23 files changed, 194 insertions(+), 234 deletions(-) rename organize/{ => migration}/_old_config.py (100%) rename organize/{ => migration}/_oldcore.py (98%) delete mode 100644 organize/testconf.json rename organize/testconf.yaml => testconf.yaml (100%) diff --git a/.editorconfig b/.editorconfig index fb560b2e..2e201952 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,3 +18,7 @@ indent_size = 2 [{*.yml,*.yaml}] indent_size = 2 + +[*.md] +indent_size = 2 +indent_style = space diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a79e9a7..f84aa8dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,147 +1,152 @@ # Changelog +## In Progress + +- config file validation +- + ## v1.10.1 (2021-04-21) -- Action `macos_tags` now supports colors and placeholders. -- Show full expanded path if folder is not found. +- Action `macos_tags` now supports colors and placeholders. +- Show full expanded path if folder is not found. ## v1.10.0 (2021-04-20) -- Add filter `mimetype` -- Add action `macos_tags` -- Support [`simplematch`](https://github.com/tfeldmann/simplematch) syntax in - `filename`-filter. -- Updated dependencies -- Because installing `textract` is quite hard on some platforms it is now an optional - dependency. Install it with `pip install organize-tool[textract]` -- This version needs python 3.6 minimum. Some dependencies that were simply backports - (pathlib2, typing) are removed. -- Add timezones in created and last_modified filters (Thank you, @win0err!) +- Add filter `mimetype` +- Add action `macos_tags` +- Support [`simplematch`](https://github.com/tfeldmann/simplematch) syntax in + `lename`-filter. +- Updated dependencies +- Because installing `textract` is quite hard on some platforms it is now an optional + dendency. Install it with `pip install organize-tool[textract]` +- This version needs python 3.6 minimum. Some dependencies that were simply backports + (thlib2, typing) are removed. +- Add timezones in created and last_modified filters (Thank you, @win0err!) ## v1.9.1 (2020-11-10) -- Add {env} variable -- Add {now} variable +- Add {env} variable +- Add {now} variable ## v1.9 (2020-06-12) -- Add filter `Duplicate`. +- Add filter `Duplicate`. ## v1.8.2 (2020-04-03) -- Fix a bug in the filename filter config parsing algorithm with digits-only filenames. +- Fix a bug in the filename filter config parsing algorithm with digits-only filenames. ## v1.8.1 (2020-03-28) -- Flatten filter and action lists to allow enhanced config file configuration (Thanks to @rawdamedia!) -- Add support for multiline content filters (Thanks to @zor-el!) +- Flatten filter and action lists to allow enhanced config file configuration (Thanks to @rawdamedia!) +- Add support for multiline content filters (Thanks to @zor-el!) ## v1.8.0 (2020-03-04) -- Added action `Delete`. -- Added filter `FileContent`. -- Python 3.4 is officially deprecated and no longer supported. -- `--config-file` command line option now supports `~` for user folder and expansion - of environment variables -- Added `years`, `months`, `weeks` and `seconds` parameter to filter `created` and - `lastmodified` +- Added action `Delete`. +- Added filter `FileContent`. +- Python 3.4 is officially deprecated and no longer supported. +- `--config-file` command line option now supports `~` for user folder and expansion + oenvironment variables +- Added `years`, `months`, `weeks` and `seconds` parameter to filter `created` and + `stmodified` ## v1.7.0 (2019-11-26) -- Added filter `Exif` to filter by image exif data. -- Placeholder variable properties are now case insensitve. +- Added filter `Exif` to filter by image exif data. +- Placeholder variable properties are now case insensitve. ## v1.6.2 (2019-11-22) -- Fix `Rename` action (`'PosixPath' object has no attribute 'items'`). -- Use type hints everywhere. +- Fix `Rename` action (`'PosixPath' object has no attribute 'items'`). +- Use type hints everywhere. ## v1.6.1 (2019-10-25) -- Shows a warning for missing folders instead of raising an exception. +- Shows a warning for missing folders instead of raising an exception. ## v1.6 (2019-08-19) -- Added filter: `Python` -- Added filter: `FileSize` -- The organize module can now be run directly: `python3 -m organize` -- Various code simplifications and speedups. -- Fixes an issue with globstring file exclusion. -- Remove `clint` dependency as it is no longer maintained. -- Added various integration tests -- The "~~ SIMULATION ~~"-banner now takes up the whole terminal width +- Added filter: `Python` +- Added filter: `FileSize` +- The organize module can now be run directly: `python3 -m organize` +- Various code simplifications and speedups. +- Fixes an issue with globstring file exclusion. +- Remove `clint` dependency as it is no longer maintained. +- Added various integration tests +- The "~~ SIMULATION ~~"-banner now takes up the whole terminal width ## v1.5.3 (2019-08-01) -- Filename filter now supports lists. +- Filename filter now supports lists. ## v1.5.2 (2019-07-29) -- Environment variables in folder pathes are now expanded (syntax `$name` or `${name}` - and additionally `%name%` on windows). - For example this allows the usage of e.g. `%public/Desktop%` in windows. +- Environment variables in folder pathes are now expanded (syntax `$name` or `${name}` + a additionally `%name%` on windows). + F example this allows the usage of e.g. `%public/Desktop%` in windows. ## v1.5.1 (2019-07-23) -- New filter "Created" to filter by creation date. -- Fixes issue #39 where globstrings don't work most of the time. -- Integration test for issue #39 -- Support indented config files +- New filter "Created" to filter by creation date. +- Fixes issue #39 where globstrings don't work most of the time. +- Integration test for issue #39 +- Support indented config files ## v1.5 (2019-07-17) -- Fixes issue #31 where the {path} variable always resolves to the source path -- Updated dependencies -- Exclude changelog and readme from published wheel +- Fixes issue #31 where the {path} variable always resolves to the source path +- Updated dependencies +- Exclude changelog and readme from published wheel ## v1.4.5 (2019-07-03) -- Filter and Actions names are now case-insensitive +- Filter and Actions names are now case-insensitive ## v1.4.4 (2019-07-02) -- Fixes issues #36 with umlauts in config file on windows +- Fixes issues #36 with umlauts in config file on windows ## v1.4.3 (2019-06-05) -- Use safe YAML loader to fix a deprecation warning. (Thanks mope1!) -- Better error message if a folder does not exist. (Again thanks mope1!) -- Fix example code in documentation for LastModified filter. -- Custom config file locations (given by cmd line argument or environment variable). -- `config --debug` now shows the full path to the config file. +- Use safe YAML loader to fix a deprecation warning. (Thanks mope1!) +- Better error message if a folder does not exist. (Again thanks mope1!) +- Fix example code in documentation for LastModified filter. +- Custom config file locations (given by cmd line argument or environment variable). +- `config --debug` now shows the full path to the config file. ## v1.4.2 (2018-11-14) -- Fixes a bug with command line arguments in the `$EDITOR` environment - variable. -- Fixes a bug where an empty config wouldn't show the correct error message. -- Fix binary wheel creation in setup.py by using environment markers +- Fixes a bug with command line arguments in the `$EDITOR` environment + viable. +- Fixes a bug where an empty config wouldn't show the correct error message. +- Fix binary wheel creation in setup.py by using environment markers ## v1.4.1 (2018-10-05) -- A custom separator `counter_separator` can now be set in the actions Move, - Copy and Rename. +- A custom separator `counter_separator` can now be set in the actions Move, + Cy and Rename. ## v1.4 (2018-09-21) -- Fixes a bug where glob wildcards are not detected correctly -- Adds support for excluding folders and files via glob syntax. -- Makes sure that files are only handled once per rule. +- Fixes a bug where glob wildcards are not detected correctly +- Adds support for excluding folders and files via glob syntax. +- Makes sure that files are only handled once per rule. ## v1.3 (2018-07-06) -- Glob support in folder configuration. -- New variable {relative_path} is now available in actions. +- Glob support in folder configuration. +- New variable {relative_path} is now available in actions. ## v1.2 (2018-03-19) -- Shows the relative path to files in subfolders. +- Shows the relative path to files in subfolders. ## v1.1 (2018-03-13) -- Removes the colon from extension filter output so `{extension.lower}` now - returns `'png'` instead of `'.png'`. +- Removes the colon from extension filter output so `{extension.lower}` now + rurns `'png'` instead of `'.png'`. ## v1.0 (2018-03-13) -- Initial release. +- Initial release. diff --git a/README.md b/README.md index b4c21eb6..c40848e8 100644 --- a/README.md +++ b/README.md @@ -61,12 +61,12 @@ In your shell, **run `organize config`** to edit the configuration: ```yaml rules: - - folders: ~/Downloads - subfolders: true - filters: - - extension: pdf - actions: - - echo: "Found PDF!" + - folders: ~/Downloads + subfolders: true + filters: + - extension: pdf + actions: + - echo: "Found PDF!" ``` > If you have problems editing the configuration you can run `organize config --open-folder` to reveal the configuration folder in your file manager. You can then edit the `config.yaml` in your favourite editor. @@ -83,8 +83,8 @@ Run `organize config` again and add a `copy`-action to your rule: ```yaml actions: - - echo: "Found PDF!" - - move: ~/Documents/PDFs/ + - echo: "Found PDF!" + - move: ~/Documents/PDFs/ ``` **Now run `organize sim` to see what would happen without touching your files**. You will see that your pdf-files would be moved over to your `Documents/PDFs` folder. @@ -101,77 +101,77 @@ Move all invoices, orders or purchase documents into your documents folder: ```yaml rules: - # sort my invoices and receipts - - folders: ~/Downloads - subfolders: true - filters: - - extension: pdf - - filename: - contains: - - Invoice - - Order - - Purchase - case_sensitive: false - actions: - - move: ~/Documents/Shopping/ + # sort my invoices and receipts + - folders: ~/Downloads + subfolders: true + filters: + - extension: pdf + - filename: + contains: + - Invoice + - Order + - Purchase + case_sensitive: false + actions: + - move: ~/Documents/Shopping/ ``` Move incomplete downloads older than 30 days into the trash: ```yaml rules: - # move incomplete downloads older > 30 days into the trash - - folders: ~/Downloads - filters: - - extension: - - download - - crdownload - - part - - lastmodified: - days: 30 - mode: older - actions: - - trash + # move incomplete downloads older > 30 days into the trash + - folders: ~/Downloads + filters: + - extension: + - download + - crdownload + - part + - lastmodified: + days: 30 + mode: older + actions: + - trash ``` Delete empty files from downloads and desktop: ```yaml rules: - # delete empty files from downloads and desktop - - folders: - - ~/Downloads - - ~/Desktop - filters: - - filesize: 0 - actions: - - trash + # delete empty files from downloads and desktop + - folders: + - ~/Downloads + - ~/Desktop + filters: + - filesize: 0 + actions: + - trash ``` Move screenshots into a "Screenshots" folder on your desktop: ```yaml rules: - # move screenshots into "Screenshots" folder - - folders: ~/Desktop - filters: - - filename: - startswith: "Screen Shot" - actions: - - move: ~/Desktop/Screenshots/ + # move screenshots into "Screenshots" folder + - folders: ~/Desktop + filters: + - filename: + startswith: "Screen Shot" + actions: + - move: ~/Desktop/Screenshots/ ``` Organize your font downloads: ```yaml rules: - # organize your font files but keep the folder structure: - # "~/Downloads/favourites/helvetica/helvetica-bold.ttf" - # is moved to - # "~/Documents/FONTS/favourites/helvetica/helvetica-bold.ttf" - - folders: ~/Downloads/**/*.ttf - actions: - - Move: "~/Documents/FONTS/{relative_path}" + # organize your font files but keep the folder structure: + # "~/Downloads/favourites/helvetica/helvetica-bold.ttf" + # is moved to + # "~/Documents/FONTS/favourites/helvetica/helvetica-bold.ttf" + - folders: ~/Downloads/**/*.ttf + actions: + - Move: "~/Documents/FONTS/{relative_path}" ``` You'll find many more examples in the full documentation. @@ -183,25 +183,25 @@ actions, recursion through subfolders and glob syntax: ```yaml rules: - - folders: ~/Documents/**/* - filters: - - extension: - - pdf - - docx - - created - actions: - - move: "~/Documents/{extension.upper}/{created.year}{created.month:02}/" - - shell: 'open "{path}"' + - folders: ~/Documents/**/* + filters: + - extension: + - pdf + - docx + - created + actions: + - move: "~/Documents/{extension.upper}/{created.year}{created.month:02}/" + - shell: 'open "{path}"' ``` Given we have two files in our `~/Documents` folder (or any of its subfolders) named `script.docx` from january 2018 and `demo.pdf` from december 2016 this will happen: -- `script.docx` will be moved to `~/Documents/DOCX/2018-01/script.docx` -- `demo.pdf` will be moved to `~/Documents/PDF/2016-12/demo.pdf` -- The files will be opened (`open` command in macOS) _from their new location_. -- Note the format syntax for `{created.month}` to make sure the month is prepended with a zero. +- `script.docx` will be moved to `~/Documents/DOCX/2018-01/script.docx` +- `demo.pdf` will be moved to `~/Documents/PDF/2016-12/demo.pdf` +- The files will be opened (`open` command in macOS) _from their new location_. +- Note the format syntax for `{created.month}` to make sure the month is prepended with a zero. ## Command line interface diff --git a/organize/actions/action.py b/organize/actions/action.py index 6bc3da5e..541e39ae 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -17,7 +17,7 @@ class Action: @classmethod def name(cls): - return cls.__name__ + return cls.__name__.lower() @classmethod def schema(cls): @@ -40,8 +40,7 @@ def pipeline(self, args: DotDict) -> Optional[Mapping[str, Any]]: def print(self, msg) -> None: """print a message for the user""" if callable(self.pre_print_hook): - self.pre_print_hook() # pylint: disable=not-callable - print(indent("- [%s] %s" % (self.__class__.__name__, msg), " " * 4)) + self.pre_print_hook(name=self.name(), msg=msg) @staticmethod def fill_template_tags(msg: str, args) -> str: diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 66f8c67d..4a4759a8 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -95,9 +95,8 @@ def __init__( self.on_conflict = on_conflict self.counter_separator = counter_separator - def pipeline(self, args: Mapping) -> None: + def pipeline(self, args: Mapping, simulate: bool) -> None: path = args["path"] - simulate = args["simulate"] expanded_dest = self.fill_template_tags(self.dest, args) # if only a folder path is given we append the filename to have the full diff --git a/organize/actions/delete.py b/organize/actions/delete.py index 997e7969..e5698b9f 100644 --- a/organize/actions/delete.py +++ b/organize/actions/delete.py @@ -35,9 +35,8 @@ class Delete(Action): - delete """ - def pipeline(self, args: Mapping): + def pipeline(self, args: Mapping, simulate: bool): path = args["path"] # type: Path - simulate = args["simulate"] # type: bool self.print('Delete "%s"' % path) if not simulate: logger.info("Deleting file %s.", path) diff --git a/organize/actions/macos_tags.py b/organize/actions/macos_tags.py index 86e18597..4f0c06e9 100644 --- a/organize/actions/macos_tags.py +++ b/organize/actions/macos_tags.py @@ -79,9 +79,8 @@ class MacOSTags(Action): def __init__(self, *tags): self.tags = tags - def pipeline(self, args: Mapping): + def pipeline(self, args: Mapping, simulate: bool): path = args["path"] # type: Path - simulate = args["simulate"] # type: bool if sys.platform != "darwin": self.print("The macos_tags action is only available on macOS") diff --git a/organize/actions/move.py b/organize/actions/move.py index 3dc55dc1..e8e1c4da 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -92,9 +92,8 @@ def __init__(self, dest: str, overwrite=False, counter_separator=" "): self.overwrite = overwrite self.counter_separator = counter_separator - def pipeline(self, args: DotDict) -> Mapping[str, Path]: + def pipeline(self, args: DotDict, simulate: bool) -> Mapping[str, Path]: path = args["path"] - simulate = args["simulate"] expanded_dest = self.fill_template_tags(self.dest, args) # if only a folder path is given we append the filename to have the full diff --git a/organize/actions/python.py b/organize/actions/python.py index f378e1eb..39ed263b 100644 --- a/organize/actions/python.py +++ b/organize/actions/python.py @@ -80,8 +80,7 @@ def create_method(self, name: str, argnames: Iterable[str], code: str) -> None: ) exec(funccode, globals_, locals_) # pylint: disable=exec-used - def pipeline(self, args: DotDict) -> Optional[Mapping[str, Any]]: - simulate = args.simulate + def pipeline(self, args: DotDict, simulate: bool) -> Optional[Mapping[str, Any]]: if simulate: self.print("Code not run in simulation. (Args: %s)" % args) return None diff --git a/organize/actions/rename.py b/organize/actions/rename.py index ee2bbb90..2fb95193 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -65,9 +65,8 @@ def __init__(self, name: str, overwrite=False, counter_separator=" ") -> None: self.overwrite = overwrite self.counter_separator = counter_separator - def pipeline(self, args: Mapping) -> Mapping[str, Path]: + def pipeline(self, args: Mapping, simulate: bool) -> Mapping[str, Path]: path = args["path"] # type: Path - simulate = args["simulate"] expanded_name = self.fill_template_tags(self.name, args) new_path = path.parent / expanded_name diff --git a/organize/actions/shell.py b/organize/actions/shell.py index 0be8290c..bf1e431c 100644 --- a/organize/actions/shell.py +++ b/organize/actions/shell.py @@ -31,10 +31,10 @@ class Shell(Action): def __init__(self, cmd: str) -> None: self.cmd = cmd - def pipeline(self, args: Mapping) -> None: + def pipeline(self, args: Mapping, simulate: bool) -> None: full_cmd = self.fill_template_tags(self.cmd, args) self.print("$ %s" % full_cmd) - if not args["simulate"]: + if not simulate: # we use call instead of run to be compatible with python < 3.5 logger.info('Executing command "%s" in shell.', full_cmd) subprocess.call(full_cmd, shell=True) diff --git a/organize/actions/trash.py b/organize/actions/trash.py index b814e2e2..7dfb7ca3 100644 --- a/organize/actions/trash.py +++ b/organize/actions/trash.py @@ -32,9 +32,8 @@ class Trash(Action): - trash """ - def pipeline(self, args: Mapping): + def pipeline(self, args: Mapping, simulate: bool): path = args["path"] # type: Path - simulate = args["simulate"] # type: bool from send2trash import send2trash # type: ignore self.print('Trash "%s"' % path) diff --git a/organize/config.py b/organize/config.py index cc14926e..79114390 100644 --- a/organize/config.py +++ b/organize/config.py @@ -41,9 +41,10 @@ } ) -# disable yaml constructors for strings starting with exclamation marks -# https://stackoverflow.com/a/13281292/300783 + def default_yaml_cnst(loader, tag_suffix, node): + # disable yaml constructors for strings starting with exclamation marks + # https://stackoverflow.com/a/13281292/300783 return str(node.tag) @@ -58,10 +59,3 @@ def load_from_string(config): def load_from_file(path): with open(path, "r", encoding="utf-8") as f: return load_from_string(f.read()) - - -conf = load_from_file("organize/testconf.yaml") -try: - CONFIG_SCHEMA.validate(conf) -except SchemaError as e: - console.print(str(e.autos[-1])) diff --git a/organize/core.py b/organize/core.py index 18d57f0a..9efc0d00 100644 --- a/organize/core.py +++ b/organize/core.py @@ -1,5 +1,7 @@ import logging +import os from collections import namedtuple +from datetime import datetime from typing import Iterable import fs @@ -90,14 +92,14 @@ def replace_with_instances(config): rule["actions"] = [instantiate_by_name(x, ACTIONS) for x in rule["actions"]] -def filter_pipeline(filters: Iterable[Filter], args: DotDict, simulate: bool) -> bool: +def filter_pipeline(filters: Iterable[Filter], args: dict) -> bool: """ run the filter pipeline. Returns True on a match, False otherwise and updates `args` in the process. """ for filter_ in filters: try: - result = filter_.pipeline(args, simulate=simulate) + result = filter_.pipeline(args) if isinstance(result, dict): args.update(result) elif not result: @@ -129,12 +131,12 @@ def action_pipeline(actions: Iterable[Action], args: DotDict, simulate: bool) -> def run(config, simulate: bool = True): count = [0, 0] - Action.pre_print_hook = output_helper.pre_print - Filter.pre_print_hook = output_helper.pre_print + Action.pre_print_hook = output_helper.pipeline_message + Filter.pre_print_hook = output_helper.pipeline_message for rule in config["rules"]: target = rule.get("targets", "files") - console.print(rule["name"], style="bold") + output_helper.print_rule(rule["name"]) status_verb = "simulating" if simulate else "organizing" with console.status("[bold green]%s..." % status_verb) as status: @@ -142,15 +144,17 @@ def run(config, simulate: bool = True): walk = walker.files if target == "files" else walker.dirs for path in walk(fs=base_fs, path=base_path): args = { - "simulate": simulate, "base_fs": base_fs, "path": path, + "relative_path": None, + "env": os.environ, + "now": datetime.now(), + "utcnow": datetime.utcnow(), } output_helper.set_location(base_fs, path) match = filter_pipeline( filters=rule["filters"], args=args, - simulate=simulate, ) if match: success = action_pipeline( @@ -164,7 +168,7 @@ def run(config, simulate: bool = True): if __name__ == "__main__": from .config import load_from_file - conf = load_from_file("organize/testconf.yaml") + conf = load_from_file("testconf.yaml") replace_with_instances(conf) console.print(conf) run(conf) diff --git a/organize/filters/created.py b/organize/filters/created.py index f6cfc1fc..e6c90a6d 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -134,7 +134,7 @@ def __init__( ) print(bool(self.timedelta)) - def pipeline(self, args: DotDict) -> Optional[Dict[str, pendulum.DateTime]]: + def pipeline(self, args: dict) -> Optional[Dict[str, pendulum.DateTime]]: created_date = self._created(args.path) # Pendulum bug: https://github.com/sdispater/pendulum/issues/387 # in_words() is a workaround: total_seconds() returns 0 if years are given diff --git a/organize/filters/extension.py b/organize/filters/extension.py index b4118e0d..3c26393d 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -117,7 +117,7 @@ def matches(self, path: Path) -> Union[bool, str]: return False return self.normalize_extension(path.suffix) in self.extensions - def pipeline(self, args: dict, simulate: bool): + def pipeline(self, args: dict): path = Path(args["path"]) if self.matches(path): result = ExtensionResult(path.suffix) diff --git a/organize/filters/filename.py b/organize/filters/filename.py index 7187d9c0..a5a9125c 100644 --- a/organize/filters/filename.py +++ b/organize/filters/filename.py @@ -103,7 +103,7 @@ def matches(self, path: Path) -> bool: ) return is_match - def pipeline(self, simulate: bool, args: Dict) -> Optional[Dict[str, Any]]: + def pipeline(self, args: Dict) -> Optional[Dict[str, Any]]: path = args["path"] result = self.matches(path) if result: diff --git a/organize/filters/filter.py b/organize/filters/filter.py index 87f81888..e09a1e36 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -36,8 +36,7 @@ def pipeline(self, args: DotDict) -> FilterResult: def print(self, msg: str) -> None: """print a message for the user""" if callable(self.pre_print_hook): - self.pre_print_hook() # pylint: disable=not-callable - print(indent("- (%s) %s" % (self.__class__.__name__, msg), " " * 4)) + self.pre_print_hook(name=self.name(), msg=msg) def __str__(self) -> str: """Return filter name and properties""" diff --git a/organize/_old_config.py b/organize/migration/_old_config.py similarity index 100% rename from organize/_old_config.py rename to organize/migration/_old_config.py diff --git a/organize/_oldcore.py b/organize/migration/_oldcore.py similarity index 98% rename from organize/_oldcore.py rename to organize/migration/_oldcore.py index eaaa7192..7805903c 100644 --- a/organize/_oldcore.py +++ b/organize/migration/_oldcore.py @@ -148,8 +148,8 @@ def all_files_for_rule(rule: Rule) -> Generator[Tuple[str, Path, Path], None, No def run_jobs(jobs: Iterable[Job], simulate: bool) -> List[int]: """ :returns: The number of successfully handled files """ count = [0, 0] - Action.pre_print_hook = output_helper.pre_print - Filter.pre_print_hook = output_helper.pre_print + Action.pre_print_hook = output_helper.pipeline_message + Filter.pre_print_hook = output_helper.pipeline_message for job in sorted(jobs, key=lambda x: (x.folderstr, x.basedir, x.path)): args = DotDict( diff --git a/organize/output.py b/organize/output.py index 48df2b5c..57f66b7d 100644 --- a/organize/output.py +++ b/organize/output.py @@ -24,7 +24,7 @@ def set_location(self, folder, path) -> None: self.curr_folder = folder self.curr_path = path - def pre_print(self) -> None: + def pipeline_message(self, name, msg) -> None: """ pre-print hook that is called everytime the moment before a filter or action is about to print something to the cli @@ -39,6 +39,8 @@ def pre_print(self) -> None: self.print_path(self.curr_path) self.prev_path = self.curr_path + self.print_pipeline_message(name, msg) + def path_not_found(self, folderstr: str) -> None: if folderstr not in self.not_found: self.not_found.add(folderstr) @@ -57,17 +59,26 @@ def print_path(self, path): def print_not_found(self, path): raise NotImplementedError + def print_pipeline_message(self, name, msg): + raise NotImplementedError + class RichOutput(Output): - def print_folder_spacer(self): - console.print() + def print_rule(self, rule): + console.print(rule, style="bold") def print_folder(self, folder): console.print(str(folder), style="bold") + def print_folder_spacer(self): + console.print() + def print_path(self, path): console.print(indent(str(path), " " * 2), style="purple bold") def print_not_found(self, path): msg = "Path not found: {}".format(path) console.print(msg, style="bold yellow") + + def print_pipeline_message(self, name, msg): + console.print(indent("- (%s) %s" % (name, msg), " " * 4)) diff --git a/organize/testconf.json b/organize/testconf.json deleted file mode 100644 index f9ae6671..00000000 --- a/organize/testconf.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "version": 1, - "rules": [ - { - "name": "Fixup old pdfs", - "targets": "files", - "locations": [ - { - "path": "~/Desktop", - "max_depth": 3 - } - ], - "filters": [ - { - "extension": "pdf" - } - ], - "actions": [ - { - "copy": "~/Dir" - } - ] - }, - { - "name": "Find some folders", - "targets": "dirs", - "locations": [ - { - "path": "~/Desktop", - "max_depth": 10 - }, - { - "path": "~/Desktop/Inbox", - "max_depth": null - } - ], - "filters": [ - { - "extension": "pdf" - } - ], - "actions": [ - { - "copy": "~/Dir" - } - ] - } - ] -} diff --git a/organize/testconf.yaml b/testconf.yaml similarity index 100% rename from organize/testconf.yaml rename to testconf.yaml From 11d739ccbd1018d9f01de9b43d1ed8654ab749d6 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 21 Jan 2022 15:01:00 +0100 Subject: [PATCH 008/108] add filter names --- organize/actions/action.py | 11 +++++++--- organize/core.py | 35 ++++++++++++++++++++----------- organize/filters/__init__.py | 22 +++++++++---------- organize/filters/created.py | 1 + organize/filters/duplicate.py | 1 + organize/filters/exif.py | 1 + organize/filters/extension.py | 15 +++++++------ organize/filters/file_content.py | 27 +++++++++++++++++------- organize/filters/filename.py | 12 ++++++----- organize/filters/filesize.py | 7 ++++++- organize/filters/filter.py | 21 ++++++++++--------- organize/filters/last_modified.py | 2 ++ organize/filters/mimetype.py | 18 +++++++++------- organize/filters/python.py | 2 ++ organize/filters/regex.py | 2 ++ organize/migration/_oldcore.py | 4 ++-- organize/output.py | 26 +++++++++++++++++------ testconf.yaml | 14 ++++++++++--- 18 files changed, 147 insertions(+), 74 deletions(-) diff --git a/organize/actions/action.py b/organize/actions/action.py index 541e39ae..bf8a2a26 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -13,7 +13,8 @@ class TemplateAttributeError(Error): class Action: - pre_print_hook = None # type: Optional[Callable] + print_hook = None # type: Optional[Callable] + print_error_hook = None # type: Optional[Callable] @classmethod def name(cls): @@ -39,8 +40,12 @@ def pipeline(self, args: DotDict) -> Optional[Mapping[str, Any]]: def print(self, msg) -> None: """print a message for the user""" - if callable(self.pre_print_hook): - self.pre_print_hook(name=self.name(), msg=msg) + if callable(self.print_hook): + self.print_hook(name=self.name(), msg=msg) + + def print_error(self, msg: str): + if callable(self.print_error_hook): + self.print_error_hook(name=self.name(), msg=msg) @staticmethod def fill_template_tags(msg: str, args) -> str: diff --git a/organize/core.py b/organize/core.py index 9efc0d00..dccc4173 100644 --- a/organize/core.py +++ b/organize/core.py @@ -1,10 +1,11 @@ import logging import os -from collections import namedtuple +from typing import NamedTuple from datetime import datetime from typing import Iterable import fs +from fs.base import FS from fs.walk import Walker from .actions import ALL as ACTIONS @@ -15,7 +16,14 @@ from .utils import DotDict logger = logging.getLogger(__name__) -Location = namedtuple("Location", "walker base_fs path") + + +class Location(NamedTuple): + walker: Walker + base_fs: FS + path: str + + output_helper = RichOutput() DEFAULT_SYSTEM_EXCLUDE_FILES = [ @@ -76,6 +84,8 @@ def instantiate_location(loc): def instantiate_by_name(d, classes): + if isinstance(d, str): + return classes[d]() key, value = list(d.items())[0] if isinstance(key, str): Class = classes[key] @@ -108,8 +118,8 @@ def filter_pipeline(filters: Iterable[Filter], args: dict) -> bool: return False except Exception as e: # pylint: disable=broad-except logger.exception(e) - console.print_exception() - # filter_.print(Fore.RED + Style.BRIGHT + "ERROR! %s" % e) + #console.print_exception() + filter_.print_error(e) return False return True @@ -123,16 +133,17 @@ def action_pipeline(actions: Iterable[Action], args: DotDict, simulate: bool) -> args.update(updates) except Exception as e: # pylint: disable=broad-except logger.exception(e) - console.print_exception() - # action.print(Fore.RED + Style.BRIGHT + "ERROR! %s" % e) + action.print_error(e) return False return True def run(config, simulate: bool = True): count = [0, 0] - Action.pre_print_hook = output_helper.pipeline_message - Filter.pre_print_hook = output_helper.pipeline_message + Action.print_hook = output_helper.pipeline_message + Action.print_error_hook = output_helper.pipeline_error + Filter.print_hook = output_helper.pipeline_message + Filter.print_error_hook = output_helper.pipeline_error for rule in config["rules"]: target = rule.get("targets", "files") @@ -144,9 +155,10 @@ def run(config, simulate: bool = True): walk = walker.files if target == "files" else walker.dirs for path in walk(fs=base_fs, path=base_path): args = { - "base_fs": base_fs, - "path": path, - "relative_path": None, + "fs": base_fs, + "fs_path": path, + "path": path, # str(base_fs.getsyspath(path)), + "relative_path": fs.path.relativefrom(base_path, path), "env": os.environ, "now": datetime.now(), "utcnow": datetime.utcnow(), @@ -170,5 +182,4 @@ def run(config, simulate: bool = True): conf = load_from_file("testconf.yaml") replace_with_instances(conf) - console.print(conf) run(conf) diff --git a/organize/filters/__init__.py b/organize/filters/__init__.py index d5e86378..0c2b3136 100644 --- a/organize/filters/__init__.py +++ b/organize/filters/__init__.py @@ -11,15 +11,15 @@ from .regex import Regex ALL = { - "created": Created, - "duplicate": Duplicate, - "exif": Exif, - "extension": Extension, - "file_content": FileContent, - "filename": Filename, - "filesize": FileSize, - "last_modified": LastModified, - "mimetype": MimeType, - "python": Python, - "regex": Regex, + Created.name: Created, + Duplicate.name: Duplicate, + Exif.name: Exif, + Extension.name: Extension, + FileContent.name: FileContent, + Filename.name: Filename, + FileSize.name: FileSize, + LastModified.name: LastModified, + MimeType.name: MimeType, + Python.name: Python, + Regex.name: Regex, } diff --git a/organize/filters/created.py b/organize/filters/created.py index e6c90a6d..37646379 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -9,6 +9,7 @@ class Created(Filter): + name = "created" """ Matches files by created date diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index e1ff52f5..2895ce86 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -42,6 +42,7 @@ def get_hash(filename, first_chunk_only=False, hash_algo=hashlib.sha1): class Duplicate(Filter): + name = "duplicate" """ Finds duplicate files. diff --git a/organize/filters/exif.py b/organize/filters/exif.py index 39f147ef..f72aede2 100644 --- a/organize/filters/exif.py +++ b/organize/filters/exif.py @@ -11,6 +11,7 @@ class Exif(Filter): + name = "exif" """ Filter by image EXIF data diff --git a/organize/filters/extension.py b/organize/filters/extension.py index 3c26393d..20b91f75 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -23,6 +23,7 @@ def __str__(self): class Extension(Filter): + name = "extension" """ Filter by file extension @@ -110,17 +111,19 @@ def normalize_extension(ext: str) -> str: else: return ext.lower() - def matches(self, path: Path) -> Union[bool, str]: + def matches(self, suffix: str) -> Union[bool, str]: if not self.extensions: return True - if not path.suffix: + if not suffix: return False - return self.normalize_extension(path.suffix) in self.extensions + return self.normalize_extension(suffix) in self.extensions def pipeline(self, args: dict): - path = Path(args["path"]) - if self.matches(path): - result = ExtensionResult(path.suffix) + fs = args["fs"] + fs_path = args["fs_path"] + suffix = fs.getinfo(fs_path).suffix + if self.matches(suffix): + result = ExtensionResult(suffix) return {"extension": result} return None diff --git a/organize/filters/file_content.py b/organize/filters/file_content.py index 812eecad..775d4e87 100644 --- a/organize/filters/file_content.py +++ b/organize/filters/file_content.py @@ -1,18 +1,18 @@ import re from typing import Any, Dict, Mapping, Optional -from pathlib import Path +from fs.errors import NoSysPath from .filter import Filter - -# not supported: .gif, .jpg, .mp3, .ogg, .png, .tiff, .wav SUPPORTED_EXTENSIONS = ( + # not supported: .gif, .jpg, .mp3, .ogg, .png, .tiff, .wav ".csv .doc .docx .eml .epub .json .html .msg .odt .pdf .pptx .ps .rtf .txt .xlsx .xls" ).split() class FileContent(Filter): + name = "filecontent" r""" Matches file content with the given regular expression @@ -58,13 +58,17 @@ class FileContent(Filter): def __init__(self, expr) -> None: self.expr = re.compile(expr, re.MULTILINE | re.DOTALL) - def matches(self, path: Path) -> Any: - if path.suffix.lower() not in SUPPORTED_EXTENSIONS: + def matches(self, path: str, extension: str) -> Any: + if extension not in SUPPORTED_EXTENSIONS: return try: import textract # type: ignore - content = textract.process(str(path), errors="ignore") + content = textract.process( + str(path), + extension=extension, + errors="ignore", + ) return self.expr.search(content.decode("utf-8", errors="ignore")) except ImportError as e: raise ImportError( @@ -75,7 +79,16 @@ def matches(self, path: Path) -> Any: pass def pipeline(self, args: Mapping) -> Optional[Dict[str, Dict]]: - match = self.matches(args["path"]) + fs = args["fs"] + fs_path = args["fs_path"] + extension = fs.getinfo(fs_path).suffix + try: + syspath = fs.getsyspath(fs_path) + except NoSysPath as e: + raise EnvironmentError( + "file_content only supports local filesystems" + ) from e + match = self.matches(path=syspath, extension=extension) if match: result = match.groupdict() return {"filecontent": result} diff --git a/organize/filters/filename.py b/organize/filters/filename.py index a5a9125c..2a1ab2f3 100644 --- a/organize/filters/filename.py +++ b/organize/filters/filename.py @@ -8,6 +8,7 @@ class Filename(Filter): + name = "filename" """ Match files by filename @@ -90,8 +91,7 @@ def __init__( self.endswith = self.create_list(endswith, case_sensitive) self.case_sensitive = case_sensitive - def matches(self, path: Path) -> bool: - filename = path.stem + def matches(self, filename: str) -> bool: if not self.case_sensitive: filename = filename.lower() @@ -104,10 +104,12 @@ def matches(self, path: Path) -> bool: return is_match def pipeline(self, args: Dict) -> Optional[Dict[str, Any]]: - path = args["path"] - result = self.matches(path) + fs = args["fs"] + fs_path = args["fs_path"] + filename = fs.getinfo(fs_path).stem + result = self.matches(filename) if result: - return {"filename": self.matcher.match(path.stem)} + return {"filename": self.matcher.match(filename)} return None @staticmethod diff --git a/organize/filters/filesize.py b/organize/filters/filesize.py index cee7d884..cbf63906 100644 --- a/organize/filters/filesize.py +++ b/organize/filters/filesize.py @@ -104,6 +104,8 @@ class FileSize(Filter): """ + name = "filesize" + def __init__(self, *conditions: Sequence[str]) -> None: self.conditions = ", ".join(flattened_string_list(list(conditions))) self.constrains = create_constrains(self.conditions) @@ -114,7 +116,10 @@ def matches(self, filesize: int) -> bool: return all(op(filesize, c_size) for op, c_size in self.constrains) def pipeline(self, args: DotDict) -> Optional[Dict[str, Dict[str, int]]]: - file_size = fullpath(args.path).stat().st_size + fs = args["fs"] + fs_path = args["fs_path"] + + file_size = fs.getinfo(fs_path, namespaces=["details"]).size if self.matches(file_size): return {"filesize": {"bytes": file_size}} return None diff --git a/organize/filters/filter.py b/organize/filters/filter.py index e09a1e36..236d4e25 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -8,18 +8,15 @@ class Filter: - pre_print_hook = None - - @classmethod - def name(cls): - return cls.__name__.lower() + print_hook = None + print_error_hook = None @classmethod def schema(cls): return Or( - cls.name(), + cls.name, { - Optional(cls.name()): Or( + Optional(cls.name): Or( str, [str], Schema({}, ignore_extra_keys=True), @@ -35,12 +32,16 @@ def pipeline(self, args: DotDict) -> FilterResult: def print(self, msg: str) -> None: """print a message for the user""" - if callable(self.pre_print_hook): - self.pre_print_hook(name=self.name(), msg=msg) + if callable(self.print_hook): + self.print_hook(name=self.name, msg=msg) + + def print_error(self, msg: str): + if callable(self.print_error_hook): + self.print_error_hook(name=self.name, msg=msg) def __str__(self) -> str: """Return filter name and properties""" - return self.name() + return self.name def __repr__(self) -> str: return "<%s>" % str(self) diff --git a/organize/filters/last_modified.py b/organize/filters/last_modified.py index 8f079a75..cb9b19c6 100644 --- a/organize/filters/last_modified.py +++ b/organize/filters/last_modified.py @@ -106,6 +106,8 @@ class LastModified(Filter): - move: '~/Documents/PDF/{lastmodified.day}/{lastmodified.hour}/' """ + name = "lastmodified" + def __init__( self, years=0, diff --git a/organize/filters/mimetype.py b/organize/filters/mimetype.py index f5599fb5..edecb836 100644 --- a/organize/filters/mimetype.py +++ b/organize/filters/mimetype.py @@ -1,7 +1,6 @@ import mimetypes -from pathlib import Path -from organize.utils import DotDict, flatten +from organize.utils import flatten from .filter import Filter @@ -74,6 +73,8 @@ class MimeType(Filter): - echo: 'Found Midi or PDF.' """ + name = "mimetype" + def __init__(self, *mimetypes): self.mimetypes = list(map(str.lower, flatten(list(mimetypes)))) @@ -82,18 +83,19 @@ def mimetype(path): type_, _ = mimetypes.guess_type(path, strict=False) return type_ - def matches(self, path: Path): - mimetype = self.mimetype(path) + def matches(self, mimetype): if mimetype is None: return False if not self.mimetypes: return True return any(mimetype.startswith(x) for x in self.mimetypes) - def pipeline(self, args: DotDict): - if self.matches(args.path): - result = self.mimetype(args.path) - return {"mimetype": result} + def pipeline(self, args: dict): + fs_path = args["fs_path"] + mimetype = self.mimetype(fs_path) + + if self.matches(mimetype): + return {"mimetype": mimetype} return None def __str__(self): diff --git a/organize/filters/python.py b/organize/filters/python.py index ed3c5a65..15ecfeaf 100644 --- a/organize/filters/python.py +++ b/organize/filters/python.py @@ -87,6 +87,8 @@ class Python(Filter): """ + name = "python" + def __init__(self, code) -> None: self.code = textwrap.dedent(code) if "return" not in self.code: diff --git a/organize/filters/regex.py b/organize/filters/regex.py index 0805762e..ed0e6a43 100644 --- a/organize/filters/regex.py +++ b/organize/filters/regex.py @@ -49,6 +49,8 @@ class Regex(Filter): - move: ~/Documents/Invoices/1und1/{regex.the_number}.pdf """ + name = "python" + def __init__(self, expr) -> None: self.expr = re.compile(expr, flags=re.UNICODE) diff --git a/organize/migration/_oldcore.py b/organize/migration/_oldcore.py index 7805903c..1f36c5d6 100644 --- a/organize/migration/_oldcore.py +++ b/organize/migration/_oldcore.py @@ -148,8 +148,8 @@ def all_files_for_rule(rule: Rule) -> Generator[Tuple[str, Path, Path], None, No def run_jobs(jobs: Iterable[Job], simulate: bool) -> List[int]: """ :returns: The number of successfully handled files """ count = [0, 0] - Action.pre_print_hook = output_helper.pipeline_message - Filter.pre_print_hook = output_helper.pipeline_message + Action.print_hook = output_helper.pipeline_message + Filter.print_hook = output_helper.pipeline_message for job in sorted(jobs, key=lambda x: (x.folderstr, x.basedir, x.path)): args = DotDict( diff --git a/organize/output.py b/organize/output.py index 57f66b7d..83cc63a8 100644 --- a/organize/output.py +++ b/organize/output.py @@ -24,14 +24,10 @@ def set_location(self, folder, path) -> None: self.curr_folder = folder self.curr_path = path - def pipeline_message(self, name, msg) -> None: - """ - pre-print hook that is called everytime the moment before a filter or action is - about to print something to the cli - """ + def print_location_update(self): if self.curr_folder != self.prev_folder: if self.prev_folder is not None: - self.folder_spacer() + self.print_folder_spacer() self.print_folder(self.curr_folder) self.prev_folder = self.curr_folder @@ -39,8 +35,18 @@ def pipeline_message(self, name, msg) -> None: self.print_path(self.curr_path) self.prev_path = self.curr_path + def pipeline_message(self, name, msg) -> None: + """ + pre-print hook that is called everytime the moment before a filter or action is + about to print something to the cli + """ + self.print_location_update() self.print_pipeline_message(name, msg) + def pipeline_error(self, name, msg): + self.print_location_update() + self.print_pipeline_error(name, msg) + def path_not_found(self, folderstr: str) -> None: if folderstr not in self.not_found: self.not_found.add(folderstr) @@ -62,6 +68,9 @@ def print_not_found(self, path): def print_pipeline_message(self, name, msg): raise NotImplementedError + def print_pipeline_error(self, name, msg): + raise NotImplementedError + class RichOutput(Output): def print_rule(self, rule): @@ -82,3 +91,8 @@ def print_not_found(self, path): def print_pipeline_message(self, name, msg): console.print(indent("- (%s) %s" % (name, msg), " " * 4)) + + def print_pipeline_error(self, name, msg): + console.print( + indent("- ([bold red]%s[/]) [bold red]ERROR! %s[/]" % (name, msg), " " * 4) + ) diff --git a/testconf.yaml b/testconf.yaml index f2eea2ea..89c3eb37 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -2,12 +2,20 @@ rules: - name: "Test" targets: files locations: - - path: ~/Desktop - max_depth: 3 + # - path: ~/Desktop + # max_depth: 3 + - path: zip:///Users/thomasfeldmann/Downloads/30067_NL-AB-BBBC_SkywirePythonGPS.zip + max_depth: null filters: - extension: pdf + - filesize: "<1MB" + - mimetype + - filecontent: "(?P.*)" actions: - - echo: "hallo" + - echo: "{fs} - {fs_path} - {path} - {relative_path}" + - echo: "{filesize}" + - echo: "{mimetype}" + - echo: "{filecontent}" # - name: Find some folders # targets: dirs From 3d3b3e01d0a4a767530e68cb23b50dc9cf9199bb Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 21 Jan 2022 15:21:48 +0100 Subject: [PATCH 009/108] update changelog --- CHANGELOG.md | 30 +++++++++++++++++++++++++++--- organize/core.py | 2 +- organize/filters/file_content.py | 2 +- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f84aa8dd..3d6acb9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,33 @@ # Changelog -## In Progress +## v2 - In Progress -- config file validation -- +This is a huge update with a large refactoring. +Please backup all your important stuff before running. + +### what's new + +- completely rewritten core! +- respects your rule order - less magic, less surprises! + (v1 tried to be clever. v2 now works your config file from top to bottom) +- Starts instantly (does not need to gather all the folders before starting) +- Now you can organize (S)FTP, S3 Buckets, Zip archives and many more! + (https://www.pyfilesystem.org/page/index-of-filesystems/) +- Most of the actions like `move` and `copy` even work across file systems! +- You can now target folders with your rules! Like copying a whole folder, renaming etc. +- `max_depth` setting when recursing into subfolders +- nice terminal output and rule names +- cleaner config file validation and stricter format + +### changed + +- The config file format got a long due overhaul. Please see the migration documentation + for what is new. + +### removed + +- Glob syntax is gone from folders (no longer needed) +- "!"-exclude syntax is gone (no longer needed) ## v1.10.1 (2021-04-21) diff --git a/organize/core.py b/organize/core.py index dccc4173..8445d4f5 100644 --- a/organize/core.py +++ b/organize/core.py @@ -118,7 +118,7 @@ def filter_pipeline(filters: Iterable[Filter], args: dict) -> bool: return False except Exception as e: # pylint: disable=broad-except logger.exception(e) - #console.print_exception() + # console.print_exception() filter_.print_error(e) return False return True diff --git a/organize/filters/file_content.py b/organize/filters/file_content.py index 775d4e87..d8af79c4 100644 --- a/organize/filters/file_content.py +++ b/organize/filters/file_content.py @@ -86,7 +86,7 @@ def pipeline(self, args: Mapping) -> Optional[Dict[str, Dict]]: syspath = fs.getsyspath(fs_path) except NoSysPath as e: raise EnvironmentError( - "file_content only supports local filesystems" + "filecontent only supports the local filesystem" ) from e match = self.matches(path=syspath, extension=extension) if match: From c66fe77a7f035215a1220f781bd49dc8d222a336 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 21 Jan 2022 16:20:43 +0100 Subject: [PATCH 010/108] always use local timezone in last_modified --- organize/filters/last_modified.py | 82 ++++++++++++++++--------------- testconf.yaml | 14 ++---- 2 files changed, 47 insertions(+), 49 deletions(-) diff --git a/organize/filters/last_modified.py b/organize/filters/last_modified.py index cb9b19c6..b250dc26 100644 --- a/organize/filters/last_modified.py +++ b/organize/filters/last_modified.py @@ -1,9 +1,8 @@ +from datetime import datetime, timedelta +from optparse import Option +from time import time from typing import Dict, Optional -import pendulum # type: ignore -from pathlib import Path -from organize.utils import DotDict - from .filter import Filter @@ -38,9 +37,6 @@ class LastModified(Filter): before the given time, 'newer' matches all files last modified within the given time. (default = 'older') - :param str timezone: - specify timezone - :returns: - ``{lastmodified.year}`` -- the year the file was last modified - ``{lastmodified.month}`` -- the month the file was last modified @@ -87,23 +83,9 @@ class LastModified(Filter): - folders: '~/Documents' filters: - extension: pdf - - LastModified + - lastmodified actions: - move: '~/Documents/PDF/{lastmodified.year}/' - - - Use specific timezone when processing files - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Documents' - filters: - - extension: pdf - - lastmodified: - timezone: "Europe/Moscow" - actions: - - move: '~/Documents/PDF/{lastmodified.day}/{lastmodified.hour}/' """ name = "lastmodified" @@ -118,41 +100,61 @@ def __init__( minutes=0, seconds=0, mode="older", - timezone=pendulum.tz.local_timezone(), ) -> None: self._mode = mode.strip().lower() if self._mode not in ("older", "newer"): raise ValueError("Unknown option for 'mode': must be 'older' or 'newer'.") - self.is_older = self._mode == "older" - self.timezone = timezone - self.timedelta = pendulum.duration( - years=years, - months=months, - weeks=weeks, + self.should_be_older = self._mode == "older" + self.timedelta = timedelta( + weeks=years * 52 + months * 4 + weeks, # quick and a bit dirty days=days, hours=hours, minutes=minutes, seconds=seconds, ) - def pipeline(self, args: DotDict) -> Optional[Dict[str, pendulum.DateTime]]: - file_modified = self._last_modified(args.path) - # Pendulum bug: https://github.com/sdispater/pendulum/issues/387 - # in_words() is a workaround: total_seconds() returns 0 if years are given - if self.timedelta.in_words(): - is_past = (file_modified + self.timedelta).is_past() - match = self.is_older == is_past + def pipeline(self, args: dict) -> Optional[Dict[str, datetime]]: + fs = args["fs"] + fs_path = args["fs_path"] + file_modified: datetime + file_modified = fs.getinfo(fs_path, namespaces=["details"]).modified + if self.timedelta.total_seconds(): + if not file_modified: + match = False + else: + file_modified = file_modified.astimezone() + is_past = ( + file_modified + self.timedelta + ).timestamp() < datetime.now().timestamp() + match = self.should_be_older == is_past else: match = True if match: return {"lastmodified": file_modified} return None - def _last_modified(self, path: Path) -> pendulum.DateTime: - return pendulum.from_timestamp(float(path.stat().st_mtime), tz=self.timezone) - def __str__(self): return "[LastModified] All files last modified %s than %s" % ( self._mode, - self.timedelta.in_words(), + self.timedelta, + ) + + @classmethod + def schema(cls): + from schema import Optional, Or + + return Or( + cls.name, + { + Optional(cls.name): { + Optional("mode"): Or("older", "newer"), + Optional("years"): int, + Optional("months"): int, + Optional("weeks"): int, + Optional("days"): int, + Optional("hours"): int, + Optional("minutes"): int, + Optional("seconds"): int, + } + }, ) diff --git a/testconf.yaml b/testconf.yaml index 89c3eb37..d54f9e54 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -2,20 +2,16 @@ rules: - name: "Test" targets: files locations: - # - path: ~/Desktop - # max_depth: 3 + - path: ~/Desktop + max_depth: 0 - path: zip:///Users/thomasfeldmann/Downloads/30067_NL-AB-BBBC_SkywirePythonGPS.zip max_depth: null filters: - - extension: pdf - - filesize: "<1MB" - - mimetype - - filecontent: "(?P.*)" + - extension: png + - lastmodified actions: - echo: "{fs} - {fs_path} - {path} - {relative_path}" - - echo: "{filesize}" - - echo: "{mimetype}" - - echo: "{filecontent}" + - echo: "{lastmodified}" # - name: Find some folders # targets: dirs From 2d18a8f2d6ad36979ad1e2ce2c28e5755f844515 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 21 Jan 2022 18:21:04 +0100 Subject: [PATCH 011/108] remove pendulum dependency --- CHANGELOG.md | 1 + organize/actions/__init__.py | 2 + organize/actions/action.py | 10 ++-- organize/actions/confirm.py | 24 ++++++++++ organize/actions/copy.py | 1 + organize/actions/delete.py | 2 + organize/actions/echo.py | 2 + organize/actions/macos_tags.py | 2 + organize/actions/move.py | 2 + organize/actions/python.py | 2 + organize/actions/rename.py | 2 + organize/actions/shell.py | 2 + organize/actions/trash.py | 2 + organize/core.py | 68 +++++++++++++++------------ organize/filters/created.py | 77 ++++++++++--------------------- organize/filters/last_modified.py | 3 +- organize/output.py | 12 +++-- poetry.lock | 64 +------------------------ pyproject.toml | 1 - testconf.yaml | 2 +- 20 files changed, 123 insertions(+), 158 deletions(-) create mode 100644 organize/actions/confirm.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6acb9a..103cbdf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Please backup all your important stuff before running. - `max_depth` setting when recursing into subfolders - nice terminal output and rule names - cleaner config file validation and stricter format +- "confirm" and "prompt" action ### changed diff --git a/organize/actions/__init__.py b/organize/actions/__init__.py index 28fa8a33..508416f2 100644 --- a/organize/actions/__init__.py +++ b/organize/actions/__init__.py @@ -1,3 +1,4 @@ +from .confirm import Confirm from .copy import Copy from .delete import Delete from .echo import Echo @@ -9,6 +10,7 @@ from .trash import Trash ALL = { + Confirm.name: Confirm, "copy": Copy, "delete": Delete, "echo": Echo, diff --git a/organize/actions/action.py b/organize/actions/action.py index bf8a2a26..2bb8f845 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -16,16 +16,12 @@ class Action: print_hook = None # type: Optional[Callable] print_error_hook = None # type: Optional[Callable] - @classmethod - def name(cls): - return cls.__name__.lower() - @classmethod def schema(cls): from schema import Schema, Optional, Or return { - Optional(cls.name().lower()): Or( + Optional(cls.name.lower()): Or( str, [str], Schema({}, ignore_extra_keys=True), @@ -41,11 +37,11 @@ def pipeline(self, args: DotDict) -> Optional[Mapping[str, Any]]: def print(self, msg) -> None: """print a message for the user""" if callable(self.print_hook): - self.print_hook(name=self.name(), msg=msg) + self.print_hook(name=self.name, msg=msg) def print_error(self, msg: str): if callable(self.print_error_hook): - self.print_error_hook(name=self.name(), msg=msg) + self.print_error_hook(name=self.name, msg=msg) @staticmethod def fill_template_tags(msg: str, args) -> str: diff --git a/organize/actions/confirm.py b/organize/actions/confirm.py new file mode 100644 index 00000000..0fdf03f2 --- /dev/null +++ b/organize/actions/confirm.py @@ -0,0 +1,24 @@ +import logging + +from rich.prompt import Prompt +from ..output import console +from .action import Action + +logger = logging.getLogger(__name__) + + +class Confirm(Action): + def __init__(self, msg, default): + self.msg = msg + self.default = default + self.prompt = Prompt(console=console) + + def pipeline(self, args: dict, simulate: bool): + self.print("asd") + chosen = self.prompt.ask("", default=self.default) + self.print(chosen) + + def __str__(self) -> str: + return 'Echo(msg="%s")' % self.msg + + name = "confirm" diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 4a4759a8..7955e1e5 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -14,6 +14,7 @@ class Copy(Action): + name = "copy" """ Copy a file to a new location. diff --git a/organize/actions/delete.py b/organize/actions/delete.py index e5698b9f..bc75d510 100644 --- a/organize/actions/delete.py +++ b/organize/actions/delete.py @@ -35,6 +35,8 @@ class Delete(Action): - delete """ + name = "delete" + def pipeline(self, args: Mapping, simulate: bool): path = args["path"] # type: Path self.print('Delete "%s"' % path) diff --git a/organize/actions/echo.py b/organize/actions/echo.py index adf816bf..bfc7577c 100644 --- a/organize/actions/echo.py +++ b/organize/actions/echo.py @@ -81,3 +81,5 @@ def pipeline(self, args: dict, simulate: bool) -> None: def __str__(self) -> str: return 'Echo(msg="%s")' % self.msg + + name = "echo" diff --git a/organize/actions/macos_tags.py b/organize/actions/macos_tags.py index 4f0c06e9..60c7f6de 100644 --- a/organize/actions/macos_tags.py +++ b/organize/actions/macos_tags.py @@ -12,6 +12,8 @@ class MacOSTags(Action): + name = "macos_tags" + """ Add macOS tags. diff --git a/organize/actions/move.py b/organize/actions/move.py index e8e1c4da..142820be 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -127,3 +127,5 @@ def pipeline(self, args: DotDict, simulate: bool) -> Mapping[str, Path]: def __str__(self) -> str: return "Move(dest=%s, overwrite=%s)" % (self.dest, self.overwrite) + + name = "move" diff --git a/organize/actions/python.py b/organize/actions/python.py index 39ed263b..b5b6f4dc 100644 --- a/organize/actions/python.py +++ b/organize/actions/python.py @@ -91,3 +91,5 @@ def pipeline(self, args: DotDict, simulate: bool) -> Optional[Mapping[str, Any]] result = self.usercode(**args) # pylint: disable=assignment-from-no-return return result + + name = "python" diff --git a/organize/actions/rename.py b/organize/actions/rename.py index 2fb95193..f839020c 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -100,3 +100,5 @@ def __str__(self) -> str: self.overwrite, self.counter_separator, ) + + name = "rename" diff --git a/organize/actions/shell.py b/organize/actions/shell.py index bf1e431c..5818bf0c 100644 --- a/organize/actions/shell.py +++ b/organize/actions/shell.py @@ -41,3 +41,5 @@ def pipeline(self, args: Mapping, simulate: bool) -> None: def __str__(self) -> str: return 'Shell(cmd="%s")' % self.cmd + + name = "shell" diff --git a/organize/actions/trash.py b/organize/actions/trash.py index 7dfb7ca3..71029728 100644 --- a/organize/actions/trash.py +++ b/organize/actions/trash.py @@ -40,3 +40,5 @@ def pipeline(self, args: Mapping, simulate: bool): if not simulate: logger.info("Moving file %s into trash.", path) send2trash(str(path)) + + name = "trash" diff --git a/organize/core.py b/organize/core.py index 8445d4f5..9df2952f 100644 --- a/organize/core.py +++ b/organize/core.py @@ -1,8 +1,7 @@ import logging import os -from typing import NamedTuple from datetime import datetime -from typing import Iterable +from typing import Iterable, NamedTuple import fs from fs.base import FS @@ -145,41 +144,52 @@ def run(config, simulate: bool = True): Filter.print_hook = output_helper.pipeline_message Filter.print_error_hook = output_helper.pipeline_error + if simulate: + output_helper.print_simulation_banner() + for rule in config["rules"]: target = rule.get("targets", "files") output_helper.print_rule(rule["name"]) - status_verb = "simulating" if simulate else "organizing" - with console.status("[bold green]%s..." % status_verb) as status: - for walker, base_fs, base_path in rule["locations"]: - walk = walker.files if target == "files" else walker.dirs - for path in walk(fs=base_fs, path=base_path): - args = { - "fs": base_fs, - "fs_path": path, - "path": path, # str(base_fs.getsyspath(path)), - "relative_path": fs.path.relativefrom(base_path, path), - "env": os.environ, - "now": datetime.now(), - "utcnow": datetime.utcnow(), - } - output_helper.set_location(base_fs, path) - match = filter_pipeline( - filters=rule["filters"], + # status_verb = "simulating" if simulate else "organizing" + # with console.status("[bold green]%s..." % status_verb) as status: + for walker, base_fs, base_path in rule["locations"]: + walk = walker.files if target == "files" else walker.dirs + for path in walk(fs=base_fs, path=base_path): + args = { + "fs": base_fs, + "fs_path": path, + "path": path, # str(base_fs.getsyspath(path)), + "relative_path": fs.path.relativefrom(base_path, path), + "env": os.environ, + "now": datetime.now(), + "utcnow": datetime.utcnow(), + } + output_helper.set_location(base_fs, path) + match = filter_pipeline( + filters=rule["filters"], + args=args, + ) + if match: + success = action_pipeline( + actions=rule["actions"], args=args, + simulate=simulate, ) - if match: - success = action_pipeline( - actions=rule["actions"], - args=args, - simulate=simulate, - ) - count[success] += 1 + count[success] += 1 + + if simulate: + output_helper.print_simulation_banner() if __name__ == "__main__": - from .config import load_from_file + from .config import load_from_file, CONFIG_SCHEMA conf = load_from_file("testconf.yaml") - replace_with_instances(conf) - run(conf) + try: + CONFIG_SCHEMA.validate(conf) + replace_with_instances(conf) + run(conf) + except Exception as e: + console.print(e.autos[-1]) + console.print(e.code) diff --git a/organize/filters/created.py b/organize/filters/created.py index 37646379..0f5c599a 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -1,9 +1,8 @@ import sys +from datetime import datetime, timedelta +from pathlib import Path from typing import Dict, Optional, SupportsFloat -import pendulum # type: ignore -from pathlib import Path -from organize.utils import DotDict from .filter import Filter @@ -40,9 +39,6 @@ class Created(Filter): time, 'newer' matches all files created within the given time. (default = 'older') - :param str timezone: - specify timezone - :returns: - ``{created.year}`` -- the year the file was created - ``{created.month}`` -- the month the file was created @@ -91,20 +87,6 @@ class Created(Filter): - created actions: - move: '~/Documents/PDF/{created.year}/' - - - Use specific timezone when processing files - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Documents' - filters: - - extension: pdf - - created: - timezone: "Europe/Moscow" - actions: - - move: '~/Documents/PDF/{created.day}/{created.hour}/' """ def __init__( @@ -117,54 +99,43 @@ def __init__( minutes=0, seconds=0, mode="older", - timezone=pendulum.tz.local_timezone(), ) -> None: self._mode = mode.strip().lower() if self._mode not in ("older", "newer"): raise ValueError("Unknown option for 'mode': must be 'older' or 'newer'.") - self.is_older = self._mode == "older" - self.timezone = timezone - self.timedelta = pendulum.duration( - years=years, - months=months, - weeks=weeks, + self.should_be_older = self._mode == "older" + self.timedelta = timedelta( + weeks=years * 52 + months * 4 + weeks, # quick and a bit dirty days=days, hours=hours, minutes=minutes, seconds=seconds, ) - print(bool(self.timedelta)) - - def pipeline(self, args: dict) -> Optional[Dict[str, pendulum.DateTime]]: - created_date = self._created(args.path) - # Pendulum bug: https://github.com/sdispater/pendulum/issues/387 - # in_words() is a workaround: total_seconds() returns 0 if years are given - if self.timedelta.in_words(): - is_past = (created_date + self.timedelta).is_past() - match = self.is_older == is_past + + def pipeline(self, args: dict) -> Optional[Dict[str, datetime]]: + fs = args["fs"] + fs_path = args["fs_path"] + file_created: datetime + file_created = fs.getinfo(fs_path, namespaces=["details"]).created + if file_created: + file_created = file_created.astimezone() + + if self.timedelta.total_seconds(): + if not file_created: + match = False + else: + is_past = ( + file_created + self.timedelta + ).timestamp() < datetime.now().timestamp() + match = self.should_be_older == is_past else: match = True if match: - return {"created": created_date} + return {"created": file_created} return None - def _created(self, path: Path) -> pendulum.DateTime: - # see https://stackoverflow.com/a/39501288/300783 - stat = path.stat() - time = 0 # type: SupportsFloat - if sys.platform.startswith("win"): - time = stat.st_ctime - else: - try: - time = stat.st_birthtime - except AttributeError: - # We're probably on Linux. No easy way to get creation dates here, - # so we'll settle for when its content was last modified. - time = stat.st_mtime - return pendulum.from_timestamp(float(time), tz=self.timezone) - def __str__(self): return "[Created] All files %s than %s" % ( self._mode, - self.timedelta.in_words(), + self.timedelta, ) diff --git a/organize/filters/last_modified.py b/organize/filters/last_modified.py index b250dc26..bf35a6f0 100644 --- a/organize/filters/last_modified.py +++ b/organize/filters/last_modified.py @@ -118,11 +118,12 @@ def pipeline(self, args: dict) -> Optional[Dict[str, datetime]]: fs_path = args["fs_path"] file_modified: datetime file_modified = fs.getinfo(fs_path, namespaces=["details"]).modified + if file_modified: + file_modified = file_modified.astimezone() if self.timedelta.total_seconds(): if not file_modified: match = False else: - file_modified = file_modified.astimezone() is_past = ( file_modified + self.timedelta ).timestamp() < datetime.now().timestamp() diff --git a/organize/output.py b/organize/output.py index 83cc63a8..f08c391f 100644 --- a/organize/output.py +++ b/organize/output.py @@ -1,6 +1,9 @@ -from rich.console import Console -from textwrap import indent import logging +from textwrap import indent + +from rich.rule import Rule +from rich.console import Console +from rich.panel import Panel logger = logging.getLogger(__name__) console = Console() @@ -73,8 +76,11 @@ def print_pipeline_error(self, name, msg): class RichOutput(Output): + def print_simulation_banner(self): + console.print(Panel("[bold green]SIMULATION", style="green")) + def print_rule(self, rule): - console.print(rule, style="bold") + console.print(Rule(rule, align="left", style="gray"), style="bold") def print_folder(self, folder): console.print(str(folder), style="bold") diff --git a/poetry.lock b/poetry.lock index c6a3efe7..3b47a0ce 100644 --- a/poetry.lock +++ b/poetry.lock @@ -597,18 +597,6 @@ sortedcontainers = "*" dev = ["nose", "tox"] docs = ["sphinx", "sphinx-argparse"] -[[package]] -name = "pendulum" -version = "2.1.2" -description = "Python datetimes made easy" -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[package.dependencies] -python-dateutil = ">=2.6,<3.0" -pytzdata = ">=2020.1" - [[package]] name = "pexpect" version = "4.8.0" @@ -780,17 +768,6 @@ wcwidth = "*" [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "nose", "requests", "mock"] -[[package]] -name = "python-dateutil" -version = "2.8.2" -description = "Extensions to the standard Python datetime module" -category = "main" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" - -[package.dependencies] -six = ">=1.5" - [[package]] name = "python-pptx" version = "0.6.21" @@ -824,14 +801,6 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" "backports.zoneinfo" = {version = "*", markers = "python_version >= \"3.6\" and python_version < \"3.9\""} tzdata = {version = "*", markers = "python_version >= \"3.6\""} -[[package]] -name = "pytzdata" -version = "2020.1" -description = "The Olson timezone database for Python." -category = "main" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - [[package]] name = "pyyaml" version = "5.4.1" @@ -1228,7 +1197,7 @@ textract = ["textract"] [metadata] lock-version = "1.1" python-versions = "^3.6.2" -content-hash = "12f40637fa7103f22c21d9356b01f257098340f285de63f28393a12c562a93d5" +content-hash = "4dc7a715c4ed84fd17a8f0fea30f2d8eefb682ec7015ff028b0cf2757827bcf0" [metadata.files] alabaster = [ @@ -1683,29 +1652,6 @@ parso = [ {file = "pdfminer.six-20191110-py2.py3-none-any.whl", hash = "sha256:ca2ca58f3ac66a486bce53a6ddba95dc2b27781612915fa41c444790ba9cd2a8"}, {file = "pdfminer.six-20191110.tar.gz", hash = "sha256:141a53ec491bee6d45bf9b2c7f82601426fb5d32636bcf6b9c8a8f3b6431fea6"}, ] -pendulum = [ - {file = "pendulum-2.1.2-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:b6c352f4bd32dff1ea7066bd31ad0f71f8d8100b9ff709fb343f3b86cee43efe"}, - {file = "pendulum-2.1.2-cp27-cp27m-win_amd64.whl", hash = "sha256:318f72f62e8e23cd6660dbafe1e346950281a9aed144b5c596b2ddabc1d19739"}, - {file = "pendulum-2.1.2-cp35-cp35m-macosx_10_15_x86_64.whl", hash = "sha256:0731f0c661a3cb779d398803655494893c9f581f6488048b3fb629c2342b5394"}, - {file = "pendulum-2.1.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:3481fad1dc3f6f6738bd575a951d3c15d4b4ce7c82dce37cf8ac1483fde6e8b0"}, - {file = "pendulum-2.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9702069c694306297ed362ce7e3c1ef8404ac8ede39f9b28b7c1a7ad8c3959e3"}, - {file = "pendulum-2.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:fb53ffa0085002ddd43b6ca61a7b34f2d4d7c3ed66f931fe599e1a531b42af9b"}, - {file = "pendulum-2.1.2-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:c501749fdd3d6f9e726086bf0cd4437281ed47e7bca132ddb522f86a1645d360"}, - {file = "pendulum-2.1.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:c807a578a532eeb226150d5006f156632df2cc8c5693d778324b43ff8c515dd0"}, - {file = "pendulum-2.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:2d1619a721df661e506eff8db8614016f0720ac171fe80dda1333ee44e684087"}, - {file = "pendulum-2.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f888f2d2909a414680a29ae74d0592758f2b9fcdee3549887779cd4055e975db"}, - {file = "pendulum-2.1.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:e95d329384717c7bf627bf27e204bc3b15c8238fa8d9d9781d93712776c14002"}, - {file = "pendulum-2.1.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:4c9c689747f39d0d02a9f94fcee737b34a5773803a64a5fdb046ee9cac7442c5"}, - {file = "pendulum-2.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1245cd0075a3c6d889f581f6325dd8404aca5884dea7223a5566c38aab94642b"}, - {file = "pendulum-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:db0a40d8bcd27b4fb46676e8eb3c732c67a5a5e6bfab8927028224fbced0b40b"}, - {file = "pendulum-2.1.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f5e236e7730cab1644e1b87aca3d2ff3e375a608542e90fe25685dae46310116"}, - {file = "pendulum-2.1.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:de42ea3e2943171a9e95141f2eecf972480636e8e484ccffaf1e833929e9e052"}, - {file = "pendulum-2.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7c5ec650cb4bec4c63a89a0242cc8c3cebcec92fcfe937c417ba18277d8560be"}, - {file = "pendulum-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:33fb61601083f3eb1d15edeb45274f73c63b3c44a8524703dc143f4212bf3269"}, - {file = "pendulum-2.1.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:29c40a6f2942376185728c9a0347d7c0f07905638c83007e1d262781f1e6953a"}, - {file = "pendulum-2.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:94b1fc947bfe38579b28e1cccb36f7e28a15e841f30384b5ad6c5e31055c85d7"}, - {file = "pendulum-2.1.2.tar.gz", hash = "sha256:b06a0ca1bfe41c990bbf0c029f0b6501a7f2ec4e38bfec730712015e8860f207"}, -] pexpect = [ {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, @@ -1837,10 +1783,6 @@ pytest = [ {file = "pytest-4.6.11-py2.py3-none-any.whl", hash = "sha256:a00a7d79cbbdfa9d21e7d0298392a8dd4123316bfac545075e6f8f24c94d8c97"}, {file = "pytest-4.6.11.tar.gz", hash = "sha256:50fa82392f2120cc3ec2ca0a75ee615be4c479e66669789771f1758332be4353"}, ] -python-dateutil = [ - {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, - {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, -] python-pptx = [ {file = "python-pptx-0.6.21.tar.gz", hash = "sha256:7798a2aaf89563565b3c7120c0acfe9aff775db0db3580544e3bf4840c2e378f"}, ] @@ -1852,10 +1794,6 @@ pytz-deprecation-shim = [ {file = "pytz_deprecation_shim-0.1.0.post0-py2.py3-none-any.whl", hash = "sha256:8314c9692a636c8eb3bda879b9f119e350e93223ae83e70e80c31675a0fdc1a6"}, {file = "pytz_deprecation_shim-0.1.0.post0.tar.gz", hash = "sha256:af097bae1b616dde5c5744441e2ddc69e74dfdcb0c263129610d85b87445a59d"}, ] -pytzdata = [ - {file = "pytzdata-2020.1-py2.py3-none-any.whl", hash = "sha256:e1e14750bcf95016381e4d472bad004eef710f2d6417240904070b3d6654485f"}, - {file = "pytzdata-2020.1.tar.gz", hash = "sha256:3efa13b335a00a8de1d345ae41ec78dd11c9f8807f522d39850f2dd828681540"}, -] pyyaml = [ {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, diff --git a/pyproject.toml b/pyproject.toml index 8497e50a..9df31d03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ PyYAML = "^5.4.1" Send2Trash = "^1.8.0" ExifRead = "^2.3.2" textract = { version = "^1.6.4", optional = true } -pendulum = "^2.1.2" simplematch = "^1.3" macos-tags = { version = "^1.5.1", markers = "sys_platform == 'darwin'"} schema = "^0.7.5" diff --git a/testconf.yaml b/testconf.yaml index d54f9e54..5704a9b7 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -1,5 +1,5 @@ rules: - - name: "Test" + - name: "Find the last modification date of some png files" targets: files locations: - path: ~/Desktop From 3fae9bad93e4fa9d6b14ace0f687dd6ac7c25351 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sat, 22 Jan 2022 11:18:36 +0100 Subject: [PATCH 012/108] updated actions --- CHANGELOG.md | 6 ++++-- organize/actions/__init__.py | 18 +++++++++--------- organize/actions/action.py | 17 ++++++++++------- organize/actions/confirm.py | 2 +- organize/actions/delete.py | 10 ++++++---- organize/actions/macos_tags.py | 10 ++++++---- organize/actions/rename.py | 4 ++-- organize/actions/trash.py | 15 +++++++-------- organize/cli.py | 12 +++++++----- organize/core.py | 15 ++++++++++----- organize/filters/created.py | 7 ++----- organize/filters/duplicate.py | 7 +++++-- organize/filters/exif.py | 12 +++++++----- organize/{output.py => tui.py} | 0 organize/utils.py | 10 +++++----- testconf.yaml | 11 ++--------- 16 files changed, 83 insertions(+), 73 deletions(-) rename organize/{output.py => tui.py} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 103cbdf0..6bd3705a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,14 @@ Please backup all your important stuff before running. ### what's new - completely rewritten core! -- respects your rule order - less magic, less surprises! +- respects your rule order - safer, less magic, less surprises. (v1 tried to be clever. v2 now works your config file from top to bottom) -- Starts instantly (does not need to gather all the folders before starting) - Now you can organize (S)FTP, S3 Buckets, Zip archives and many more! (https://www.pyfilesystem.org/page/index-of-filesystems/) - Most of the actions like `move` and `copy` even work across file systems! - You can now target folders with your rules! Like copying a whole folder, renaming etc. - `max_depth` setting when recursing into subfolders +- starts instantly (does not need to gather all the folders before starting) - nice terminal output and rule names - cleaner config file validation and stricter format - "confirm" and "prompt" action @@ -24,6 +24,8 @@ Please backup all your important stuff before running. - The config file format got a long due overhaul. Please see the migration documentation for what is new. +- The `timezone` keyword for `lastmodified` and `created` was removed. The timezone is + now the local timezone by default. ### removed diff --git a/organize/actions/__init__.py b/organize/actions/__init__.py index 508416f2..4604c5e6 100644 --- a/organize/actions/__init__.py +++ b/organize/actions/__init__.py @@ -11,13 +11,13 @@ ALL = { Confirm.name: Confirm, - "copy": Copy, - "delete": Delete, - "echo": Echo, - "macos_tags": MacOSTags, - "move": Move, - "python": Python, - "rename": Rename, - "shell": Shell, - "trash": Trash, + Copy.name: Copy, + Delete.name: Delete, + Echo.name: Echo, + MacOSTags.name: MacOSTags, + Move.name: Move, + Python.name: Python, + Rename.name: Rename, + Shell.name: Shell, + Trash.name: Trash, } diff --git a/organize/actions/action.py b/organize/actions/action.py index 2bb8f845..a727902b 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -20,13 +20,16 @@ class Action: def schema(cls): from schema import Schema, Optional, Or - return { - Optional(cls.name.lower()): Or( - str, - [str], - Schema({}, ignore_extra_keys=True), - ), - } + return Or( + str, + { + Optional(cls.name.lower()): Or( + str, + [str], + Schema({}, ignore_extra_keys=True), + ), + }, + ) def run(self, **kwargs) -> Optional[Mapping[str, Any]]: return self.pipeline(DotDict(kwargs)) diff --git a/organize/actions/confirm.py b/organize/actions/confirm.py index 0fdf03f2..b8c81fd2 100644 --- a/organize/actions/confirm.py +++ b/organize/actions/confirm.py @@ -1,7 +1,7 @@ import logging from rich.prompt import Prompt -from ..output import console +from ..tui import console from .action import Action logger = logging.getLogger(__name__) diff --git a/organize/actions/delete.py b/organize/actions/delete.py index bc75d510..1447298c 100644 --- a/organize/actions/delete.py +++ b/organize/actions/delete.py @@ -38,8 +38,10 @@ class Delete(Action): name = "delete" def pipeline(self, args: Mapping, simulate: bool): - path = args["path"] # type: Path - self.print('Delete "%s"' % path) + fs = args["fs"] + fs_path = args["fs_path"] + relative_path = args["relative_path"] + self.print('Deleting "%s"' % relative_path) if not simulate: - logger.info("Deleting file %s.", path) - os.remove(str(path)) + logger.info("Deleting file %s.", relative_path) + fs.remove(fs_path) diff --git a/organize/actions/macos_tags.py b/organize/actions/macos_tags.py index 60c7f6de..0c7d6e23 100644 --- a/organize/actions/macos_tags.py +++ b/organize/actions/macos_tags.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Mapping -import simplematch as sm # type: ignore +import simplematch as sm # type: ignore from .action import Action @@ -81,8 +81,10 @@ class MacOSTags(Action): def __init__(self, *tags): self.tags = tags - def pipeline(self, args: Mapping, simulate: bool): - path = args["path"] # type: Path + def pipeline(self, args: dict, simulate: bool): + fs = args["fs"] + fs_path = args["fs_path"] + path = fs.getsyspath(fs_path) if sys.platform != "darwin": self.print("The macos_tags action is only available on macOS") @@ -110,7 +112,7 @@ def pipeline(self, args: Mapping, simulate: bool): macos_tags.add(_tag, file=str(path)) def _parse_tag(self, s): - """ parse a tag definition and return a tuple (name, color) """ + """parse a tag definition and return a tuple (name, color)""" result = sm.match("{name} ({color})", s) if not result: return s, "none" diff --git a/organize/actions/rename.py b/organize/actions/rename.py index f839020c..c0c92423 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -55,6 +55,8 @@ class Rename(Action): - rename: "{path.stem}.{extension.lower}" """ + name = "rename" + def __init__(self, name: str, overwrite=False, counter_separator=" ") -> None: if os.path.sep in name: ValueError( @@ -100,5 +102,3 @@ def __str__(self) -> str: self.overwrite, self.counter_separator, ) - - name = "rename" diff --git a/organize/actions/trash.py b/organize/actions/trash.py index 71029728..2e4318b9 100644 --- a/organize/actions/trash.py +++ b/organize/actions/trash.py @@ -1,7 +1,4 @@ import logging -from typing import Mapping - -from pathlib import Path from .action import Action @@ -32,13 +29,15 @@ class Trash(Action): - trash """ - def pipeline(self, args: Mapping, simulate: bool): - path = args["path"] # type: Path + name = "trash" + + def pipeline(self, args: dict, simulate: bool): from send2trash import send2trash # type: ignore + fs = args["fs"] + fs_path = args["fs_path"] + path = fs.getsyspath(fs_path) self.print('Trash "%s"' % path) if not simulate: logger.info("Moving file %s into trash.", path) - send2trash(str(path)) - - name = "trash" + send2trash(path) diff --git a/organize/cli.py b/organize/cli.py index 28a5ec80..10291c47 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -2,9 +2,9 @@ organize -- The file management automation tool. Usage: - organize sim [--config-file=] - organize run [--config-file=] - organize config [--open-folder | --path | --debug] [--config-file=] + organize sim [] + organize run [] + organize config [--open-folder | --path | --debug] [] organize list organize --help organize --version @@ -27,14 +27,15 @@ import logging import os import sys +from pathlib import Path from typing import Union from colorama import Fore, Style # type: ignore from docopt import docopt # type: ignore +from rich import print from . import CONFIG_DIR, CONFIG_PATH, LOG_PATH from .__version__ import __version__ -from pathlib import Path from .config import Config from .core import execute_rules from .utils import flatten, fullpath @@ -140,7 +141,8 @@ def config_debug(config_path: Path) -> None: def list_actions_and_filters() -> None: """Prints a list of available actions and filters""" import inspect # pylint: disable=import-outside-toplevel - from organize import filters, actions # pylint: disable=import-outside-toplevel + + from organize import actions, filters # pylint: disable=import-outside-toplevel print(Style.BRIGHT + "Filters:") for name, _ in inspect.getmembers(filters, inspect.isclass): diff --git a/organize/core.py b/organize/core.py index 9df2952f..50d27a81 100644 --- a/organize/core.py +++ b/organize/core.py @@ -2,7 +2,7 @@ import os from datetime import datetime from typing import Iterable, NamedTuple - +from schema import SchemaError import fs from fs.base import FS from fs.walk import Walker @@ -11,7 +11,7 @@ from .actions.action import Action from .filters import ALL as FILTERS from .filters.filter import Filter -from .output import RichOutput, console +from .tui import RichOutput, console from .utils import DotDict logger = logging.getLogger(__name__) @@ -97,7 +97,10 @@ def instantiate_by_name(d, classes): def replace_with_instances(config): for rule in config["rules"]: rule["locations"] = [instantiate_location(loc) for loc in rule["locations"]] - rule["filters"] = [instantiate_by_name(x, FILTERS) for x in rule["filters"]] + # filters are optional + rule["filters"] = [ + instantiate_by_name(x, FILTERS) for x in rule.get("filters", []) + ] rule["actions"] = [instantiate_by_name(x, ACTIONS) for x in rule["actions"]] @@ -189,7 +192,9 @@ def run(config, simulate: bool = True): try: CONFIG_SCHEMA.validate(conf) replace_with_instances(conf) - run(conf) - except Exception as e: + run(conf, simulate=False) + except SchemaError as e: console.print(e.autos[-1]) console.print(e.code) + except Exception as e: + console.print_exception() diff --git a/organize/filters/created.py b/organize/filters/created.py index 0f5c599a..7a401ce7 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -1,8 +1,5 @@ -import sys from datetime import datetime, timedelta -from pathlib import Path -from typing import Dict, Optional, SupportsFloat - +from typing import Dict, Optional from .filter import Filter @@ -105,7 +102,7 @@ def __init__( raise ValueError("Unknown option for 'mode': must be 'older' or 'newer'.") self.should_be_older = self._mode == "older" self.timedelta = timedelta( - weeks=years * 52 + months * 4 + weeks, # quick and a bit dirty + weeks=52 * years + 4 * months + weeks, # quick and a bit dirty days=days, hours=hours, minutes=minutes, diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index 2895ce86..9f3e7359 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -22,7 +22,7 @@ def chunk_reader(fobj, chunk_size=1024): - """ Generator that reads a file in chunks of bytes """ + """Generator that reads a file in chunks of bytes""" while True: chunk = fobj.read(chunk_size) if not chunk: @@ -140,7 +140,10 @@ def matches(self, path: str) -> Union[bool, Dict[str, str]]: return False def pipeline(self, args): - return self.matches(str(fullpath(args["path"]))) + fs = args["fs"] + fs_path = args["fs_path"] + fs.getsyspath(fs_path) + return self.matches(fs.getsyspath(fs_path)) def __str__(self) -> str: return "Duplicate()" diff --git a/organize/filters/exif.py b/organize/filters/exif.py index f72aede2..7d0d03c6 100644 --- a/organize/filters/exif.py +++ b/organize/filters/exif.py @@ -103,14 +103,11 @@ def category_dict(self, tags: Mapping[str, str]) -> ExifDict: result[key] = value # type: ignore return result - def matches(self, path: Path) -> Union[bool, ExifDict]: + def matches(self, exiftags: dict) -> Union[bool, ExifDict]: # NOTE: This should return Union[Literal[False], ExifDict] but Literal is only # available in Python>=3.8. - with path.open("rb") as f: - exiftags = exifread.process_file(f, details=False) # type: Dict if not exiftags: return False - tags = {k.lower(): v.printable for k, v in exiftags.items()} # no match if expected tag is not found @@ -126,7 +123,12 @@ def matches(self, path: Path) -> Union[bool, ExifDict]: return self.category_dict(tags) def pipeline(self, args: Mapping[str, Any]) -> Optional[Dict[str, ExifDict]]: - tags = self.matches(args["path"]) + fs = args["fs"] + fs_path = args["fs_path"] + with fs.openbin(fs_path) as f: + exiftags = exifread.process_file(f, details=False) + + tags = self.matches(exiftags) if isinstance(tags, dict): return {"exif": tags} return None diff --git a/organize/output.py b/organize/tui.py similarity index 100% rename from organize/output.py rename to organize/tui.py diff --git a/organize/utils.py b/organize/utils.py index cc5263f3..a2afe6d5 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -10,7 +10,7 @@ def splitglob(globstr: str) -> Tuple[Path, str]: - """ split a string with wildcards into a base folder and globstring """ + """split a string with wildcards into a base folder and globstring""" path = fullpath(globstr.strip()) parts = path.parts for i, part in enumerate(parts): @@ -20,7 +20,7 @@ def splitglob(globstr: str) -> Tuple[Path, str]: def fullpath(path: Union[str, Path]) -> Path: - """ Expand '~' and resolve the given path. Path can be a string or a Path obj. """ + """Expand '~' and resolve the given path. Path can be a string or a Path obj.""" return Path(os.path.expandvars(str(path))).expanduser().resolve(strict=False) @@ -85,7 +85,7 @@ def __setattr__(self, key, value) -> None: self[self.normkey(key)] = value def update(self, other): - """ recursively update the dotdict instance with another dicts items """ + """recursively update the dotdict instance with another dicts items""" for key, val in other.items(): normkey = self.normkey(key) if isinstance(val, Mapping): @@ -97,7 +97,7 @@ def update(self, other): self[normkey] = val def merge(self, other) -> Mapping: - """ recursively merge values from another dict and return a new instance """ + """recursively merge values from another dict and return a new instance""" new_dct = deepcopy(self) new_dct.update(other) return new_dct @@ -137,7 +137,7 @@ def find_unused_filename(path: Path, separator=" ") -> Path: def dict_merge(dct, merge_dct, add_keys=True): - """ Recursive dict merge. + """Recursive dict merge. Inspired by :meth:``dict.update()``, instead of updating only top-level keys, dict_merge recurses down into dicts nested diff --git a/testconf.yaml b/testconf.yaml index 5704a9b7..485d5d71 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -2,16 +2,9 @@ rules: - name: "Find the last modification date of some png files" targets: files locations: - - path: ~/Desktop - max_depth: 0 - - path: zip:///Users/thomasfeldmann/Downloads/30067_NL-AB-BBBC_SkywirePythonGPS.zip - max_depth: null - filters: - - extension: png - - lastmodified + - path: ~/Desktop/testfolder actions: - - echo: "{fs} - {fs_path} - {path} - {relative_path}" - - echo: "{lastmodified}" + - macos_tags: "Test" # - name: Find some folders # targets: dirs From 55588bf53a72e76ae08bf6f68c94c714d463cc5c Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 23 Jan 2022 15:18:55 +0100 Subject: [PATCH 013/108] new copy code with fs and conflict strategies --- CHANGELOG.md | 1 + organize/actions/action.py | 7 +- organize/actions/copy.py | 140 +++++++++++++++++++++-------- organize/actions/move.py | 7 +- organize/actions/python.py | 4 +- organize/actions/trash.py | 11 ++- organize/core.py | 15 ++-- organize/filters/extension.py | 2 +- organize/filters/filesize.py | 4 +- organize/filters/filter.py | 6 +- organize/filters/last_modified.py | 2 +- organize/filters/regex.py | 4 +- organize/utils.py | 145 +++++++----------------------- testconf.yaml | 8 ++ 14 files changed, 175 insertions(+), 181 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd3705a..62a1dda0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Please backup all your important stuff before running. - nice terminal output and rule names - cleaner config file validation and stricter format - "confirm" and "prompt" action +- `rename_template` option in `move` and `copy` ### changed diff --git a/organize/actions/action.py b/organize/actions/action.py index a727902b..fd2590bd 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -1,8 +1,5 @@ -from textwrap import indent from typing import Any, Mapping, Optional, Callable -from organize.utils import DotDict - class Error(Exception): pass @@ -32,9 +29,9 @@ def schema(cls): ) def run(self, **kwargs) -> Optional[Mapping[str, Any]]: - return self.pipeline(DotDict(kwargs)) + return self.pipeline(kwargs) - def pipeline(self, args: DotDict) -> Optional[Mapping[str, Any]]: + def pipeline(self, args: dict) -> Optional[Mapping[str, Any]]: raise NotImplementedError def print(self, msg) -> None: diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 7955e1e5..be821665 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -1,16 +1,43 @@ import logging -import os -import shutil -from organize.utils import Mapping, find_unused_filename, fullpath +from fs import open_fs +from fs.base import FS +from fs.copy import copy_file +from fs.move import move_file +from fs.path import basename, dirname, join, splitext from .action import Action from .trash import Trash +from ..utils import Template, file_desc logger = logging.getLogger(__name__) -CONFLICT_OPTIONS = ("rename_new", "rename_old", "skip", "trash", "overwrite") +CONFLICT_OPTIONS = ( + "skip", + "overwrite", + "trash", + "rename_new", + "rename_existing", + # "keep_newer", + # "keep_older", +) + + +def next_free_filename(fs, template, name, extension): + counter = 1 + prev_candidate = "" + while True: + candidate = template.render(name=name, extension=extension, counter=counter) + if not fs.exists(candidate): + return candidate + if prev_candidate == candidate: + raise ValueError( + "Could not find a free filename for the given template. " + 'Maybe you forgot the "{counter}" placeholder?' + ) + prev_candidate = candidate + counter += 1 class Copy(Action): @@ -86,44 +113,85 @@ class Copy(Action): """ def __init__( - self, dest: str, on_conflict="rename_new", counter_separator=" " + self, + dest: str, + conflict_mode="rename_new", + rename_template="{name} {counter}{extension}", ) -> None: - if on_conflict not in CONFLICT_OPTIONS: + if conflict_mode not in CONFLICT_OPTIONS: raise ValueError( - "on_conflict must be one of %s" % ", ".join(CONFLICT_OPTIONS) + "conflict_mode must be one of %s" % ", ".join(CONFLICT_OPTIONS) ) + self.dest = dest - self.on_conflict = on_conflict - self.counter_separator = counter_separator - - def pipeline(self, args: Mapping, simulate: bool) -> None: - path = args["path"] - - expanded_dest = self.fill_template_tags(self.dest, args) - # if only a folder path is given we append the filename to have the full - # path. We use os.path for that because pathlib removes trailing slashes - if expanded_dest.endswith(("\\", "/")): - expanded_dest = os.path.join(expanded_dest, path.name) - - new_path = fullpath(expanded_dest) - if new_path.exists() and not new_path.samefile(path): - if self.overwrite: - self.print("File already exists") - Trash().run(path=new_path, simulate=simulate) - else: - new_path = find_unused_filename( - path=new_path, separator=self.counter_separator - ) + self.conflict_mode = conflict_mode + self.rename_template = Template(rename_template) + + def pipeline(self, args: dict, simulate: bool): + src_fs = args["fs"] # type: FS + src_path = args["fs_path"] - self.print('Copy to "%s"' % new_path) - if not simulate: - logger.info("Creating folder if not exists: %s", new_path.parent) - new_path.parent.mkdir(parents=True, exist_ok=True) - logger.info('Copying "%s" to "%s"', path, new_path) - shutil.copy2(src=str(path), dst=str(new_path)) + dst_path = self.fill_template_tags(self.dest, args) + # if the destination ends with a slash we assume the name should not change + if dst_path.endswith(("\\", "/")): + dst_path = join(dst_path, basename(src_path)) - # the next actions should handle the original file + dst_fs = open_fs(dirname(dst_path), writeable=True, create=True) + dst_path = basename(dst_path) + + if dst_fs.exists(dst_path): + self.print( + 'File %s already exists (conflict mode is "%s").' + % (file_desc(dst_fs, dst_path), self.conflict_mode) + ) + + if self.conflict_mode == "trash": + Trash().pipeline({"fs": dst_fs, "fs_path": dst_path}, simulate=simulate) + if not simulate: + copy_file(src_fs, src_path, dst_fs, dst_path) + self.print("Copied to %s." % file_desc(dst_fs, dst_path)) + + elif self.conflict_mode == "skip": + self.print("Skipped.") + return + + elif self.conflict_mode == "overwrite": + if not simulate: + copy_file(src_fs, src_path, dst_fs, dst_path) + self.print("Copied to %s (overwritten)." % file_desc(dst_fs, dst_path)) + + elif self.conflict_mode == "rename_new": + stem, ext = splitext(dst_path) + name = next_free_filename( + fs=dst_fs, + name=stem, + extension=ext, + template=self.rename_template, + ) + if not simulate: + copy_file(src_fs, src_path, dst_fs, name) + self.print("Copied to %s" % file_desc(dst_fs, name)) + + elif self.conflict_mode == "rename_existing": + stem, ext = splitext(dst_path) + name = next_free_filename( + fs=dst_fs, + name=stem, + extension=ext, + template=self.rename_template, + ) + self.print("Renaming existing file to: %s" % name) + if not simulate: + move_file(dst_fs, dst_path, dst_fs, name) + copy_file(src_fs, src_path, dst_fs, dst_path) + self.print("Copied to %s" % file_desc(dst_fs, dst_path)) + else: + if not simulate: + copy_file(src_fs, src_path, dst_fs, dst_path) + self.print("Copied to %s" % file_desc(dst_fs, dst_path)) + + # the next action should handle the original file return None def __str__(self) -> str: - return "Copy(dest=%s, on_conflict=%s)" % (self.dest, self.on_conflict) + return "Copy(dest=%s, conflict_mode=%s)" % (self.dest, self.conflict_mode) diff --git a/organize/actions/move.py b/organize/actions/move.py index 142820be..1c3481ec 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -4,7 +4,7 @@ from typing import Mapping from pathlib import Path -from organize.utils import DotDict, find_unused_filename, fullpath +from organize.utils import find_unused_filename, fullpath from .action import Action from .trash import Trash @@ -92,8 +92,9 @@ def __init__(self, dest: str, overwrite=False, counter_separator=" "): self.overwrite = overwrite self.counter_separator = counter_separator - def pipeline(self, args: DotDict, simulate: bool) -> Mapping[str, Path]: - path = args["path"] + def pipeline(self, args: dict, simulate: bool) -> Mapping[str, Path]: + fs = args["fs"] + fs_path = args["fs_path"] expanded_dest = self.fill_template_tags(self.dest, args) # if only a folder path is given we append the filename to have the full diff --git a/organize/actions/python.py b/organize/actions/python.py index b5b6f4dc..5304bd38 100644 --- a/organize/actions/python.py +++ b/organize/actions/python.py @@ -2,8 +2,6 @@ import textwrap from typing import Any, Mapping, Optional, Iterable -from organize.utils import DotDict - from .action import Action logger = logging.getLogger(__name__) @@ -80,7 +78,7 @@ def create_method(self, name: str, argnames: Iterable[str], code: str) -> None: ) exec(funccode, globals_, locals_) # pylint: disable=exec-used - def pipeline(self, args: DotDict, simulate: bool) -> Optional[Mapping[str, Any]]: + def pipeline(self, args: dict, simulate: bool) -> Optional[Mapping[str, Any]]: if simulate: self.print("Code not run in simulation. (Args: %s)" % args) return None diff --git a/organize/actions/trash.py b/organize/actions/trash.py index 2e4318b9..24dbaff0 100644 --- a/organize/actions/trash.py +++ b/organize/actions/trash.py @@ -31,13 +31,16 @@ class Trash(Action): name = "trash" - def pipeline(self, args: dict, simulate: bool): + def trash(self, path: str, simulate: bool): from send2trash import send2trash # type: ignore - fs = args["fs"] - fs_path = args["fs_path"] - path = fs.getsyspath(fs_path) self.print('Trash "%s"' % path) if not simulate: logger.info("Moving file %s into trash.", path) send2trash(path) + + def pipeline(self, args: dict, simulate: bool): + fs = args["fs"] + fs_path = args["fs_path"] + path = fs.getsyspath(fs_path) + self.trash(path=path, simulate=simulate) diff --git a/organize/core.py b/organize/core.py index 50d27a81..ae927248 100644 --- a/organize/core.py +++ b/organize/core.py @@ -2,17 +2,18 @@ import os from datetime import datetime from typing import Iterable, NamedTuple -from schema import SchemaError + import fs from fs.base import FS from fs.walk import Walker +from schema import SchemaError from .actions import ALL as ACTIONS from .actions.action import Action from .filters import ALL as FILTERS from .filters.filter import Filter from .tui import RichOutput, console -from .utils import DotDict +from .utils import deep_merge, deep_merge_inplace logger = logging.getLogger(__name__) @@ -113,7 +114,7 @@ def filter_pipeline(filters: Iterable[Filter], args: dict) -> bool: try: result = filter_.pipeline(args) if isinstance(result, dict): - args.update(result) + deep_merge_inplace(args, result) elif not result: # filters might return a simple True / False. # Exit early if a filter does not match. @@ -126,13 +127,13 @@ def filter_pipeline(filters: Iterable[Filter], args: dict) -> bool: return True -def action_pipeline(actions: Iterable[Action], args: DotDict, simulate: bool) -> bool: +def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bool: for action in actions: try: updates = action.pipeline(args, simulate=simulate) # jobs may return a dict with updates that should be merged into args if updates is not None: - args.update(updates) + deep_merge_inplace(args, updates) except Exception as e: # pylint: disable=broad-except logger.exception(e) action.print_error(e) @@ -162,7 +163,7 @@ def run(config, simulate: bool = True): args = { "fs": base_fs, "fs_path": path, - "path": path, # str(base_fs.getsyspath(path)), + "path": "NOT IMPLEMENTED", # str(base_fs.getsyspath(path)), "relative_path": fs.path.relativefrom(base_path, path), "env": os.environ, "now": datetime.now(), @@ -186,7 +187,7 @@ def run(config, simulate: bool = True): if __name__ == "__main__": - from .config import load_from_file, CONFIG_SCHEMA + from .config import CONFIG_SCHEMA, load_from_file conf = load_from_file("testconf.yaml") try: diff --git a/organize/filters/extension.py b/organize/filters/extension.py index 20b91f75..3a8c860b 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -1,7 +1,7 @@ from typing import Dict, Optional, Union from pathlib import Path -from organize.utils import DotDict, flatten +from organize.utils import flatten from .filter import Filter diff --git a/organize/filters/filesize.py b/organize/filters/filesize.py index cbf63906..bebbc0aa 100644 --- a/organize/filters/filesize.py +++ b/organize/filters/filesize.py @@ -2,7 +2,7 @@ import re from typing import Callable, Dict, Optional, Sequence, Set, Tuple -from organize.utils import DotDict, flattened_string_list, fullpath +from organize.utils import flattened_string_list, fullpath from .filter import Filter @@ -115,7 +115,7 @@ def __init__(self, *conditions: Sequence[str]) -> None: def matches(self, filesize: int) -> bool: return all(op(filesize, c_size) for op, c_size in self.constrains) - def pipeline(self, args: DotDict) -> Optional[Dict[str, Dict[str, int]]]: + def pipeline(self, args: dict) -> Optional[Dict[str, Dict[str, int]]]: fs = args["fs"] fs_path = args["fs_path"] diff --git a/organize/filters/filter.py b/organize/filters/filter.py index 236d4e25..7f13cf3e 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -2,8 +2,6 @@ from textwrap import indent from typing import Any, Dict, Union -from organize.utils import DotDict - FilterResult = Union[Dict[str, Any], bool, None] @@ -25,9 +23,9 @@ def schema(cls): ) def run(self, **kwargs: Dict) -> FilterResult: - return self.pipeline(DotDict(kwargs)) + return self.pipeline(dict(kwargs)) - def pipeline(self, args: DotDict) -> FilterResult: + def pipeline(self, args: dict) -> FilterResult: raise NotImplementedError def print(self, msg: str) -> None: diff --git a/organize/filters/last_modified.py b/organize/filters/last_modified.py index bf35a6f0..fa22394f 100644 --- a/organize/filters/last_modified.py +++ b/organize/filters/last_modified.py @@ -117,7 +117,7 @@ def pipeline(self, args: dict) -> Optional[Dict[str, datetime]]: fs = args["fs"] fs_path = args["fs_path"] file_modified: datetime - file_modified = fs.getinfo(fs_path, namespaces=["details"]).modified + file_modified = fs.getmodified(fs_path) if file_modified: file_modified = file_modified.astimezone() if self.timedelta.total_seconds(): diff --git a/organize/filters/regex.py b/organize/filters/regex.py index ed0e6a43..1a0b7f38 100644 --- a/organize/filters/regex.py +++ b/organize/filters/regex.py @@ -57,8 +57,8 @@ def __init__(self, expr) -> None: def matches(self, path: Path) -> Any: return self.expr.search(path.name) - def pipeline(self, args: Mapping) -> Optional[Dict[str, Dict]]: - match = self.matches(args["path"]) + def pipeline(self, args: dict) -> Optional[Dict[str, Dict]]: + match = self.matches(args["fs_path"]) if match: result = match.groupdict() return {"regex": result} diff --git a/organize/utils.py b/organize/utils.py index a2afe6d5..981fb12b 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -1,10 +1,19 @@ +from fs.errors import NoSysPath import os import re from collections.abc import Mapping from copy import deepcopy -from typing import Any, Sequence, Tuple, Union, List, Hashable - +from functools import partial from pathlib import Path +from typing import Any, Hashable, List, Sequence, Tuple, Union + +from jinja2 import Template as JinjaTemplate + +Template = partial( + JinjaTemplate, + variable_start_string="{", + variable_end_string="}", +) WILDCARD_REGEX = re.compile(r"(? Hashable: return list(dic.keys())[0] -class DotDict(dict): - """ - Quick and dirty implementation of a dot-able dict, which allows access and - assignment via object properties rather than dict indexing. - Keys are case insensitive. - """ - - def __init__(self, *args, **kwargs): - super().__init__() - # we could just call super(DotDict, self).__init__(*args, **kwargs) - # but that won't get us nested dotdict objects - od = dict(*args, **kwargs) - for key, val in od.items(): - if isinstance(val, Mapping): - value = DotDict(val) - else: - value = val - self[self.normkey(key)] = value - - @staticmethod - def normkey(key): - if isinstance(key, str): - return key.lower() - else: - return key - - def __delattr__(self, key): - try: - del self[self.normkey(key)] - except KeyError as ex: - raise AttributeError("No attribute called: %s" % key) from ex - - def __getattr__(self, key): - try: - return self[self.normkey(key)] - except KeyError as ex: - raise AttributeError("No attribute called: %s" % key) from ex - - def __setattr__(self, key, value) -> None: - self[self.normkey(key)] = value - - def update(self, other): - """recursively update the dotdict instance with another dicts items""" - for key, val in other.items(): - normkey = self.normkey(key) - if isinstance(val, Mapping): - if isinstance(self.get(normkey), dict): - self[normkey].update(val) - else: - self[normkey] = __class__(val) - else: - self[normkey] = val - - def merge(self, other) -> Mapping: - """recursively merge values from another dict and return a new instance""" - new_dct = deepcopy(self) - new_dct.update(other) - return new_dct - - -def increment_filename_version(path: Path, separator=" ") -> Path: - stem = path.stem - try: - # try to find any existing counter - splitstem = stem.split(separator) # raises ValueError on missing sep - if len(splitstem) < 2: - raise ValueError() - counter = int(splitstem[-1]) - stem = separator.join(splitstem[:-1]) - except (ValueError, IndexError): - # not found, we start with 1 - counter = 1 - return path.with_name( - "{stem}{sep}{cnt}{suffix}".format( - stem=stem, sep=separator, cnt=(counter + 1), suffix=path.suffix - ) - ) - - def find_unused_filename(path: Path, separator=" ") -> Path: """ We assume the given path already exists. This function adds a counter to the @@ -136,39 +66,28 @@ def find_unused_filename(path: Path, separator=" ") -> Path: return tmp -def dict_merge(dct, merge_dct, add_keys=True): - """Recursive dict merge. - - Inspired by :meth:``dict.update()``, instead of - updating only top-level keys, dict_merge recurses down into dicts nested - to an arbitrary depth, updating keys. The ``merge_dct`` is merged into - ``dct``. - - This version will return a copy of the dictionary and leave the original - arguments untouched. - - The optional argument ``add_keys``, determines whether keys which are - present in ``merge_dict`` but not ``dct`` should be included in the - new dict. - - Args: - dct (dict) onto which the merge is executed - merge_dct (dict): dct merged into dct - add_keys (bool): whether to add new keys - - Returns: - dict: updated dict +def deep_merge(a: dict, b: dict) -> dict: + result = deepcopy(a) + for bk, bv in b.items(): + av = result.get("k") + if isinstance(av, dict) and isinstance(bv, dict): + result[bk] = deep_merge(av, bv) + else: + result[bk] = deepcopy(bv) + return result - Taken from comment thread: https://gist.github.com/angstwad/bf22d1822c38a92ec0a9 - """ - dct = deepcopy(dct) - if not add_keys: - merge_dct = {k: merge_dct[k] for k in set(dct).intersection(set(merge_dct))} - for k, v in merge_dct.items(): - if isinstance(dct.get(k), dict) and isinstance(v, Mapping): - dct[k] = dict_merge(dct[k], v, add_keys=add_keys) +def deep_merge_inplace(base: dict, updates: dict) -> None: + for bk, bv in updates.items(): + av = base.get("k") + if isinstance(av, dict) and isinstance(bv, dict): + deep_merge_inplace(av, bv) else: - dct[k] = v + base[bk] = bv - return dct + +def file_desc(fs, path): + try: + return fs.getsyspath(path) + except NoSysPath: + return "{} on {}".format(path, fs) diff --git a/testconf.yaml b/testconf.yaml index 485d5d71..2659143f 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -3,7 +3,15 @@ rules: targets: files locations: - path: ~/Desktop/testfolder + filters: + - lastmodified + - extension: png actions: + - echo: "{lastmodified}" + - copy: + dest: ~/Desktop/test/more/folders/asd.png + conflict_mode: "trash" + rename_template: "{name}_old_{counter}{extension}" - macos_tags: "Test" # - name: Find some folders From 98cafbab85123c94fce392988d060db069630226 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 23 Jan 2022 16:04:05 +0100 Subject: [PATCH 014/108] `run_in_simulation` option --- organize/actions/python.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/organize/actions/python.py b/organize/actions/python.py index 5304bd38..f41edbe7 100644 --- a/organize/actions/python.py +++ b/organize/actions/python.py @@ -61,8 +61,9 @@ class Python(Action): webbrowser.open('https://www.google.com/search?q=%s' % path.stem) """ - def __init__(self, code) -> None: + def __init__(self, code, run_in_simulation=False) -> None: self.code = textwrap.dedent(code) + self.run_in_simulation = run_in_simulation def usercode(self, *args, **kwargs) -> Optional[Any]: pass # will be overwritten by `create_method` @@ -79,8 +80,8 @@ def create_method(self, name: str, argnames: Iterable[str], code: str) -> None: exec(funccode, globals_, locals_) # pylint: disable=exec-used def pipeline(self, args: dict, simulate: bool) -> Optional[Mapping[str, Any]]: - if simulate: - self.print("Code not run in simulation. (Args: %s)" % args) + if simulate and not self.run_in_simulation: + self.print("Code not run in simulation.", style="yellow") return None logger.info('Executing python:\n"""\n%s\n""", args=%s', self.code, args) From 7fe8e905fba64fe13402566d65976ea83ff3bc35 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 23 Jan 2022 16:04:35 +0100 Subject: [PATCH 015/108] reworked copy --- CHANGELOG.md | 1 + organize/actions/action.py | 51 +++++++++++++++++++++------------- organize/actions/confirm.py | 5 ++-- organize/actions/copy.py | 42 +++++++++++++++------------- organize/config.py | 6 ++-- organize/core.py | 7 +++-- organize/filters/filter.py | 13 ++++++++- organize/{tui.py => output.py} | 6 ++-- organize/utils.py | 21 +++++++++++++- testconf.yaml | 8 ++++-- 10 files changed, 107 insertions(+), 53 deletions(-) rename organize/{tui.py => output.py} (93%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a1dda0..525bf072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Please backup all your important stuff before running. - cleaner config file validation and stricter format - "confirm" and "prompt" action - `rename_template` option in `move` and `copy` +- option to run `python` actions in simulation ### changed diff --git a/organize/actions/action.py b/organize/actions/action.py index fd2590bd..c1b7341e 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -1,4 +1,5 @@ -from typing import Any, Mapping, Optional, Callable +from schema import Or, Schema, Optional +from typing import Any, Dict, Optional as tyOptional, Callable class Error(Exception): @@ -13,31 +14,43 @@ class Action: print_hook = None # type: Optional[Callable] print_error_hook = None # type: Optional[Callable] + name = None + arg_schema = None + schema_support_instance_without_args = False + + @classmethod + def get_name(cls): + if cls.name: + return cls.name + return cls.__name__.lower() + @classmethod - def schema(cls): - from schema import Schema, Optional, Or - - return Or( - str, - { - Optional(cls.name.lower()): Or( - str, - [str], - Schema({}, ignore_extra_keys=True), - ), - }, - ) - - def run(self, **kwargs) -> Optional[Mapping[str, Any]]: + def get_schema(cls): + if cls.arg_schema: + arg_schema = cls.arg_schema + else: + arg_schema = Or( + str, + [str], + Schema({}, ignore_extra_keys=True), + ) + if cls.is_callable_without_args: + return Or( + str, + {Optional(cls.get_name()): arg_schema}, + ) + return {Optional(cls.get_name()): arg_schema} + + def run(self, **kwargs) -> tyOptional[Dict[str, Any]]: return self.pipeline(kwargs) - def pipeline(self, args: dict) -> Optional[Mapping[str, Any]]: + def pipeline(self, args: dict) -> tyOptional[Dict[str, Any]]: raise NotImplementedError - def print(self, msg) -> None: + def print(self, msg, *args, **kwargs) -> None: """print a message for the user""" if callable(self.print_hook): - self.print_hook(name=self.name, msg=msg) + self.print_hook(name=self.name, msg=msg, *args, **kwargs) def print_error(self, msg: str): if callable(self.print_error_hook): diff --git a/organize/actions/confirm.py b/organize/actions/confirm.py index b8c81fd2..1ee76fd5 100644 --- a/organize/actions/confirm.py +++ b/organize/actions/confirm.py @@ -1,11 +1,13 @@ import logging from rich.prompt import Prompt -from ..tui import console +from ..output import console from .action import Action logger = logging.getLogger(__name__) +# TODO not working right now + class Confirm(Action): def __init__(self, msg, default): @@ -14,7 +16,6 @@ def __init__(self, msg, default): self.prompt = Prompt(console=console) def pipeline(self, args: dict, simulate: bool): - self.print("asd") chosen = self.prompt.ask("", default=self.default) self.print(chosen) diff --git a/organize/actions/copy.py b/organize/actions/copy.py index be821665..6717f9b6 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -5,10 +5,11 @@ from fs.copy import copy_file from fs.move import move_file from fs.path import basename, dirname, join, splitext +from schema import Optional, Or +from ..utils import Template, file_desc, next_free_filename from .action import Action from .trash import Trash -from ..utils import Template, file_desc logger = logging.getLogger(__name__) @@ -24,24 +25,7 @@ ) -def next_free_filename(fs, template, name, extension): - counter = 1 - prev_candidate = "" - while True: - candidate = template.render(name=name, extension=extension, counter=counter) - if not fs.exists(candidate): - return candidate - if prev_candidate == candidate: - raise ValueError( - "Could not find a free filename for the given template. " - 'Maybe you forgot the "{counter}" placeholder?' - ) - prev_candidate = candidate - counter += 1 - - class Copy(Action): - name = "copy" """ Copy a file to a new location. @@ -112,6 +96,21 @@ class Copy(Action): counter_separator: '_' """ + name = "copy" + + @classmethod + def get_schema(cls): + return Or( + cls.name, + { + cls.name: { + "dest": str, + Optional("conflict_mode"): Or(*CONFLICT_OPTIONS), + Optional("rename_template"): str, + } + }, + ) + def __init__( self, dest: str, @@ -190,8 +189,11 @@ def pipeline(self, args: dict, simulate: bool): copy_file(src_fs, src_path, dst_fs, dst_path) self.print("Copied to %s" % file_desc(dst_fs, dst_path)) - # the next action should handle the original file - return None + # the next action should work with the newly created copy + return { + "fs": dst_fs, + "fs_path": dst_path, + } def __str__(self) -> str: return "Copy(dest=%s, conflict_mode=%s)" % (self.dest, self.conflict_mode) diff --git a/organize/config.py b/organize/config.py index 79114390..951c7e4b 100644 --- a/organize/config.py +++ b/organize/config.py @@ -34,8 +34,10 @@ }, ), ], - Optional("filters"): [FILTER.schema() for FILTER in FILTERS.values()], - "actions": [ACTION.schema() for ACTION in ACTIONS.values()], + Optional("filters"): [ + FILTER.get_schema() for FILTER in FILTERS.values() + ], + "actions": [ACTION.get_schema() for ACTION in ACTIONS.values()], } ], } diff --git a/organize/core.py b/organize/core.py index ae927248..d5b929b4 100644 --- a/organize/core.py +++ b/organize/core.py @@ -12,8 +12,8 @@ from .actions.action import Action from .filters import ALL as FILTERS from .filters.filter import Filter -from .tui import RichOutput, console -from .utils import deep_merge, deep_merge_inplace +from .output import RichOutput, console +from .utils import deep_merge_inplace logger = logging.getLogger(__name__) @@ -191,9 +191,10 @@ def run(config, simulate: bool = True): conf = load_from_file("testconf.yaml") try: + console.print(CONFIG_SCHEMA) CONFIG_SCHEMA.validate(conf) replace_with_instances(conf) - run(conf, simulate=False) + run(conf, simulate=True) except SchemaError as e: console.print(e.autos[-1]) console.print(e.code) diff --git a/organize/filters/filter.py b/organize/filters/filter.py index 7f13cf3e..cc4f9d95 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -9,8 +9,19 @@ class Filter: print_hook = None print_error_hook = None + name = None + schema = None + + @classmethod + def get_name(cls): + if cls.name: + return cls.name + return cls.__name__.lower() + @classmethod - def schema(cls): + def get_schema(cls): + if cls.schema: + return cls.schema return Or( cls.name, { diff --git a/organize/tui.py b/organize/output.py similarity index 93% rename from organize/tui.py rename to organize/output.py index f08c391f..2ed893a8 100644 --- a/organize/tui.py +++ b/organize/output.py @@ -38,13 +38,13 @@ def print_location_update(self): self.print_path(self.curr_path) self.prev_path = self.curr_path - def pipeline_message(self, name, msg) -> None: + def pipeline_message(self, name, msg, *args, **kwargs) -> None: """ pre-print hook that is called everytime the moment before a filter or action is about to print something to the cli """ self.print_location_update() - self.print_pipeline_message(name, msg) + self.print_pipeline_message(name, msg, *args, **kwargs) def pipeline_error(self, name, msg): self.print_location_update() @@ -95,7 +95,7 @@ def print_not_found(self, path): msg = "Path not found: {}".format(path) console.print(msg, style="bold yellow") - def print_pipeline_message(self, name, msg): + def print_pipeline_message(self, name, msg, *args, **kwargs): console.print(indent("- (%s) %s" % (name, msg), " " * 4)) def print_pipeline_error(self, name, msg): diff --git a/organize/utils.py b/organize/utils.py index 981fb12b..2826d71b 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -1,4 +1,3 @@ -from fs.errors import NoSysPath import os import re from collections.abc import Mapping @@ -7,6 +6,8 @@ from pathlib import Path from typing import Any, Hashable, List, Sequence, Tuple, Union +from fs.base import FS +from fs.errors import NoSysPath from jinja2 import Template as JinjaTemplate Template = partial( @@ -91,3 +92,21 @@ def file_desc(fs, path): return fs.getsyspath(path) except NoSysPath: return "{} on {}".format(path, fs) + + +def next_free_filename( + fs: FS, template: JinjaTemplate, name: str, extension: str +) -> str: + counter = 1 + prev_candidate = "" + while True: + candidate = template.render(name=name, extension=extension, counter=counter) + if not fs.exists(candidate): + return candidate + if prev_candidate == candidate: + raise ValueError( + "Could not find a free filename for the given template. " + 'Maybe you forgot the "{counter}" placeholder?' + ) + prev_candidate = candidate + counter += 1 diff --git a/testconf.yaml b/testconf.yaml index 2659143f..cfeec8fc 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -9,10 +9,14 @@ rules: actions: - echo: "{lastmodified}" - copy: - dest: ~/Desktop/test/more/folders/asd.png - conflict_mode: "trash" + dest: ~/Desktop/test/more/folders/ + conflict_mode: "rename_existing" rename_template: "{name}_old_{counter}{extension}" - macos_tags: "Test" + - python: + code: | + print(fs) + run_in_simulation: false # - name: Find some folders # targets: dirs From bb093abc5e168a5c87b338e683992889b93f3284 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 23 Jan 2022 16:35:04 +0100 Subject: [PATCH 016/108] update schema checks --- organize/actions/action.py | 12 ++++-- organize/actions/copy.py | 21 ++++------ organize/core.py | 2 +- organize/filters/__init__.py | 4 +- .../{file_content.py => filecontent.py} | 0 organize/filters/filter.py | 33 +++++++++------- .../{last_modified.py => lastmodified.py} | 38 +++++++------------ 7 files changed, 53 insertions(+), 57 deletions(-) rename organize/filters/{file_content.py => filecontent.py} (100%) rename organize/filters/{last_modified.py => lastmodified.py} (84%) diff --git a/organize/actions/action.py b/organize/actions/action.py index c1b7341e..22cc214d 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -34,12 +34,16 @@ def get_schema(cls): [str], Schema({}, ignore_extra_keys=True), ) - if cls.is_callable_without_args: + if cls.schema_support_instance_without_args: return Or( - str, - {Optional(cls.get_name()): arg_schema}, + cls.get_name(), + { + cls.get_name(): arg_schema, + }, ) - return {Optional(cls.get_name()): arg_schema} + return { + cls.get_name(): arg_schema, + } def run(self, **kwargs) -> tyOptional[Dict[str, Any]]: return self.pipeline(kwargs) diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 6717f9b6..1fa34129 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -97,19 +97,14 @@ class Copy(Action): """ name = "copy" - - @classmethod - def get_schema(cls): - return Or( - cls.name, - { - cls.name: { - "dest": str, - Optional("conflict_mode"): Or(*CONFLICT_OPTIONS), - Optional("rename_template"): str, - } - }, - ) + arg_schema = Or( + str, + { + "dest": str, + Optional("conflict_mode"): Or(*CONFLICT_OPTIONS), + Optional("rename_template"): str, + }, + ) def __init__( self, diff --git a/organize/core.py b/organize/core.py index d5b929b4..828b871f 100644 --- a/organize/core.py +++ b/organize/core.py @@ -191,7 +191,7 @@ def run(config, simulate: bool = True): conf = load_from_file("testconf.yaml") try: - console.print(CONFIG_SCHEMA) + # console.print(CONFIG_SCHEMA.json_schema("asd")) CONFIG_SCHEMA.validate(conf) replace_with_instances(conf) run(conf, simulate=True) diff --git a/organize/filters/__init__.py b/organize/filters/__init__.py index 0c2b3136..f2526b47 100644 --- a/organize/filters/__init__.py +++ b/organize/filters/__init__.py @@ -2,10 +2,10 @@ from .duplicate import Duplicate from .exif import Exif from .extension import Extension -from .file_content import FileContent +from .filecontent import FileContent from .filename import Filename from .filesize import FileSize -from .last_modified import LastModified +from .lastmodified import LastModified from .mimetype import MimeType from .python import Python from .regex import Regex diff --git a/organize/filters/file_content.py b/organize/filters/filecontent.py similarity index 100% rename from organize/filters/file_content.py rename to organize/filters/filecontent.py diff --git a/organize/filters/filter.py b/organize/filters/filter.py index cc4f9d95..74aa8ce3 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -10,7 +10,8 @@ class Filter: print_error_hook = None name = None - schema = None + arg_schema = None + schema_support_instance_without_args = False @classmethod def get_name(cls): @@ -20,18 +21,24 @@ def get_name(cls): @classmethod def get_schema(cls): - if cls.schema: - return cls.schema - return Or( - cls.name, - { - Optional(cls.name): Or( - str, - [str], - Schema({}, ignore_extra_keys=True), - ), - }, - ) + if cls.arg_schema: + arg_schema = cls.arg_schema + else: + arg_schema = Or( + str, + [str], + Schema({}, ignore_extra_keys=True), + ) + if cls.schema_support_instance_without_args: + return Or( + cls.get_name(), + { + cls.get_name(): arg_schema, + }, + ) + return { + cls.get_name(): arg_schema, + } def run(self, **kwargs: Dict) -> FilterResult: return self.pipeline(dict(kwargs)) diff --git a/organize/filters/last_modified.py b/organize/filters/lastmodified.py similarity index 84% rename from organize/filters/last_modified.py rename to organize/filters/lastmodified.py index fa22394f..1cc3f39d 100644 --- a/organize/filters/last_modified.py +++ b/organize/filters/lastmodified.py @@ -1,7 +1,6 @@ +from schema import Or, Optional from datetime import datetime, timedelta -from optparse import Option -from time import time -from typing import Dict, Optional +from typing import Dict, Optional as tyOptional from .filter import Filter @@ -89,6 +88,17 @@ class LastModified(Filter): """ name = "lastmodified" + schema_support_instance_without_args = True + arg_schema = { + Optional("mode"): Or("older", "newer"), + Optional("years"): int, + Optional("months"): int, + Optional("weeks"): int, + Optional("days"): int, + Optional("hours"): int, + Optional("minutes"): int, + Optional("seconds"): int, + } def __init__( self, @@ -113,7 +123,7 @@ def __init__( seconds=seconds, ) - def pipeline(self, args: dict) -> Optional[Dict[str, datetime]]: + def pipeline(self, args: dict) -> tyOptional[Dict[str, datetime]]: fs = args["fs"] fs_path = args["fs_path"] file_modified: datetime @@ -139,23 +149,3 @@ def __str__(self): self._mode, self.timedelta, ) - - @classmethod - def schema(cls): - from schema import Optional, Or - - return Or( - cls.name, - { - Optional(cls.name): { - Optional("mode"): Or("older", "newer"), - Optional("years"): int, - Optional("months"): int, - Optional("weeks"): int, - Optional("days"): int, - Optional("hours"): int, - Optional("minutes"): int, - Optional("seconds"): int, - } - }, - ) From 4ebb51c637a02770e03f663914a44711e7a2fa25 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 23 Jan 2022 16:48:11 +0100 Subject: [PATCH 017/108] added schema for delete, echo, macos_tags --- organize/actions/delete.py | 7 +++++-- organize/actions/echo.py | 8 ++++++-- organize/actions/macos_tags.py | 7 +++++-- organize/config.py | 6 ++++-- organize/core.py | 2 +- 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/organize/actions/delete.py b/organize/actions/delete.py index 1447298c..aaa5b295 100644 --- a/organize/actions/delete.py +++ b/organize/actions/delete.py @@ -1,8 +1,7 @@ -import os import logging from typing import Mapping -from pathlib import Path +from schema import Optional, Or from .action import Action @@ -37,6 +36,10 @@ class Delete(Action): name = "delete" + @classmethod + def get_schema(cls): + return cls.name + def pipeline(self, args: Mapping, simulate: bool): fs = args["fs"] fs_path = args["fs_path"] diff --git a/organize/actions/echo.py b/organize/actions/echo.py index bfc7577c..5ef01e1d 100644 --- a/organize/actions/echo.py +++ b/organize/actions/echo.py @@ -68,6 +68,12 @@ class Echo(Action): - echo: 'Path: {path}' """ + name = "echo" + + @classmethod + def get_schema(cls): + return {cls.name: str} + def __init__(self, msg) -> None: self.msg = msg self.log = logging.getLogger(__name__) @@ -81,5 +87,3 @@ def pipeline(self, args: dict, simulate: bool) -> None: def __str__(self) -> str: return 'Echo(msg="%s")' % self.msg - - name = "echo" diff --git a/organize/actions/macos_tags.py b/organize/actions/macos_tags.py index 0c7d6e23..63134d0c 100644 --- a/organize/actions/macos_tags.py +++ b/organize/actions/macos_tags.py @@ -1,9 +1,8 @@ import logging import sys -from pathlib import Path -from typing import Mapping import simplematch as sm # type: ignore +from schema import Or from .action import Action @@ -78,6 +77,10 @@ class MacOSTags(Action): - Year-{created.year} (red) """ + @classmethod + def get_schema(cls): + return {cls.name: Or(str, [str])} + def __init__(self, *tags): self.tags = tags diff --git a/organize/config.py b/organize/config.py index 951c7e4b..515d3752 100644 --- a/organize/config.py +++ b/organize/config.py @@ -35,9 +35,11 @@ ), ], Optional("filters"): [ - FILTER.get_schema() for FILTER in FILTERS.values() + Optional(FILTER.get_schema()) for FILTER in FILTERS.values() + ], + "actions": [ + Optional(ACTION.get_schema()) for ACTION in ACTIONS.values() ], - "actions": [ACTION.get_schema() for ACTION in ACTIONS.values()], } ], } diff --git a/organize/core.py b/organize/core.py index 828b871f..243d5c74 100644 --- a/organize/core.py +++ b/organize/core.py @@ -191,7 +191,7 @@ def run(config, simulate: bool = True): conf = load_from_file("testconf.yaml") try: - # console.print(CONFIG_SCHEMA.json_schema("asd")) + console.print(CONFIG_SCHEMA.json_schema("asd")) CONFIG_SCHEMA.validate(conf) replace_with_instances(conf) run(conf, simulate=True) From 54961db78540cbe491b2f0d2c496362781829b29 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 10:16:38 +0100 Subject: [PATCH 018/108] rename filters, add schema, size dirs --- organize/actions/python.py | 17 ++++++-- organize/cli.py | 1 + organize/core.py | 2 +- organize/filters/__init__.py | 8 ++-- organize/filters/created.py | 21 +++++++-- organize/filters/exif.py | 6 ++- organize/filters/extension.py | 2 + organize/filters/filecontent.py | 2 + organize/filters/mimetype.py | 3 ++ organize/filters/{filename.py => name.py} | 6 +-- organize/filters/{filesize.py => size.py} | 52 ++++++++++++++++------- testconf.yaml | 23 ++++------ 12 files changed, 96 insertions(+), 47 deletions(-) rename organize/filters/{filename.py => name.py} (98%) rename organize/filters/{filesize.py => size.py} (70%) diff --git a/organize/actions/python.py b/organize/actions/python.py index f41edbe7..af4d3ff2 100644 --- a/organize/actions/python.py +++ b/organize/actions/python.py @@ -1,6 +1,9 @@ import logging import textwrap -from typing import Any, Mapping, Optional, Iterable +from typing import Any, Dict, Iterable +from typing import Optional as tyOptional + +from schema import Optional, Or from .action import Action @@ -61,11 +64,19 @@ class Python(Action): webbrowser.open('https://www.google.com/search?q=%s' % path.stem) """ + arg_schema = Or( + str, + { + "code": str, + Optional("run_in_simulation"): bool, + }, + ) + def __init__(self, code, run_in_simulation=False) -> None: self.code = textwrap.dedent(code) self.run_in_simulation = run_in_simulation - def usercode(self, *args, **kwargs) -> Optional[Any]: + def usercode(self, *args, **kwargs): pass # will be overwritten by `create_method` def create_method(self, name: str, argnames: Iterable[str], code: str) -> None: @@ -79,7 +90,7 @@ def create_method(self, name: str, argnames: Iterable[str], code: str) -> None: ) exec(funccode, globals_, locals_) # pylint: disable=exec-used - def pipeline(self, args: dict, simulate: bool) -> Optional[Mapping[str, Any]]: + def pipeline(self, args: dict, simulate: bool) -> tyOptional[Dict[str, Any]]: if simulate and not self.run_in_simulation: self.print("Code not run in simulation.", style="yellow") return None diff --git a/organize/cli.py b/organize/cli.py index 10291c47..f6e7d823 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -6,6 +6,7 @@ organize run [] organize config [--open-folder | --path | --debug] [] organize list + organize schema organize --help organize --version diff --git a/organize/core.py b/organize/core.py index 243d5c74..7246a7d5 100644 --- a/organize/core.py +++ b/organize/core.py @@ -191,7 +191,7 @@ def run(config, simulate: bool = True): conf = load_from_file("testconf.yaml") try: - console.print(CONFIG_SCHEMA.json_schema("asd")) + console.print(CONFIG_SCHEMA.json_schema(None)) CONFIG_SCHEMA.validate(conf) replace_with_instances(conf) run(conf, simulate=True) diff --git a/organize/filters/__init__.py b/organize/filters/__init__.py index f2526b47..5d56081b 100644 --- a/organize/filters/__init__.py +++ b/organize/filters/__init__.py @@ -3,12 +3,12 @@ from .exif import Exif from .extension import Extension from .filecontent import FileContent -from .filename import Filename -from .filesize import FileSize from .lastmodified import LastModified from .mimetype import MimeType +from .name import Name from .python import Python from .regex import Regex +from .size import Size ALL = { Created.name: Created, @@ -16,8 +16,8 @@ Exif.name: Exif, Extension.name: Extension, FileContent.name: FileContent, - Filename.name: Filename, - FileSize.name: FileSize, + Name.name: Name, + Size.name: Size, LastModified.name: LastModified, MimeType.name: MimeType, Python.name: Python, diff --git a/organize/filters/created.py b/organize/filters/created.py index 7a401ce7..5bc90fe1 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -1,12 +1,11 @@ +from schema import Optional, Or from datetime import datetime, timedelta -from typing import Dict, Optional +from typing import Dict, Optional as tyOptional from .filter import Filter class Created(Filter): - name = "created" - """ Matches files by created date @@ -86,6 +85,19 @@ class Created(Filter): - move: '~/Documents/PDF/{created.year}/' """ + name = "created" + schema_support_instance_without_args = True + arg_schema = { + Optional("years"): int, + Optional("months"): int, + Optional("weeks"): int, + Optional("days"): int, + Optional("hours"): int, + Optional("minutes"): int, + Optional("seconds"): int, + Optional("mode"): Or("older", "newer"), + } + def __init__( self, years=0, @@ -109,9 +121,10 @@ def __init__( seconds=seconds, ) - def pipeline(self, args: dict) -> Optional[Dict[str, datetime]]: + def pipeline(self, args: dict) -> tyOptional[Dict[str, datetime]]: fs = args["fs"] fs_path = args["fs_path"] + file_created: datetime file_created = fs.getinfo(fs_path, namespaces=["details"]).created if file_created: diff --git a/organize/filters/exif.py b/organize/filters/exif.py index 7d0d03c6..4e98d32b 100644 --- a/organize/filters/exif.py +++ b/organize/filters/exif.py @@ -11,8 +11,6 @@ class Exif(Filter): - name = "exif" - """ Filter by image EXIF data @@ -89,6 +87,10 @@ class Exif(Filter): - move: '~/Pictures/{exif.image.model}/' """ + name = "exif" + arg_schema = None + schema_support_instance_without_args = True + def __init__(self, *required_tags: str, **tag_filters: str) -> None: self.args = required_tags # expected exif keys self.kwargs = tag_filters # exif keys with expected values diff --git a/organize/filters/extension.py b/organize/filters/extension.py index 3a8c860b..7722e60c 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -121,6 +121,8 @@ def matches(self, suffix: str) -> Union[bool, str]: def pipeline(self, args: dict): fs = args["fs"] fs_path = args["fs_path"] + if fs.isdir(fs_path): + raise ValueError("Dirs not supported") suffix = fs.getinfo(fs_path).suffix if self.matches(suffix): result = ExtensionResult(suffix) diff --git a/organize/filters/filecontent.py b/organize/filters/filecontent.py index d8af79c4..c947acb3 100644 --- a/organize/filters/filecontent.py +++ b/organize/filters/filecontent.py @@ -81,6 +81,8 @@ def matches(self, path: str, extension: str) -> Any: def pipeline(self, args: Mapping) -> Optional[Dict[str, Dict]]: fs = args["fs"] fs_path = args["fs_path"] + if fs.isdir(fs_path): + raise ValueError("Dirs not supported") extension = fs.getinfo(fs_path).suffix try: syspath = fs.getsyspath(fs_path) diff --git a/organize/filters/mimetype.py b/organize/filters/mimetype.py index edecb836..1f08707d 100644 --- a/organize/filters/mimetype.py +++ b/organize/filters/mimetype.py @@ -91,7 +91,10 @@ def matches(self, mimetype): return any(mimetype.startswith(x) for x in self.mimetypes) def pipeline(self, args: dict): + fs = args["fs"] fs_path = args["fs_path"] + if fs.isdir(fs_path): + raise ValueError("Dirs not supported.") mimetype = self.mimetype(fs_path) if self.matches(mimetype): diff --git a/organize/filters/filename.py b/organize/filters/name.py similarity index 98% rename from organize/filters/filename.py rename to organize/filters/name.py index 2a1ab2f3..024f7a1d 100644 --- a/organize/filters/filename.py +++ b/organize/filters/name.py @@ -7,9 +7,7 @@ from .filter import Filter -class Filename(Filter): - name = "filename" - +class Name(Filter): """ Match files by filename @@ -82,6 +80,8 @@ class Filename(Filter): - echo: 'Found a match.' """ + name = "name" + def __init__( self, match="*", *, startswith="", contains="", endswith="", case_sensitive=True ) -> None: diff --git a/organize/filters/filesize.py b/organize/filters/size.py similarity index 70% rename from organize/filters/filesize.py rename to organize/filters/size.py index bebbc0aa..c84dad72 100644 --- a/organize/filters/filesize.py +++ b/organize/filters/size.py @@ -1,6 +1,11 @@ import operator import re -from typing import Callable, Dict, Optional, Sequence, Set, Tuple +from typing import Callable, Dict +from typing import Optional as Opt +from typing import Sequence, Set, Tuple + +from fs.filesize import binary, decimal, traditional +from schema import Optional, Or from organize.utils import flattened_string_list, fullpath @@ -20,7 +25,7 @@ ) -def create_constrains(inp: str) -> Set[Tuple[Callable[[int, int], bool], int]]: +def create_constraints(inp: str) -> Set[Tuple[Callable[[int, int], bool], int]]: """ Given an input string it returns a list of tuples (comparison operator, number of bytes). @@ -30,7 +35,7 @@ def create_constrains(inp: str) -> Set[Tuple[Callable[[int, int], bool], int]]: we calculate base 1024. """ result = set() # type: Set[Tuple[Callable[[int, int], bool], int]] - parts = inp.replace(" ", "").lower().split(",") + parts = str(inp).replace(" ", "").lower().split(",") for part in parts: try: reg_match = SIZE_REGEX.match(part) @@ -48,11 +53,11 @@ def create_constrains(inp: str) -> Set[Tuple[Callable[[int, int], bool], int]]: return result -def satisfies_constrains(size, constrains): - return all(op(size, p_size) for op, p_size in constrains) +def satisfies_constraints(size, constraints): + return all(op(size, p_size) for op, p_size in constraints) -class FileSize(Filter): +class Size(Filter): """ Matches files by file size @@ -85,7 +90,7 @@ class FileSize(Filter): actions: - trash - - Move all JPEGS bigger > 1MB and <10 MB. Search all subfolders and keep the´ + - Move all JPEGS bigger > 1MB and <10 MB. Search all subfolders and keep the original relative path. .. code-block:: yaml @@ -104,24 +109,39 @@ class FileSize(Filter): """ - name = "filesize" + name = "size" + arg_schema = Optional(Or(str, [str], int, [int])) + schema_support_instance_without_args = True def __init__(self, *conditions: Sequence[str]) -> None: self.conditions = ", ".join(flattened_string_list(list(conditions))) - self.constrains = create_constrains(self.conditions) - if not self.constrains: - raise ValueError("No size(s) given!") + self.constraints = create_constraints(self.conditions) def matches(self, filesize: int) -> bool: - return all(op(filesize, c_size) for op, c_size in self.constrains) + if not self.constraints: + return True + return all(op(filesize, c_size) for op, c_size in self.constraints) - def pipeline(self, args: dict) -> Optional[Dict[str, Dict[str, int]]]: + def pipeline(self, args: dict) -> Opt[Dict[str, Dict[str, int]]]: fs = args["fs"] fs_path = args["fs_path"] - file_size = fs.getinfo(fs_path, namespaces=["details"]).size - if self.matches(file_size): - return {"filesize": {"bytes": file_size}} + if fs.isdir(fs_path): + size = sum( + info.size + for _, info in fs.walk.info(path=fs_path, namespaces=["details"]) + ) + else: + size = fs.getinfo(fs_path, namespaces=["details"]).size + if self.matches(size): + return { + self.name: { + "bytes": size, + "traditional": traditional(size), + "binary": binary(size), + "decimal": decimal(size), + }, + } return None def __str__(self) -> str: diff --git a/testconf.yaml b/testconf.yaml index cfeec8fc..3538a18a 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -1,22 +1,17 @@ rules: - name: "Find the last modification date of some png files" - targets: files + targets: dirs locations: - - path: ~/Desktop/testfolder + - path: ~/Desktop/ + max_depth: 2 filters: - - lastmodified - - extension: png + - name: + startswith: "Inbo" + - created + - size actions: - - echo: "{lastmodified}" - - copy: - dest: ~/Desktop/test/more/folders/ - conflict_mode: "rename_existing" - rename_template: "{name}_old_{counter}{extension}" - - macos_tags: "Test" - - python: - code: | - print(fs) - run_in_simulation: false + - echo: "{created}" + - echo: "{size}" # - name: Find some folders # targets: dirs From cf4131048cf83684756e542f9f254cdc82b07121 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 10:23:20 +0100 Subject: [PATCH 019/108] fix regex --- organize/filters/regex.py | 10 ++++------ testconf.yaml | 1 + 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/organize/filters/regex.py b/organize/filters/regex.py index 1a0b7f38..7816aef3 100644 --- a/organize/filters/regex.py +++ b/organize/filters/regex.py @@ -1,8 +1,6 @@ import re from typing import Any, Dict, Mapping, Optional -from pathlib import Path - from .filter import Filter @@ -49,16 +47,16 @@ class Regex(Filter): - move: ~/Documents/Invoices/1und1/{regex.the_number}.pdf """ - name = "python" + name = "regex" def __init__(self, expr) -> None: self.expr = re.compile(expr, flags=re.UNICODE) - def matches(self, path: Path) -> Any: - return self.expr.search(path.name) + def matches(self, path: str) -> Any: + return self.expr.search(path) def pipeline(self, args: dict) -> Optional[Dict[str, Dict]]: - match = self.matches(args["fs_path"]) + match = self.matches(args["relative_path"]) if match: result = match.groupdict() return {"regex": result} diff --git a/testconf.yaml b/testconf.yaml index 3538a18a..2f85eeda 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -9,6 +9,7 @@ rules: startswith: "Inbo" - created - size + - regex: ^.* actions: - echo: "{created}" - echo: "{size}" From dbf3aa450a9908a26a94a010df2b83de21b537fc Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 10:26:14 +0100 Subject: [PATCH 020/108] remove unused utils --- organize/utils.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/organize/utils.py b/organize/utils.py index 2826d71b..678153d0 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -16,18 +16,6 @@ variable_end_string="}", ) -WILDCARD_REGEX = re.compile(r"(? Tuple[Path, str]: - """split a string with wildcards into a base folder and globstring""" - path = fullpath(globstr.strip()) - parts = path.parts - for i, part in enumerate(parts): - if WILDCARD_REGEX.search(part): - return (Path(*parts[:i]), str(Path(*parts[i:]))) - return (path, "") - def fullpath(path: Union[str, Path]) -> Path: """Expand '~' and resolve the given path. Path can be a string or a Path obj.""" @@ -53,20 +41,6 @@ def first_key(dic: Mapping) -> Hashable: return list(dic.keys())[0] -def find_unused_filename(path: Path, separator=" ") -> Path: - """ - We assume the given path already exists. This function adds a counter to the - filename until we find a unused filename. - """ - # TODO: Check whether the assumption can be eliminated for cleaner code. - # TODO: Optimization: The counter only needs to be parsed once. - tmp = path - while True: - tmp = increment_filename_version(tmp, separator=separator) - if not tmp.exists(): - return tmp - - def deep_merge(a: dict, b: dict) -> dict: result = deepcopy(a) for bk, bv in b.items(): From 529c4620f3e1c6419affc46a0b66f14c93fdee56 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 11:31:34 +0100 Subject: [PATCH 021/108] many new features for the shell action --- organize/actions/shell.py | 53 ++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/organize/actions/shell.py b/organize/actions/shell.py index 5818bf0c..02f0841f 100644 --- a/organize/actions/shell.py +++ b/organize/actions/shell.py @@ -1,7 +1,10 @@ +from schema import Or, Optional +import shlex import logging import subprocess -from typing import Mapping +from subprocess import PIPE +from ..utils import JinjaEnv from .action import Action logger = logging.getLogger(__name__) @@ -28,18 +31,50 @@ class Shell(Action): - shell: 'open "{path}"' """ - def __init__(self, cmd: str) -> None: - self.cmd = cmd + name = "shell" + arg_schema = Or( + str, + { + "cmd": str, + Optional("run_in_simulation"): bool, + Optional("ignore_errors"): bool, + }, + ) + + def __init__(self, cmd: str, run_in_simulation=False, ignore_errors=False): + self.cmd = JinjaEnv.from_string(cmd) + self.run_in_simulation = run_in_simulation + self.ignore_errors = ignore_errors - def pipeline(self, args: Mapping, simulate: bool) -> None: - full_cmd = self.fill_template_tags(self.cmd, args) + def pipeline(self, args: dict, simulate: bool) -> None: + full_cmd = self.cmd.render(**args) self.print("$ %s" % full_cmd) - if not simulate: + if not simulate or self.run_in_simulation: # we use call instead of run to be compatible with python < 3.5 logger.info('Executing command "%s" in shell.', full_cmd) - subprocess.call(full_cmd, shell=True) + try: + lexed = shlex.split(full_cmd) + call = subprocess.run( + lexed, + check=True, + stdout=PIPE, + stderr=subprocess.STDOUT, + ) + return { + self.get_name(): { + "output": call.stdout.decode("utf-8"), + "returncode": 0, + } + } + except subprocess.CalledProcessError as e: + if not self.ignore_errors: + raise e + return { + self.get_name(): { + "output": e.stdout.decode("utf-8"), + "returncode": e.returncode, + } + } def __str__(self) -> str: return 'Shell(cmd="%s")' % self.cmd - - name = "shell" From 365fdb0e62e6a67076cc9017c2984593e2a21d5b Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 11:32:06 +0100 Subject: [PATCH 022/108] support single str locations --- organize/config.py | 39 ++++++++++++++++++---------------- organize/core.py | 52 +++++++++++++++++++++++++--------------------- 2 files changed, 49 insertions(+), 42 deletions(-) diff --git a/organize/config.py b/organize/config.py index 515d3752..453c96ae 100644 --- a/organize/config.py +++ b/organize/config.py @@ -16,24 +16,27 @@ { Optional("name", description="The name of the rule"): And(str, len), Optional("targets"): Or("dirs", "files"), - "locations": [ - Or( - str, - { - "path": And(str, len), - Optional("filesystem"): str, - Optional("max_depth"): Or(int, None), - Optional("search"): Or("depth", "breadth"), - Optional("exclude_files"): [str], - Optional("exclude_dirs"): [str], - Optional("system_exlude_files"): [str], - Optional("system_exclude_dirs"): [str], - Optional("ignore_errors"): bool, - Optional("filter"): [str], - Optional("filter_dirs"): [str], - }, - ), - ], + "locations": Or( + str, + [ + Or( + str, + { + "path": And(str, len), + Optional("filesystem"): str, + Optional("max_depth"): Or(int, None), + Optional("search"): Or("depth", "breadth"), + Optional("exclude_files"): [str], + Optional("exclude_dirs"): [str], + Optional("system_exlude_files"): [str], + Optional("system_exclude_dirs"): [str], + Optional("ignore_errors"): bool, + Optional("filter"): [str], + Optional("filter_dirs"): [str], + }, + ), + ], + ), Optional("filters"): [ Optional(FILTER.get_schema()) for FILTER in FILTERS.values() ], diff --git a/organize/core.py b/organize/core.py index 7246a7d5..b0ca7d3f 100644 --- a/organize/core.py +++ b/organize/core.py @@ -97,6 +97,9 @@ def instantiate_by_name(d, classes): def replace_with_instances(config): for rule in config["rules"]: + # wrap locations given as a single string in a list + if isinstance(rule["locations"], str): + rule["locations"] = [rule["locations"]] rule["locations"] = [instantiate_location(loc) for loc in rule["locations"]] # filters are optional rule["filters"] = [ @@ -155,32 +158,33 @@ def run(config, simulate: bool = True): target = rule.get("targets", "files") output_helper.print_rule(rule["name"]) - # status_verb = "simulating" if simulate else "organizing" - # with console.status("[bold green]%s..." % status_verb) as status: - for walker, base_fs, base_path in rule["locations"]: - walk = walker.files if target == "files" else walker.dirs - for path in walk(fs=base_fs, path=base_path): - args = { - "fs": base_fs, - "fs_path": path, - "path": "NOT IMPLEMENTED", # str(base_fs.getsyspath(path)), - "relative_path": fs.path.relativefrom(base_path, path), - "env": os.environ, - "now": datetime.now(), - "utcnow": datetime.utcnow(), - } - output_helper.set_location(base_fs, path) - match = filter_pipeline( - filters=rule["filters"], - args=args, - ) - if match: - success = action_pipeline( - actions=rule["actions"], + status_verb = "simulating" if simulate else "organizing" + with console.status("[bold green]%s..." % status_verb) as status: + for walker, base_fs, base_path in rule["locations"]: + walk = walker.files if target == "files" else walker.dirs + for path in walk(fs=base_fs, path=base_path): + relative_path = fs.path.relativefrom(base_path, path) + output_helper.set_location(base_fs, relative_path) + args = { + "fs": base_fs, + "fs_path": path, + "relative_path": relative_path, + "env": os.environ, + "now": datetime.now(), + "utcnow": datetime.utcnow(), + "path": lambda: base_fs.getsyspath(path), + } + match = filter_pipeline( + filters=rule["filters"], args=args, - simulate=simulate, ) - count[success] += 1 + if match: + success = action_pipeline( + actions=rule["actions"], + args=args, + simulate=simulate, + ) + count[success] += 1 if simulate: output_helper.print_simulation_banner() From 4e3412204abae9ca47d51393beab9add3c8f2c58 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 11:32:15 +0100 Subject: [PATCH 023/108] improve JinjaEnv --- organize/actions/copy.py | 8 ++++---- organize/actions/echo.py | 9 ++++----- organize/actions/move.py | 2 +- organize/actions/rename.py | 1 - organize/filters/size.py | 7 ++++--- organize/utils.py | 13 +++++++------ testconf.yaml | 11 ++++++++--- 7 files changed, 28 insertions(+), 23 deletions(-) diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 1fa34129..bb6e0dde 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -7,7 +7,7 @@ from fs.path import basename, dirname, join, splitext from schema import Optional, Or -from ..utils import Template, file_desc, next_free_filename +from ..utils import JinjaEnv, file_desc, next_free_filename from .action import Action from .trash import Trash @@ -117,15 +117,15 @@ def __init__( "conflict_mode must be one of %s" % ", ".join(CONFLICT_OPTIONS) ) - self.dest = dest + self.dest = JinjaEnv.from_string(dest) self.conflict_mode = conflict_mode - self.rename_template = Template(rename_template) + self.rename_template = JinjaEnv.from_string(rename_template) def pipeline(self, args: dict, simulate: bool): src_fs = args["fs"] # type: FS src_path = args["fs_path"] - dst_path = self.fill_template_tags(self.dest, args) + dst_path = self.dst.render(**args) # if the destination ends with a slash we assume the name should not change if dst_path.endswith(("\\", "/")): dst_path = join(dst_path, basename(src_path)) diff --git a/organize/actions/echo.py b/organize/actions/echo.py index 5ef01e1d..b3e55549 100644 --- a/organize/actions/echo.py +++ b/organize/actions/echo.py @@ -4,6 +4,8 @@ logger = logging.getLogger(__name__) +from ..utils import JinjaEnv + class Echo(Action): @@ -75,14 +77,11 @@ def get_schema(cls): return {cls.name: str} def __init__(self, msg) -> None: - self.msg = msg + self.msg = JinjaEnv.from_string(msg) self.log = logging.getLogger(__name__) def pipeline(self, args: dict, simulate: bool) -> None: - path = args["path"] - logger.debug('Echo msg "%s", path: "%s", args: "%s"', self.msg, path, args) - full_msg = self.fill_template_tags(self.msg, args) - logger.info("Console output: %s", full_msg) + full_msg = self.msg.render(**args) self.print("%s" % full_msg) def __str__(self) -> str: diff --git a/organize/actions/move.py b/organize/actions/move.py index 1c3481ec..5ffda668 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -4,7 +4,7 @@ from typing import Mapping from pathlib import Path -from organize.utils import find_unused_filename, fullpath +from organize.utils import fullpath from .action import Action from .trash import Trash diff --git a/organize/actions/rename.py b/organize/actions/rename.py index c0c92423..698d4808 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -3,7 +3,6 @@ from typing import Mapping from pathlib import Path -from organize.utils import find_unused_filename from .action import Action from .trash import Trash diff --git a/organize/filters/size.py b/organize/filters/size.py index c84dad72..f70c8715 100644 --- a/organize/filters/size.py +++ b/organize/filters/size.py @@ -59,7 +59,7 @@ def satisfies_constraints(size, constraints): class Size(Filter): """ - Matches files by file size + Matches files and folders by size :param str conditions: @@ -75,7 +75,7 @@ class Size(Filter): - If binary prefix is given (KiB, GiB) the size is calculated using base 1024. :returns: - - ``{filesize.bytes}`` -- File size in bytes + - ``{size.bytes}`` -- Size in bytes Examples: - Trash big downloads: @@ -84,7 +84,8 @@ class Size(Filter): :caption: config.yaml rules: - - folders: '~/Downloads' + - locations: '~/Downloads' + targets: files filters: - filesize: '> 0.5 GB' actions: diff --git a/organize/utils.py b/organize/utils.py index 678153d0..fd57d303 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -8,13 +8,16 @@ from fs.base import FS from fs.errors import NoSysPath -from jinja2 import Template as JinjaTemplate +from jinja2 import Environment, Template -Template = partial( - JinjaTemplate, + +JinjaEnv = Environment( variable_start_string="{", variable_end_string="}", + finalize=lambda x: x() if callable(x) else x, + autoescape=False, ) +JinjaEnv.globals.update({"path": lambda: vars()}) def fullpath(path: Union[str, Path]) -> Path: @@ -68,9 +71,7 @@ def file_desc(fs, path): return "{} on {}".format(path, fs) -def next_free_filename( - fs: FS, template: JinjaTemplate, name: str, extension: str -) -> str: +def next_free_filename(fs: FS, template: Template, name: str, extension: str) -> str: counter = 1 prev_candidate = "" while True: diff --git a/testconf.yaml b/testconf.yaml index 2f85eeda..265df13b 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -2,17 +2,22 @@ rules: - name: "Find the last modification date of some png files" targets: dirs locations: - - path: ~/Desktop/ + - path: ~/Desktop max_depth: 2 filters: - - name: - startswith: "Inbo" - created - size - regex: ^.* actions: + - echo: "{path}" - echo: "{created}" - echo: "{size}" + - shell: + cmd: "ls" + run_in_simulation: true + ignore_errors: no + - echo: "{shell}" + # - name: Find some folders # targets: dirs From f469af2403fc3d6e9948ddff5e4d833a4252adaa Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 12:32:38 +0100 Subject: [PATCH 024/108] improve name filter --- organize/actions/python.py | 3 +-- organize/actions/shell.py | 1 - organize/actions/trash.py | 8 +++++--- organize/core.py | 14 +++++++------- organize/filters/mimetype.py | 1 + organize/filters/name.py | 35 ++++++++++++++++++++++------------- testconf.yaml | 14 +++----------- 7 files changed, 39 insertions(+), 37 deletions(-) diff --git a/organize/actions/python.py b/organize/actions/python.py index af4d3ff2..e28d7d16 100644 --- a/organize/actions/python.py +++ b/organize/actions/python.py @@ -64,6 +64,7 @@ class Python(Action): webbrowser.open('https://www.google.com/search?q=%s' % path.stem) """ + name = "python" arg_schema = Or( str, { @@ -101,5 +102,3 @@ def pipeline(self, args: dict, simulate: bool) -> tyOptional[Dict[str, Any]]: result = self.usercode(**args) # pylint: disable=assignment-from-no-return return result - - name = "python" diff --git a/organize/actions/shell.py b/organize/actions/shell.py index 02f0841f..5517a62e 100644 --- a/organize/actions/shell.py +++ b/organize/actions/shell.py @@ -11,7 +11,6 @@ class Shell(Action): - """ Executes a shell command diff --git a/organize/actions/trash.py b/organize/actions/trash.py index 24dbaff0..7f4e9f70 100644 --- a/organize/actions/trash.py +++ b/organize/actions/trash.py @@ -31,6 +31,10 @@ class Trash(Action): name = "trash" + @classmethod + def get_schema(cls): + return cls.name + def trash(self, path: str, simulate: bool): from send2trash import send2trash # type: ignore @@ -40,7 +44,5 @@ def trash(self, path: str, simulate: bool): send2trash(path) def pipeline(self, args: dict, simulate: bool): - fs = args["fs"] - fs_path = args["fs_path"] - path = fs.getsyspath(fs_path) + path = args["path"]() self.trash(path=path, simulate=simulate) diff --git a/organize/core.py b/organize/core.py index b0ca7d3f..199d4ee8 100644 --- a/organize/core.py +++ b/organize/core.py @@ -13,7 +13,7 @@ from .filters import ALL as FILTERS from .filters.filter import Filter from .output import RichOutput, console -from .utils import deep_merge_inplace +from .utils import deep_merge_inplace, JinjaEnv logger = logging.getLogger(__name__) @@ -70,16 +70,16 @@ def instantiate_location(loc): walker = loc["walker"] if "fs" in loc: - base_fs = fs.open_fs(loc["fs"]) + base_fs = loc["fs"] path = loc.get("path", "/") else: - base_fs = fs.open_fs(loc["path"]) + base_fs = loc["path"] path = "/" return Location( walker=walker, - base_fs=base_fs, - path=path, + base_fs=fs.open_fs(JinjaEnv.from_string(base_fs).render(env=os.environ)), + path=JinjaEnv.from_string(path).render(env=os.environ), ) @@ -195,12 +195,12 @@ def run(config, simulate: bool = True): conf = load_from_file("testconf.yaml") try: - console.print(CONFIG_SCHEMA.json_schema(None)) + # console.print(CONFIG_SCHEMA.json_schema(None)) CONFIG_SCHEMA.validate(conf) replace_with_instances(conf) run(conf, simulate=True) except SchemaError as e: + console.print("Invalid config file") console.print(e.autos[-1]) - console.print(e.code) except Exception as e: console.print_exception() diff --git a/organize/filters/mimetype.py b/organize/filters/mimetype.py index 1f08707d..266ff7da 100644 --- a/organize/filters/mimetype.py +++ b/organize/filters/mimetype.py @@ -74,6 +74,7 @@ class MimeType(Filter): """ name = "mimetype" + schema_support_instance_without_args = True def __init__(self, *mimetypes): self.mimetypes = list(map(str.lower, flatten(list(mimetypes)))) diff --git a/organize/filters/name.py b/organize/filters/name.py index 024f7a1d..78c2195f 100644 --- a/organize/filters/name.py +++ b/organize/filters/name.py @@ -1,7 +1,7 @@ from typing import Any, List, Union, Optional, Dict import simplematch # type: ignore - +from fs.path import basename from pathlib import Path from .filter import Filter @@ -81,9 +81,16 @@ class Name(Filter): """ name = "name" + schema_support_instance_without_args = True def __init__( - self, match="*", *, startswith="", contains="", endswith="", case_sensitive=True + self, + match="{__fullname__}", + *, + startswith="", + contains="", + endswith="", + case_sensitive=True, ) -> None: self.matcher = simplematch.Matcher(match, case_sensitive=case_sensitive) self.startswith = self.create_list(startswith, case_sensitive) @@ -91,25 +98,27 @@ def __init__( self.endswith = self.create_list(endswith, case_sensitive) self.case_sensitive = case_sensitive - def matches(self, filename: str) -> bool: + def matches(self, name: str) -> bool: if not self.case_sensitive: - filename = filename.lower() + name = name.lower() is_match = ( - self.matcher.test(filename) - and any(x in filename for x in self.contains) - and any(filename.startswith(x) for x in self.startswith) - and any(filename.endswith(x) for x in self.endswith) + self.matcher.test(name) + and any(x in name for x in self.contains) + and any(name.startswith(x) for x in self.startswith) + and any(name.endswith(x) for x in self.endswith) ) return is_match def pipeline(self, args: Dict) -> Optional[Dict[str, Any]]: - fs = args["fs"] - fs_path = args["fs_path"] - filename = fs.getinfo(fs_path).stem - result = self.matches(filename) + name = basename(args["fs_path"]) + result = self.matches(name) + if result: - return {"filename": self.matcher.match(filename)} + m = self.matcher.match(name) + if "__fullname__" in m: + m = m["__fullname__"] + return {self.get_name(): m} return None @staticmethod diff --git a/testconf.yaml b/testconf.yaml index 265df13b..6476e8ac 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -2,22 +2,14 @@ rules: - name: "Find the last modification date of some png files" targets: dirs locations: - - path: ~/Desktop + - path: ~/Desktop/ max_depth: 2 filters: - created - - size - regex: ^.* + - name: "{test} {test2}" actions: - - echo: "{path}" - - echo: "{created}" - - echo: "{size}" - - shell: - cmd: "ls" - run_in_simulation: true - ignore_errors: no - - echo: "{shell}" - + - echo: "{name}" # - name: Find some folders # targets: dirs From 05f910c233e126b9a5f29b5803b1016896f8a8dd Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 14:36:07 +0100 Subject: [PATCH 025/108] ensure_list --- organize/core.py | 16 +++++++++------- organize/utils.py | 6 ++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/organize/core.py b/organize/core.py index 199d4ee8..f45d4bbc 100644 --- a/organize/core.py +++ b/organize/core.py @@ -13,7 +13,7 @@ from .filters import ALL as FILTERS from .filters.filter import Filter from .output import RichOutput, console -from .utils import deep_merge_inplace, JinjaEnv +from .utils import deep_merge_inplace, JinjaEnv, ensure_list logger = logging.getLogger(__name__) @@ -97,15 +97,17 @@ def instantiate_by_name(d, classes): def replace_with_instances(config): for rule in config["rules"]: - # wrap locations given as a single string in a list - if isinstance(rule["locations"], str): - rule["locations"] = [rule["locations"]] - rule["locations"] = [instantiate_location(loc) for loc in rule["locations"]] + rule["locations"] = [ + instantiate_location(loc) for loc in ensure_list(rule["locations"]) + ] # filters are optional rule["filters"] = [ - instantiate_by_name(x, FILTERS) for x in rule.get("filters", []) + instantiate_by_name(x, FILTERS) + for x in ensure_list(rule.get("filters", [])) + ] + rule["actions"] = [ + instantiate_by_name(x, ACTIONS) for x in ensure_list(rule["actions"]) ] - rule["actions"] = [instantiate_by_name(x, ACTIONS) for x in rule["actions"]] def filter_pipeline(filters: Iterable[Filter], args: dict) -> bool: diff --git a/organize/utils.py b/organize/utils.py index fd57d303..e5b9d8f2 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -85,3 +85,9 @@ def next_free_filename(fs: FS, template: Template, name: str, extension: str) -> ) prev_candidate = candidate counter += 1 + + +def ensure_list(inp): + if not isinstance(inp, list): + return [inp] + return inp From 9284c89f80265add506147c3c7a88a38bed9161c Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 14:36:11 +0100 Subject: [PATCH 026/108] update docs --- docs/page/filters.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/page/filters.rst b/docs/page/filters.rst index 3c847548..b18cdfca 100644 --- a/docs/page/filters.rst +++ b/docs/page/filters.rst @@ -24,13 +24,13 @@ FileContent ----------- .. autoclass:: FileContent -Filename +Name -------- -.. autoclass:: Filename +.. autoclass:: Name -FileSize +Size -------- -.. autoclass:: FileSize +.. autoclass:: Size LastModified ------------ From 3d9f6b24a46459f936d01ee706ccaf96cd874efe Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 14:46:58 +0100 Subject: [PATCH 027/108] formatting --- pyproject.toml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9df31d03..8cbed26a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,15 +2,21 @@ name = "organize-tool" version = "1.10.1" description = "The file management automation tool" -packages = [ - { include = "organize" }, -] +packages = [{ include = "organize" }] authors = ["Thomas Feldmann "] license = "MIT" readme = "README.md" repository = "https://github.com/tfeldmann/organize" documentation = "https://organize.readthedocs.io" -keywords = ["file", "management", "automation", "tool", "organization", "rules", "yaml"] +keywords = [ + "file", + "management", + "automation", + "tool", + "organization", + "rules", + "yaml", +] classifiers = [ # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers "Development Status :: 5 - Production/Stable", @@ -35,7 +41,7 @@ Send2Trash = "^1.8.0" ExifRead = "^2.3.2" textract = { version = "^1.6.4", optional = true } simplematch = "^1.3" -macos-tags = { version = "^1.5.1", markers = "sys_platform == 'darwin'"} +macos-tags = { version = "^1.5.1", markers = "sys_platform == 'darwin'" } schema = "^0.7.5" [tool.poetry.extras] From 6a0b53e103efc0506a5cfe52836bb103aef733ef Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 14:56:01 +0100 Subject: [PATCH 028/108] remove path global --- organize/utils.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/organize/utils.py b/organize/utils.py index e5b9d8f2..dd7dfc63 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -1,23 +1,19 @@ import os -import re from collections.abc import Mapping from copy import deepcopy -from functools import partial from pathlib import Path -from typing import Any, Hashable, List, Sequence, Tuple, Union +from typing import Any, Hashable, List, Sequence, Union from fs.base import FS from fs.errors import NoSysPath from jinja2 import Environment, Template - JinjaEnv = Environment( variable_start_string="{", variable_end_string="}", finalize=lambda x: x() if callable(x) else x, autoescape=False, ) -JinjaEnv.globals.update({"path": lambda: vars()}) def fullpath(path: Union[str, Path]) -> Path: From 44d53ab19ba020b7b23d510e0811193d7a6330b4 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 16:20:58 +0100 Subject: [PATCH 029/108] copy working with dirs --- organize/actions/copy.py | 92 ++++++++++------------------------- organize/actions/trash.py | 5 +- organize/actions/utils.py | 74 ++++++++++++++++++++++++++++ organize/cli.py | 3 +- organize/core.py | 2 +- organize/filters/duplicate.py | 1 - organize/filters/name.py | 6 +-- organize/output.py | 19 +++++--- organize/utils.py | 2 +- testconf.yaml | 12 +++-- 10 files changed, 128 insertions(+), 88 deletions(-) create mode 100644 organize/actions/utils.py diff --git a/organize/actions/copy.py b/organize/actions/copy.py index bb6e0dde..7667780e 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -2,29 +2,18 @@ from fs import open_fs from fs.base import FS -from fs.copy import copy_file -from fs.move import move_file -from fs.path import basename, dirname, join, splitext +from fs.copy import copy_dir, copy_file +from fs.path import basename, dirname, join from schema import Optional, Or -from ..utils import JinjaEnv, file_desc, next_free_filename +from organize.utils import JinjaEnv, file_desc + from .action import Action -from .trash import Trash +from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict logger = logging.getLogger(__name__) -CONFLICT_OPTIONS = ( - "skip", - "overwrite", - "trash", - "rename_new", - "rename_existing", - # "keep_newer", - # "keep_older", -) - - class Copy(Action): """ @@ -101,7 +90,7 @@ class Copy(Action): str, { "dest": str, - Optional("conflict_mode"): Or(*CONFLICT_OPTIONS), + Optional("on_conflict"): Or(*CONFLICT_OPTIONS), Optional("rename_template"): str, }, ) @@ -109,23 +98,23 @@ class Copy(Action): def __init__( self, dest: str, - conflict_mode="rename_new", + on_conflict="rename_new", rename_template="{name} {counter}{extension}", ) -> None: - if conflict_mode not in CONFLICT_OPTIONS: + if on_conflict not in CONFLICT_OPTIONS: raise ValueError( "conflict_mode must be one of %s" % ", ".join(CONFLICT_OPTIONS) ) self.dest = JinjaEnv.from_string(dest) - self.conflict_mode = conflict_mode + self.conflict_mode = on_conflict self.rename_template = JinjaEnv.from_string(rename_template) def pipeline(self, args: dict, simulate: bool): src_fs = args["fs"] # type: FS src_path = args["fs_path"] - dst_path = self.dst.render(**args) + dst_path = self.dest.render(**args) # if the destination ends with a slash we assume the name should not change if dst_path.endswith(("\\", "/")): dst_path = join(dst_path, basename(src_path)) @@ -133,55 +122,28 @@ def pipeline(self, args: dict, simulate: bool): dst_fs = open_fs(dirname(dst_path), writeable=True, create=True) dst_path = basename(dst_path) + if src_fs.isdir(src_path): + copy_action = copy_dir + elif src_fs.isfile(src_path): + copy_action = copy_file + + skip = False if dst_fs.exists(dst_path): self.print( - 'File %s already exists (conflict mode is "%s").' + '%s already exists (conflict mode is "%s").' % (file_desc(dst_fs, dst_path), self.conflict_mode) ) - - if self.conflict_mode == "trash": - Trash().pipeline({"fs": dst_fs, "fs_path": dst_path}, simulate=simulate) - if not simulate: - copy_file(src_fs, src_path, dst_fs, dst_path) - self.print("Copied to %s." % file_desc(dst_fs, dst_path)) - - elif self.conflict_mode == "skip": - self.print("Skipped.") - return - - elif self.conflict_mode == "overwrite": - if not simulate: - copy_file(src_fs, src_path, dst_fs, dst_path) - self.print("Copied to %s (overwritten)." % file_desc(dst_fs, dst_path)) - - elif self.conflict_mode == "rename_new": - stem, ext = splitext(dst_path) - name = next_free_filename( - fs=dst_fs, - name=stem, - extension=ext, - template=self.rename_template, - ) - if not simulate: - copy_file(src_fs, src_path, dst_fs, name) - self.print("Copied to %s" % file_desc(dst_fs, name)) - - elif self.conflict_mode == "rename_existing": - stem, ext = splitext(dst_path) - name = next_free_filename( - fs=dst_fs, - name=stem, - extension=ext, - template=self.rename_template, - ) - self.print("Renaming existing file to: %s" % name) - if not simulate: - move_file(dst_fs, dst_path, dst_fs, name) - copy_file(src_fs, src_path, dst_fs, dst_path) - self.print("Copied to %s" % file_desc(dst_fs, dst_path)) - else: + dst_fs, dst_path, skip = resolve_overwrite_conflict( + dst_fs=dst_fs, + dst_path=dst_path, + conflict_mode=self.conflict_mode, + rename_template=self.rename_template, + simulate=simulate, + print=self.print, + ) + if not skip: if not simulate: - copy_file(src_fs, src_path, dst_fs, dst_path) + copy_action(src_fs, src_path, dst_fs, dst_path) self.print("Copied to %s" % file_desc(dst_fs, dst_path)) # the next action should work with the newly created copy diff --git a/organize/actions/trash.py b/organize/actions/trash.py index 7f4e9f70..06cc95d1 100644 --- a/organize/actions/trash.py +++ b/organize/actions/trash.py @@ -44,5 +44,6 @@ def trash(self, path: str, simulate: bool): send2trash(path) def pipeline(self, args: dict, simulate: bool): - path = args["path"]() - self.trash(path=path, simulate=simulate) + fs = args["fs"] + fs_path = args["fs_path"] + self.trash(path=fs.getsyspath(fs_path), simulate=simulate) diff --git a/organize/actions/utils.py b/organize/actions/utils.py new file mode 100644 index 00000000..cdd5221d --- /dev/null +++ b/organize/actions/utils.py @@ -0,0 +1,74 @@ +from typing import Callable, Tuple, Union, NamedTuple + +from fs.base import FS +from fs.move import move_dir, move_file +from fs.path import splitext +from jinja2 import Template + +from organize.utils import file_desc, next_free_name + +from .trash import Trash + +CONFLICT_OPTIONS = ( + "skip", + "overwrite", + "trash", + "rename_new", + "rename_existing", + # "keep_newer", + # "keep_older", +) + + +class ResolverResult(NamedTuple): + dst_fs: FS + dst_path: str + skip: bool + + +def resolve_overwrite_conflict( + dst_fs: FS, + dst_path: str, + conflict_mode: str, + rename_template: Template, + simulate: bool, + print: Callable, +) -> ResolverResult: + if conflict_mode == "trash": + Trash().pipeline({"fs": dst_fs, "fs_path": dst_path}, simulate=simulate) + return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=False) + + elif conflict_mode == "skip": + print("Skipped.") + return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=True) + + elif conflict_mode == "overwrite": + print("Overwrite %s." % file_desc(dst_fs, dst_path)) + return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=False) + + elif conflict_mode == "rename_new": + stem, ext = splitext(dst_path) + name = next_free_name( + fs=dst_fs, + name=stem, + extension=ext, + template=rename_template, + ) + return ResolverResult(dst_fs=dst_fs, dst_path=name, skip=False) + + elif conflict_mode == "rename_existing": + stem, ext = splitext(dst_path) + name = next_free_name( + fs=dst_fs, + name=stem, + extension=ext, + template=rename_template, + ) + print('Renaming existing to: "%s"' % name) + if not simulate: + if dst_fs.isdir(dst_path): + move_dir(dst_fs, dst_path, dst_fs, name) + elif dst_fs.isfile(dst_path): + move_file(dst_fs, dst_path, dst_fs, name) + + return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=False) diff --git a/organize/cli.py b/organize/cli.py index f6e7d823..69d0940d 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -4,9 +4,8 @@ Usage: organize sim [] organize run [] - organize config [--open-folder | --path | --debug] [] + organize config [--open-folder | --path | --debug | --schema] organize list - organize schema organize --help organize --version diff --git a/organize/core.py b/organize/core.py index f45d4bbc..f2ff1240 100644 --- a/organize/core.py +++ b/organize/core.py @@ -200,7 +200,7 @@ def run(config, simulate: bool = True): # console.print(CONFIG_SCHEMA.json_schema(None)) CONFIG_SCHEMA.validate(conf) replace_with_instances(conf) - run(conf, simulate=True) + run(conf, simulate=False) except SchemaError as e: console.print("Invalid config file") console.print(e.autos[-1]) diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index 9f3e7359..c3788a3a 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -142,7 +142,6 @@ def matches(self, path: str) -> Union[bool, Dict[str, str]]: def pipeline(self, args): fs = args["fs"] fs_path = args["fs_path"] - fs.getsyspath(fs_path) return self.matches(fs.getsyspath(fs_path)) def __str__(self) -> str: diff --git a/organize/filters/name.py b/organize/filters/name.py index 78c2195f..05e9cd9c 100644 --- a/organize/filters/name.py +++ b/organize/filters/name.py @@ -85,7 +85,7 @@ class Name(Filter): def __init__( self, - match="{__fullname__}", + match="*", *, startswith="", contains="", @@ -116,8 +116,8 @@ def pipeline(self, args: Dict) -> Optional[Dict[str, Any]]: if result: m = self.matcher.match(name) - if "__fullname__" in m: - m = m["__fullname__"] + if m == {}: + m = name return {self.get_name(): m} return None diff --git a/organize/output.py b/organize/output.py index 2ed893a8..5da3b291 100644 --- a/organize/output.py +++ b/organize/output.py @@ -1,6 +1,6 @@ import logging from textwrap import indent - +from fs.osfs import OSFS from rich.rule import Rule from rich.console import Console from rich.panel import Panel @@ -30,8 +30,8 @@ def set_location(self, folder, path) -> None: def print_location_update(self): if self.curr_folder != self.prev_folder: if self.prev_folder is not None: - self.print_folder_spacer() - self.print_folder(self.curr_folder) + self.print_location_spacer() + self.print_location(self.curr_folder) self.prev_folder = self.curr_folder if self.curr_path != self.prev_path: @@ -56,10 +56,10 @@ def path_not_found(self, folderstr: str) -> None: self.print_not_found(folderstr) logger.warning("Path not found: %s", folderstr) - def print_folder_spacer(self): + def print_location_spacer(self): raise NotImplementedError - def print_folder(self, folder): + def print_location(self, folder): raise NotImplementedError def print_path(self, path): @@ -82,10 +82,13 @@ def print_simulation_banner(self): def print_rule(self, rule): console.print(Rule(rule, align="left", style="gray"), style="bold") - def print_folder(self, folder): - console.print(str(folder), style="bold") + def print_location(self, folder): + if isinstance(folder, OSFS): + console.print(folder.root_path) + else: + console.print(str(folder), style="bold") - def print_folder_spacer(self): + def print_location_spacer(self): console.print() def print_path(self, path): diff --git a/organize/utils.py b/organize/utils.py index dd7dfc63..9eaf93dc 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -67,7 +67,7 @@ def file_desc(fs, path): return "{} on {}".format(path, fs) -def next_free_filename(fs: FS, template: Template, name: str, extension: str) -> str: +def next_free_name(fs: FS, template: Template, name: str, extension: str) -> str: counter = 1 prev_candidate = "" while True: diff --git a/testconf.yaml b/testconf.yaml index 6476e8ac..2d825ae6 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -2,14 +2,16 @@ rules: - name: "Find the last modification date of some png files" targets: dirs locations: - - path: ~/Desktop/ - max_depth: 2 + - path: ~/Desktop/test + max_depth: 0 filters: - - created - - regex: ^.* - - name: "{test} {test2}" + - name: "more" actions: - echo: "{name}" + - copy: + dest: ~/Desktop/test/ahoi/ + on_conflict: "skip" + rename_template: "{name} -- {counter}" # - name: Find some folders # targets: dirs From 69899fe1635b00453bcf7301b7bf234611657bd0 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 16:42:23 +0100 Subject: [PATCH 030/108] copy to other filesystems --- organize/actions/copy.py | 13 ++++++++++--- organize/actions/delete.py | 18 +++++++++--------- organize/utils.py | 6 +++--- testconf.yaml | 3 ++- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 7667780e..18c94fa2 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -92,6 +92,7 @@ class Copy(Action): "dest": str, Optional("on_conflict"): Or(*CONFLICT_OPTIONS), Optional("rename_template"): str, + Optional("dest_filesystem"): str, }, ) @@ -100,15 +101,17 @@ def __init__( dest: str, on_conflict="rename_new", rename_template="{name} {counter}{extension}", + dest_filesystem=None, ) -> None: if on_conflict not in CONFLICT_OPTIONS: raise ValueError( - "conflict_mode must be one of %s" % ", ".join(CONFLICT_OPTIONS) + "on_conflict must be one of %s" % ", ".join(CONFLICT_OPTIONS) ) self.dest = JinjaEnv.from_string(dest) self.conflict_mode = on_conflict self.rename_template = JinjaEnv.from_string(rename_template) + self.dest_filesystem = dest_filesystem def pipeline(self, args: dict, simulate: bool): src_fs = args["fs"] # type: FS @@ -119,8 +122,12 @@ def pipeline(self, args: dict, simulate: bool): if dst_path.endswith(("\\", "/")): dst_path = join(dst_path, basename(src_path)) - dst_fs = open_fs(dirname(dst_path), writeable=True, create=True) - dst_path = basename(dst_path) + if self.dest_filesystem: + dst_fs = open_fs(self.dest_filesystem, writeable=True, create=True) + dst_path = dst_path + else: + dst_fs = open_fs(dirname(dst_path), writeable=True, create=True) + dst_path = basename(dst_path) if src_fs.isdir(src_path): copy_action = copy_dir diff --git a/organize/actions/delete.py b/organize/actions/delete.py index aaa5b295..0546617a 100644 --- a/organize/actions/delete.py +++ b/organize/actions/delete.py @@ -1,8 +1,5 @@ import logging -from typing import Mapping - -from schema import Optional, Or - +from fs.base import FS from .action import Action logger = logging.getLogger(__name__) @@ -40,11 +37,14 @@ class Delete(Action): def get_schema(cls): return cls.name - def pipeline(self, args: Mapping, simulate: bool): - fs = args["fs"] - fs_path = args["fs_path"] + def pipeline(self, args: dict, simulate: bool): + fs = args["fs"] # type: FS + fs_path = args["fs_path"] # type: str relative_path = args["relative_path"] self.print('Deleting "%s"' % relative_path) if not simulate: - logger.info("Deleting file %s.", relative_path) - fs.remove(fs_path) + logger.info("Deleting %s.", relative_path) + if fs.isdir(fs_path): + fs.removetree(fs_path) + elif fs.isfile(fs_path): + fs.remove(fs_path) diff --git a/organize/utils.py b/organize/utils.py index 9eaf93dc..08ef5246 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -5,6 +5,7 @@ from typing import Any, Hashable, List, Sequence, Union from fs.base import FS +from fs.osfs import OSFS from fs.errors import NoSysPath from jinja2 import Environment, Template @@ -61,10 +62,9 @@ def deep_merge_inplace(base: dict, updates: dict) -> None: def file_desc(fs, path): - try: + if isinstance(fs, OSFS): return fs.getsyspath(path) - except NoSysPath: - return "{} on {}".format(path, fs) + return "{} on {}".format(path, fs) def next_free_name(fs: FS, template: Template, name: str, extension: str) -> str: diff --git a/testconf.yaml b/testconf.yaml index 2d825ae6..b3aefd50 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -9,7 +9,8 @@ rules: actions: - echo: "{name}" - copy: - dest: ~/Desktop/test/ahoi/ + dest: /ahoi/test/ + dest_filesystem: "zip:///Users/thomasfeldmann/Desktop/test.zip" on_conflict: "skip" rename_template: "{name} -- {counter}" From 3b062e60fc9ee55e6ece7db292712e542e682fe4 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 17:52:13 +0100 Subject: [PATCH 031/108] working move and copy --- organize/actions/copy.py | 6 +- organize/actions/move.py | 127 ++++++++++++++++++++++------------ organize/actions/rename.py | 114 +++++++++++++++++++----------- organize/core.py | 2 +- organize/filters/extension.py | 3 + organize/filters/name.py | 10 ++- testconf.yaml | 14 ++-- 7 files changed, 177 insertions(+), 99 deletions(-) diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 18c94fa2..7df73535 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -123,7 +123,11 @@ def pipeline(self, args: dict, simulate: bool): dst_path = join(dst_path, basename(src_path)) if self.dest_filesystem: - dst_fs = open_fs(self.dest_filesystem, writeable=True, create=True) + dst_fs_ = self.dest_filesystem + # render if we have a template + if isinstance(dst_fs_, str): + dst_fs_ = JinjaEnv.from_string(dst_fs_).render(**args) + dst_fs = open_fs(dst_fs_, writeable=True, create=True) dst_path = dst_path else: dst_fs = open_fs(dirname(dst_path), writeable=True, create=True) diff --git a/organize/actions/move.py b/organize/actions/move.py index 5ffda668..89afd4c6 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -1,13 +1,15 @@ import logging -import os -import shutil -from typing import Mapping -from pathlib import Path -from organize.utils import fullpath +from fs import open_fs +from fs.base import FS +from fs.move import move_dir, move_file +from fs.path import basename, dirname, join +from schema import Optional, Or + +from organize.utils import JinjaEnv, file_desc from .action import Action -from .trash import Trash +from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict logger = logging.getLogger(__name__) @@ -87,46 +89,83 @@ class Move(Action): counter_separator: '_' """ - def __init__(self, dest: str, overwrite=False, counter_separator=" "): - self.dest = dest - self.overwrite = overwrite - self.counter_separator = counter_separator - - def pipeline(self, args: dict, simulate: bool) -> Mapping[str, Path]: - fs = args["fs"] - fs_path = args["fs_path"] - - expanded_dest = self.fill_template_tags(self.dest, args) - # if only a folder path is given we append the filename to have the full - # path. We use os.path for that because pathlib removes trailing slashes - if expanded_dest.endswith(("\\", "/")): - expanded_dest = os.path.join(expanded_dest, path.name) - - new_path = fullpath(expanded_dest) - new_path_exists = new_path.exists() - new_path_samefile = new_path_exists and new_path.samefile(path) - if new_path_exists and not new_path_samefile: - if self.overwrite: - self.print("File already exists") - Trash().run(path=new_path, simulate=simulate) - else: - new_path = find_unused_filename( - path=new_path, separator=self.counter_separator - ) - - if new_path_samefile and new_path == path: - self.print("Keep location") + name = "move" + arg_schema = Or( + str, + { + "dest": str, + Optional("on_conflict"): Or(*CONFLICT_OPTIONS), + Optional("rename_template"): str, + Optional("dest_filesystem"): str, + }, + ) + + def __init__( + self, + dest: str, + on_conflict="rename_new", + rename_template="{name} {counter}{extension}", + dest_filesystem=None, + ) -> None: + if on_conflict not in CONFLICT_OPTIONS: + raise ValueError( + "on_conflict must be one of %s" % ", ".join(CONFLICT_OPTIONS) + ) + + self.dest = JinjaEnv.from_string(dest) + self.conflict_mode = on_conflict + self.rename_template = JinjaEnv.from_string(rename_template) + self.dest_filesystem = dest_filesystem + + def pipeline(self, args: dict, simulate: bool): + src_fs = args["fs"] # type: FS + src_path = args["fs_path"] + + dst_path = self.dest.render(**args) + # if the destination ends with a slash we assume the name should not change + if dst_path.endswith(("\\", "/")): + dst_path = join(dst_path, basename(src_path)) + + if self.dest_filesystem: + dst_fs_ = self.dest_filesystem + # render if we have a template + if isinstance(dst_fs_, str): + dst_fs_ = JinjaEnv.from_string(dst_fs_).render(**args) + dst_fs = open_fs(dst_fs_, writeable=True, create=True) + dst_path = dst_path else: - self.print('Move to "%s"' % new_path) + dst_fs = open_fs(dirname(dst_path), writeable=True, create=True) + dst_path = basename(dst_path) + + if src_fs.isdir(src_path): + move_action = move_dir + elif src_fs.isfile(src_path): + move_action = move_file + + skip = False + if dst_fs.exists(dst_path): + self.print( + '%s already exists (conflict mode is "%s").' + % (file_desc(dst_fs, dst_path), self.conflict_mode) + ) + dst_fs, dst_path, skip = resolve_overwrite_conflict( + dst_fs=dst_fs, + dst_path=dst_path, + conflict_mode=self.conflict_mode, + rename_template=self.rename_template, + simulate=simulate, + print=self.print, + ) + if not skip: if not simulate: - logger.info("Creating folder if not exists: %s", new_path.parent) - new_path.parent.mkdir(parents=True, exist_ok=True) - logger.info('Moving "%s" to "%s"', path, new_path) - shutil.move(src=str(path), dst=str(new_path)) + move_action(src_fs, src_path, dst_fs, dst_path) + self.print("Moved to %s" % file_desc(dst_fs, dst_path)) - return {"path": new_path} + # the next action should work with the newly created copy + return { + "fs": dst_fs, + "fs_path": dst_path, + } def __str__(self) -> str: - return "Move(dest=%s, overwrite=%s)" % (self.dest, self.overwrite) - - name = "move" + return "Move(dest=%s, conflict_mode=%s)" % (self.dest, self.conflict_mode) diff --git a/organize/actions/rename.py b/organize/actions/rename.py index 698d4808..43dc7a7e 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -1,11 +1,15 @@ import logging import os -from typing import Mapping -from pathlib import Path +from fs import path +from fs.base import FS +from fs.move import move_dir, move_file +from schema import Optional, Or + +from organize.utils import JinjaEnv, file_desc from .action import Action -from .trash import Trash +from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict logger = logging.getLogger(__name__) @@ -55,49 +59,77 @@ class Rename(Action): """ name = "rename" + arg_schema = Or( + str, + { + "name": str, + Optional("on_conflict"): Or(*CONFLICT_OPTIONS), + Optional("rename_template"): str, + }, + ) + + def __init__( + self, + new_name: str, + on_conflict="rename_new", + rename_template="{name} {counter}{extension}", + ) -> None: + if on_conflict not in CONFLICT_OPTIONS: + raise ValueError( + "on_conflict must be one of %s" % ", ".join(CONFLICT_OPTIONS) + ) + + self.new_name = JinjaEnv.from_string(new_name) + self.conflict_mode = on_conflict + self.rename_template = JinjaEnv.from_string(rename_template) + + def pipeline(self, args: dict, simulate: bool): + fs = args["fs"] # type: FS + src_path = args["fs_path"] - def __init__(self, name: str, overwrite=False, counter_separator=" ") -> None: - if os.path.sep in name: + new_name = self.new_name.render(**args) + if os.path.sep in new_name: ValueError( - "Rename only takes a filename as argument. To move files between " - "folders use the Move action." + "Rename only takes a name as argument. " + "To move files or folders use the move action." ) - self.name = name - self.overwrite = overwrite - self.counter_separator = counter_separator - - def pipeline(self, args: Mapping, simulate: bool) -> Mapping[str, Path]: - path = args["path"] # type: Path - expanded_name = self.fill_template_tags(self.name, args) - new_path = path.parent / expanded_name - - # handle filename collisions - new_path_exists = new_path.exists() - new_path_samefile = new_path_exists and new_path.samefile(path) - if new_path_exists and not new_path_samefile: - if self.overwrite: - self.print("File already exists") - Trash().run(path=new_path, simulate=simulate) - else: - new_path = find_unused_filename( - path=new_path, separator=self.counter_separator - ) - # do nothing if the new name is equal to the old name and the file is - # the same - if new_path_samefile and new_path == path: - self.print("Keep name") + parents, full_name = path.split(src_path) + name, ext = path.splitext(full_name) + dst_path = path.join(parents, new_name) + + if dst_path == src_path: + self.print("Name did not change") else: - self.print('New name: "%s"' % new_path.name) - if not simulate: - logger.info('Renaming "%s" to "%s".', path, new_path) - path.rename(new_path) + if fs.isdir(src_path): + move_action = move_dir + elif fs.isfile(src_path): + move_action = move_file + + skip = False + if fs.exists(dst_path): + self.print( + '%s already exists (conflict mode is "%s").' + % (file_desc(fs, dst_path), self.conflict_mode) + ) + fs, dst_path, skip = resolve_overwrite_conflict( + dst_fs=fs, + dst_path=dst_path, + conflict_mode=self.conflict_mode, + rename_template=self.rename_template, + simulate=simulate, + print=self.print, + ) + if not skip: + if not simulate: + move_action(fs, src_path, fs, dst_path) + self.print("Renamed to %s" % file_desc(fs, dst_path)) - return {"path": new_path} + # the next action should work with the newly created copy + return { + "fs": fs, + "fs_path": dst_path, + } def __str__(self) -> str: - return "Rename(name=%s, overwrite=%s, sep=%s)" % ( - self.name, - self.overwrite, - self.counter_separator, - ) + return "Move(dest=%s, conflict_mode=%s)" % (self.dest, self.conflict_mode) diff --git a/organize/core.py b/organize/core.py index f2ff1240..f45d4bbc 100644 --- a/organize/core.py +++ b/organize/core.py @@ -200,7 +200,7 @@ def run(config, simulate: bool = True): # console.print(CONFIG_SCHEMA.json_schema(None)) CONFIG_SCHEMA.validate(conf) replace_with_instances(conf) - run(conf, simulate=False) + run(conf, simulate=True) except SchemaError as e: console.print("Invalid config file") console.print(e.autos[-1]) diff --git a/organize/filters/extension.py b/organize/filters/extension.py index 7722e60c..bd484d0b 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -100,6 +100,9 @@ class Extension(Filter): - echo: 'Found media file: {path}' """ + name = "extension" + schema_support_instance_without_args = True + def __init__(self, *extensions) -> None: self.extensions = list(map(self.normalize_extension, flatten(list(extensions)))) diff --git a/organize/filters/name.py b/organize/filters/name.py index 05e9cd9c..3965a7a2 100644 --- a/organize/filters/name.py +++ b/organize/filters/name.py @@ -1,8 +1,7 @@ from typing import Any, List, Union, Optional, Dict import simplematch # type: ignore -from fs.path import basename -from pathlib import Path +from fs import path from .filter import Filter @@ -111,7 +110,12 @@ def matches(self, name: str) -> bool: return is_match def pipeline(self, args: Dict) -> Optional[Dict[str, Any]]: - name = basename(args["fs_path"]) + fs = args["fs"] + fs_path = args["fs_path"] + if fs.isdir(fs_path): + name = path.basename(fs_path) + else: + name, _ = path.splitext(path.basename(fs_path)) result = self.matches(name) if result: diff --git a/testconf.yaml b/testconf.yaml index b3aefd50..d26e8fbf 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -1,18 +1,14 @@ rules: - name: "Find the last modification date of some png files" - targets: dirs + targets: files locations: - path: ~/Desktop/test - max_depth: 0 + max_depth: 3 filters: - - name: "more" + - name: "Bildschirmfoto {date} um {time}" + - extension: png actions: - - echo: "{name}" - - copy: - dest: /ahoi/test/ - dest_filesystem: "zip:///Users/thomasfeldmann/Desktop/test.zip" - on_conflict: "skip" - rename_template: "{name} -- {counter}" + - echo: "{name.date}" # - name: Find some folders # targets: dirs From 8df9a743e00737858b22532e68d5823861bc0fc0 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 24 Jan 2022 18:15:54 +0100 Subject: [PATCH 032/108] update docs --- docs/conf.py | 7 +++- docs/index.rst | 1 + docs/page/migration.md | 1 + organize/core.py | 2 +- poetry.lock | 86 +++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + testconf.yaml | 4 +- 7 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 docs/page/migration.md diff --git a/docs/conf.py b/docs/conf.py index 8aee0584..ba0b56f9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -31,7 +31,12 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.autodoc", "sphinx.ext.todo", "sphinx.ext.autosectionlabel"] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.todo", + "sphinx.ext.autosectionlabel", + "myst_parser", +] # If true, the current module name will be prepended to all description # unit titles (such as .. function::). diff --git a/docs/index.rst b/docs/index.rst index 9e53441b..62d3710a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,6 +14,7 @@ Contents: page/config page/filters page/actions + page/migration If you find any bugs or have an idea for a new feature please don't hesitate to `open an issue `_ on GitHub. diff --git a/docs/page/migration.md b/docs/page/migration.md new file mode 100644 index 00000000..2d1bc598 --- /dev/null +++ b/docs/page/migration.md @@ -0,0 +1 @@ +# New in `organize` v2 diff --git a/organize/core.py b/organize/core.py index f45d4bbc..f2ff1240 100644 --- a/organize/core.py +++ b/organize/core.py @@ -200,7 +200,7 @@ def run(config, simulate: bool = True): # console.print(CONFIG_SCHEMA.json_schema(None)) CONFIG_SCHEMA.validate(conf) replace_with_instances(conf) - run(conf, simulate=True) + run(conf, simulate=False) except SchemaError as e: console.print("Invalid config file") console.print(e.autos[-1]) diff --git a/poetry.lock b/poetry.lock index 3b47a0ce..c95554cb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -493,6 +493,28 @@ python-versions = ">=3.6,<4.0" mdfind-wrapper = ">=0.1.3,<0.2.0" xattr = ">=0.9.7,<0.10.0" +[[package]] +name = "markdown-it-py" +version = "2.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +category = "dev" +optional = false +python-versions = "~=3.6" + +[package.dependencies] +attrs = ">=19,<22" +mdurl = ">=0.1,<1.0" +typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark (>=3.2,<4.0)"] +code_style = ["pre-commit (==2.6)"] +compare = ["commonmark (>=0.9.1,<0.10.0)", "markdown (>=3.2.2,<3.3.0)", "mistletoe-ebp (>=0.10.0,<0.11.0)", "mistune (>=0.8.4,<0.9.0)", "panflute (>=1.12,<2.0)"] +linkify = ["linkify-it-py (>=1.0,<2.0)"] +plugins = ["mdit-py-plugins"] +rtd = ["myst-nb (==0.13.0a1)", "pyyaml", "sphinx (>=2,<4)", "sphinx-copybutton", "sphinx-panels (>=0.4.0,<0.5.0)", "sphinx-book-theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + [[package]] name = "markupsafe" version = "2.0.1" @@ -517,6 +539,30 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "mdit-py-plugins" +version = "0.3.0" +description = "Collection of plugins for markdown-it-py" +category = "dev" +optional = false +python-versions = "~=3.6" + +[package.dependencies] +markdown-it-py = ">=1.0.0,<3.0.0" + +[package.extras] +code_style = ["pre-commit (==2.6)"] +rtd = ["myst-parser (>=0.14.0,<0.15.0)", "sphinx-book-theme (>=0.1.0,<0.2.0)"] +testing = ["coverage", "pytest (>=3.6,<4)", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.0" +description = "Markdown URL utilities" +category = "dev" +optional = false +python-versions = ">=3.6" + [[package]] name = "more-itertools" version = "8.12.0" @@ -549,6 +595,28 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "myst-parser" +version = "0.16.1" +description = "An extended commonmark compliant parser, with bridges to docutils & sphinx." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +docutils = ">=0.15,<0.18" +jinja2 = "*" +markdown-it-py = ">=1.0.0,<3.0.0" +mdit-py-plugins = ">=0.3.0,<0.4.0" +pyyaml = "*" +sphinx = ">=3.1,<5" + +[package.extras] +code_style = ["pre-commit (>=2.12,<3.0)"] +linkify = ["linkify-it-py (>=1.0,<2.0)"] +rtd = ["ipython", "sphinx-book-theme (>=0.1.0,<0.2.0)", "sphinx-panels (>=0.5.2,<0.6.0)", "sphinxcontrib-bibtex (>=2.1,<3.0)", "sphinxext-rediraffe (>=0.2,<1.0)", "sphinxcontrib.mermaid (>=0.6.3,<0.7.0)", "sphinxext-opengraph (>=0.4.2,<0.5.0)"] +testing = ["beautifulsoup4", "coverage", "docutils (>=0.17.0,<0.18.0)", "pytest (>=3.6,<4)", "pytest-cov", "pytest-regressions"] + [[package]] name = "olefile" version = "0.46" @@ -1197,7 +1265,7 @@ textract = ["textract"] [metadata] lock-version = "1.1" python-versions = "^3.6.2" -content-hash = "4dc7a715c4ed84fd17a8f0fea30f2d8eefb682ec7015ff028b0cf2757827bcf0" +content-hash = "623aaaf69ce3f4df54a009e645279380156cff816725c54898c15dae3618c1a3" [metadata.files] alabaster = [ @@ -1526,6 +1594,10 @@ macos-tags = [ {file = "macos-tags-1.5.1.tar.gz", hash = "sha256:f144c5bc05d01573966d8aca2483cb345b20b76a5b32e9967786e086a38712e7"}, {file = "macos_tags-1.5.1-py3-none-any.whl", hash = "sha256:56419233af32242b703dd35bcf38c9f198abd969faddbe986eb8aaa6d95349cf"}, ] +markdown-it-py = [ + {file = "markdown-it-py-2.0.0.tar.gz", hash = "sha256:c138a596f6c9988e0b5fa3299bc38ffa76c75076bc178e8dfac40a84343c7022"}, + {file = "markdown_it_py-2.0.0-py3-none-any.whl", hash = "sha256:15cc69c5b7c493ba8603722b710e39ce3fab2961994179fb4fa1c99b070d2059"}, +] markupsafe = [ {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, @@ -1605,6 +1677,14 @@ mdfind-wrapper = [ {file = "mdfind-wrapper-0.1.5.tar.gz", hash = "sha256:c0dbd5bc99c6d1fb4678bfa1841a3380ccac61e9b43a26a8d658aa9cafe27441"}, {file = "mdfind_wrapper-0.1.5-py3-none-any.whl", hash = "sha256:fd00e65684b47f2d286eb7394eb172f4766f2926d95eddff6eb948352f620cbc"}, ] +mdit-py-plugins = [ + {file = "mdit-py-plugins-0.3.0.tar.gz", hash = "sha256:ecc24f51eeec6ab7eecc2f9724e8272c2fb191c2e93cf98109120c2cace69750"}, + {file = "mdit_py_plugins-0.3.0-py3-none-any.whl", hash = "sha256:b1279701cee2dbf50e188d3da5f51fee8d78d038cdf99be57c6b9d1aa93b4073"}, +] +mdurl = [ + {file = "mdurl-0.1.0-py3-none-any.whl", hash = "sha256:40654d6dcb8d21501ed13c21cc0bd6fc42ff07ceb8be30029e5ae63ebc2ecfda"}, + {file = "mdurl-0.1.0.tar.gz", hash = "sha256:94873a969008ee48880fb21bad7de0349fef529f3be178969af5817239e9b990"}, +] more-itertools = [ {file = "more-itertools-8.12.0.tar.gz", hash = "sha256:7dc6ad46f05f545f900dd59e8dfb4e84a4827b97b3cfecb175ea0c7d247f6064"}, {file = "more_itertools-8.12.0-py3-none-any.whl", hash = "sha256:43e6dd9942dffd72661a2c4ef383ad7da1e6a3e968a927ad7a6083ab410a688b"}, @@ -1637,6 +1717,10 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] +myst-parser = [ + {file = "myst-parser-0.16.1.tar.gz", hash = "sha256:a6473b9735c8c74959b49b36550725464f4aecc4481340c9a5f9153829191f83"}, + {file = "myst_parser-0.16.1-py3-none-any.whl", hash = "sha256:617a90ceda2162ebf81cd13ad17d879bd4f49e7fb5c4f177bb905272555a2268"}, +] olefile = [ {file = "olefile-0.46.zip", hash = "sha256:133b031eaf8fd2c9399b78b8bc5b8fcbe4c31e85295749bb17a87cba8f3c3964"}, ] diff --git a/pyproject.toml b/pyproject.toml index 8cbed26a..424548bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ sphinx = "^3.1.0" sphinx-rtd-theme = "^0.5.2" mypy = "^0.812" flake8 = "^3.9.1" +myst-parser = "^0.16.1" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/testconf.yaml b/testconf.yaml index d26e8fbf..9d13eb26 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -5,10 +5,10 @@ rules: - path: ~/Desktop/test max_depth: 3 filters: - - name: "Bildschirmfoto {date} um {time}" + - name: "Bildschirmfoto {date} um {time}_*" - extension: png actions: - - echo: "{name.date}" + - move: "~/Desktop/Fotos/{name}.png" # - name: Find some folders # targets: dirs From 41b0c8934757e1924c315b8248aba9e04aa498e2 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 25 Jan 2022 10:09:00 +0100 Subject: [PATCH 033/108] update docs --- docs/Makefile | 12 +- docs/_static/organize.svg | 272 ------------------- docs/conf.py | 182 ------------- docs/images/organize.pdf | Bin 308245 -> 0 bytes docs/make.bat | 15 +- docs/source/conf.py | 66 +++++ docs/{_static => source/images}/organize.pdf | Bin docs/{ => source}/images/organize.svg | 0 docs/{ => source}/index.rst | 0 docs/{ => source}/page/actions.rst | 0 docs/{ => source}/page/config.rst | 0 docs/{ => source}/page/filters.rst | 0 docs/{ => source}/page/migration.md | 0 docs/{ => source}/page/quickstart.rst | 0 makedocs.sh | 2 + 15 files changed, 81 insertions(+), 468 deletions(-) delete mode 100644 docs/_static/organize.svg delete mode 100644 docs/conf.py delete mode 100644 docs/images/organize.pdf create mode 100644 docs/source/conf.py rename docs/{_static => source/images}/organize.pdf (100%) rename docs/{ => source}/images/organize.svg (100%) rename docs/{ => source}/index.rst (100%) rename docs/{ => source}/page/actions.rst (100%) rename docs/{ => source}/page/config.rst (100%) rename docs/{ => source}/page/filters.rst (100%) rename docs/{ => source}/page/migration.md (100%) rename docs/{ => source}/page/quickstart.rst (100%) create mode 100755 makedocs.sh diff --git a/docs/Makefile b/docs/Makefile index e77582a6..d0c3cbf1 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,12 +1,12 @@ # Minimal makefile for Sphinx documentation # -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = python3 -msphinx -SPHINXPROJ = organize -SOURCEDIR = . -BUILDDIR = _build +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: diff --git a/docs/_static/organize.svg b/docs/_static/organize.svg deleted file mode 100644 index 4423317f..00000000 --- a/docs/_static/organize.svg +++ /dev/null @@ -1,272 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index ba0b56f9..00000000 --- a/docs/conf.py +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -# -# organize documentation build configuration file, created by -# sphinx-quickstart on Fri Sep 29 15:43:41 2017. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. - -import os -import sys - -sys.path.insert(0, os.path.abspath("../organize")) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.todo", - "sphinx.ext.autosectionlabel", - "myst_parser", -] - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -add_module_names = False - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = ".rst" - -# The master toctree document. -master_doc = "index" - -# General information about the project. -project = "organize" -copyright = "Thomas Feldmann" -author = "Thomas Feldmann" - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -src_dir = os.path.realpath(os.path.dirname(os.path.dirname(__file__))) -sys.path.insert(0, src_dir) -from organize.__version__ import __version__ - -version = __version__ -# The full version, including alpha/beta/rc tags. -release = __version__ - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -# html_theme = 'alabaster' -html_theme = "sphinx_rtd_theme" - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -# html_theme_options = {} - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] - -# Custom sidebar templates, must be a dictionary that maps document names -# to template names. -# -# This is required for the alabaster theme -# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars -html_sidebars = { - "**": [ - "about.html", - "navigation.html", - "relations.html", # needs 'show_related': True theme option to display - "searchbox.html", - "donate.html", - ] -} - - -# -- Options for HTMLHelp output ------------------------------------------ - -# Output file base name for HTML help builder. -htmlhelp_basename = "organizedoc" - - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, "organize.tex", "organize Documentation", "Thomas Feldmann", "manual") -] - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [(master_doc, "organize", "organize Documentation", [author], 1)] - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - "organize", - "organize Documentation", - author, - "organize", - "One line description of project.", - "Miscellaneous", - ) -] diff --git a/docs/images/organize.pdf b/docs/images/organize.pdf deleted file mode 100644 index 7538b99f256dd65d2fce854cc1d84ec3ba1ad306..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 308245 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 z

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 b&BDQ|kBJE|(ZzZzcn?nA=U@Dq4pTPTPl8>8b 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%0AGLkNO( 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

j65aw9*SHSF(t`YM$%em%!p&8D{HSS3tMF zxx~1^yRp3Wu#p`^&VGZKN6=g45Wwsf_46WNg8u$!z-s6>tW#?E=Y^zuN1wJ!z?xIJ zMpK3pOV@}NjHBe@@0740>(@t)Lk?_tPQFV5qgZ1@N}hc&2}AS+JDUcxtHuGS+L2P` z^>+~yy^&M%3m`$WOQiuI5LH6%!nk8sd#2hn&RSOJkPKL5X_sRW%Ic^}9WT(_z&d_Uy_Fa^zmw#xY)2HXth)LvE z2yfT|i?J_f!2X4zk55=ukNeN435o5zz|Cr?Gj2M&PpM-slo%nvaE_xLV@4r?nAp9T_1@DzRD>B z#A`_Q?$>YhS;Ja9t})b9Y~UQULz9IwWo9J5@&FrD1awesyf-dUW`%X~s&B0?fNbJwkbXnVsysc}bhK8;0t^L}YGl%~CoJ3CxnZ65oE!kAqT z<$&XPn^keNCREhdP}t2Z@gk5#HyP-p6semp#Gi?AqZLK)y&gvbd@#u!FLs`zKm{@+ zN=gzQ&C~-T*!_q}EUNF^TFb}n6OCTn1~capIX(4S)yi=xV<}Gpz9^UPw1f}xDJZK# zrSkjCM6xfqAH3wx6BtQQft}6>{*)A|Vy>z`-E|00>QhL?`Yd?#z#AE~`uH_ggVemU zBLa!r;WTn&RrWfrmbUCch01}_UdgVs)=w=?O>fFZYMgAhoG~!03w9sv)m|b;f}c(O z3vxOY!)kYW*cRG9gT?Yzrlb6_+0sWc@d71xF|>=uN{V;~5)m%b$QWsVcT`yIRZiRj zNn7JsEM=s{l|f6}H2F~`>&&L&eN4e5OF5W0q!wOzo<&#%D%<2W68@{$j%KR_7farY znDwA*J?&{I7id^)WkBBn8LvtEVHMDuL*9uL@~RqLOB@nK(8=Q}QrGO$Ac0NCY)O=H zRk&J&3PLeQ8sF(`LkeKGft>$rPh2^d)%@w#plS7}?&voUkxq!IJ$4qcsCawc*AEml zkeF>pMgI2dQ!G&_c*B&auh=!}p~)>D=fo0B^_3nZ2n7%+xhNmieV3bGG>lA{Ie3s5 ztj1~M&#@I{CBdyHTvTIuYl%Vya9M*Ru3i)%nI!76U8==(mdpvPkVR3&&RHxnaDFR* z>75c0M+$6T{|DS84cj=i1znqnLn+8y_ z0+>f$YLKyI$_2JMA9YQcMQ$tRPZTSMtniE|x>Z;2o3nC`|73JyPlM_*-|c2BQS?9i z2Pu3(k0QLb@#hAPCLVGq2MzJ5lz*!L7gsizfGz>^i1e;f$sI%j{TV=`KGEM`^#m3- zAE(I&39I;1TMMQt-CJvYR?s06C+JiX2CJo$Mu`Ia)FzY|7lGoC&RTS7X5JS)3?XSp zbfdUEBtccLtNPCK9ECz)WJTtpIj6Z7vT5oxa~ug94r?3QjYul3KE`zh_s>@xFVh!e zIO4Zvl(Ve>wu%Y}@pwvZDS+KR+a2o-{7fne>u9t8TyYQY+?t^=Y9In^ zZ@rHg5K$ws@672IT&T4Y61d43e0mrRzyveU`0MD2c(<98iaKy{BBLNpUjPd8$YB;Z z!kMrb=EX8=in}7R0HM#e0xLmvjNYb8Z12I;pKLewhCkW*{K(J3(#@;r(<>>@XA+55Upu>c9T zfQ-h8NS17Y=(OCZGbs|u5N#37RdjZVBE<$vf}n^huaacDxgggL^eG#C6k1t3Qf(@_ z|E7AY2(Pt0-W(213pa`)FMxA%1fQ-(Y|Rd%zf|)r6_DC+d_C&<$CB`*V+^D?p#pjT z=+_9j(7mKL)nDEuhrUX*Qge|3m3}g6d0N16MBJbONJheu7TMc78v^_}jIzNT-UVoX zdDR^6eXNq0Fj<1B28|EaxcE)^`O5E*@M~TRY^NmwSq7qbljzE0F=A9Ex}S-z>@(y0 zEw6~doccjzkV!A8McDe6$P79y-fV(5WN2ib%KO^0Ci?^TO_|MY0n+UVL_sT-k(MU}MU1Afffia0KGb_^(y$CGQjNgxB6C^IuK{^8 ze16{_OB*XQc6r-fp5OOZD*_C@A*ptZDzAr6Yk7G)JwDGZ8&UstB0XLp^84ODd`KDb zw|yoi^z4Ow_q?R)?R@27NVC6|Xy5hR_#zxWZ9RKqwP5CLcE3-2Z`1DZz3-o$5&k^z z`z%$TnX%jM@p^sa*YmwUE@iLH-SPfTgsxxmBYX~1x7+FZxH~J&`?;CBy=(dT@YmLR zDI&j*bE`|vZqW5-U+MdJ&v)5QC({Vw2cAPr&wIy*d(JOQ+|~78Y>ZCbpRJ0G-=NgN zLV04D(KYdM?Ks2^B=w105yR34bZCkb*O1=%W33{%uHmVII0}G^(SIM+O16o8lsV>$ zVvJE@4U{ay@eD%a>-QSf%9#E3VoMiQwXyTUxd2qm?o9y$6X`uJmBfU~e z9NH6WV@M9?S+y(~H%+9$nk)tgJUiM`=902#15td0qCmB*7Zm|-ytPVMPI5~!@J2ux zH@gh=<4gi7JrOZ!A|n`og!Vc~72F37AgZ@UnK&AdR?ToSNT;lR6=Si`xkk?#L_2FK zRf&d2TgO1nNif!Bu6sPaK`jBZv)3e)=~{dgx$N7`^w)_!js~5Wdnd`aH7fhOO^KCe@#GwPVR%u{zi%r z13VaGHVHa`+KG)n?h5_DZSM-i5*aa#YcctxM)GJfYvuhbs-lu@X~GdZDa2ji$ZUVQ z?@}hdzoF z6t$Jq?l(9#>q^NpvN+291hzwhW)#sJ=K+bLPaQE~H3ToJNxGSk+R_`P&woP4AvPbC&; zBFcirs=B=mXqcwus_G{Ck()O!E3ZqSL_U(&5 zu|ZVRjI+1LS(O%B1QblSM&r$Vy39!aA{KTL{t(fqDe)y@s${LtlkHbgFr?BGv$j#EmasZCy;>4%W)m(IMmBbtsy!N~F#s+%o06vukF+7Yx#@o}0V zBM^k*Mk+2HF*xDa^xh7Y)+Xer#A#jy0|^nK@EIgQ#l7j1iSX8etbVuWsy{M8`$q_L z9Sa%CpEJKxue4U-L^MryAW9@ruA(x;e`GM(acPb!mZ{s3VQ(W!-Lba3HFQ(p4( zOrHxwRddC2bb(LetxVQT)36e<{J`K~`oNG1fXG|2+nCRTd1_`~_HvTczoWi>}+!?8;-iHW8Iv>iSx}g;( z43U6WXK6d&;E85oHc5lyE~!`~ZD7njMX{}J0{4SpP@JMOYXWF3fDB%8bJtph|9qFU zZ@RFx#Snc^Q@9SklQ#<$LtL)SsMb$awenBfWTqUU4S!243tN<&R>|uF0+=aN~(_zi1U|Y*e)i6q~KExIR?bB?)^*VN7!O~Un4i&(CXU6L7le}H0_x`LZ75x zU<-T^>6YAvAVCL>yx<+D<55^irg?!7JO&6m(*|?n-MeSOvYvP=lyqz)HZt}=hn~5z zlbf$2u)D^l{jo9a{EMfg*1jsnT(eP>ea^>y2D&;^B91HK&gF_&OBuxAlX8YdM!gxS z9N~7ZmU(ZAFdYueJ@MOdwYfg-|D&14hPSkGe^}L6S zy%8LbOAO@^|Nc5JL4`|oMpq?FGGk4Ca0@sw%%mad#Iz`1o7v=s&^R@a)VAXSh))zM0fzuh?;c&Pl+GU%*JKj2@L6v1bOijC0UtyJ{D(@*8z?6uA1#2ofv@URe?pB8_n#H+S7ZK@ zlT2oH@>`Y+S0-hVNjLII5Y?h{I}wErXt2{-c^#~aEV0;t;vLY#Y%9jcxOCf63aZf$ zsSZE~M6mjel~8-wTZ>_8aKDUifD`X?%q5}9Up610yx zQor#8%Bc|}JgWWkr^A8271zyjezp^dgyu5)QKP}v&b1zjI5bvhpRgOb?1b9p;(wg0 zAlqW}H+%%e;nsBhDtf*fke|P3#}E z!`xa%dsguIga&hCEr1p%xolLK3vkF`<%;!Y*IChZa~+xl^maTC?+=n|XK$5%+62jrDYF>u zGA=))v@|GUWcf~%0u;rwNW8)nTdiD}$t`qDcjj$62Yd>t?gA7Qk%e*TqWQiS>ZBCU zV%&xnYS;eS@nCA z1_U}9&c;$-{U^oUnm4?FoQOh zaP|vNvuMQK8N{lFWZ$S|HFizj`;%}nZ4HoJJmb;Q_0 zI5@v0C(&g-0Jc$5r5{*g;4EP662jqJZqwaUX1c~~S7(L!Ps4nPrPt>L|1xhzqU-#o^p`HP6gjyYqU?)~Czc{_|zfTllc&JBHy z(*U6#sO9*;wH|03Zw7zr;tt#~dsEVF$o}t7?||q7V~q09@PD?ntSYU2RH-_e`8FHaC=Jv1<-~?axwMm3;M%usCptewl$m~clDU76kJ9S!XSn?Xh2Y?lJ z)Fuz$ge(p-zea(gbv+Vs?+V%YM(Bb_eb+WQz!*97(hbXyb$K({T&d~)| zt{1bM<7!TU^ZT{Jn%7H&jLl=IG=(kNSxs{tBmm^-IcGeAa^R&ev-xDluP|f74g}^p zi)rfe5xeOtX+zZEiF&hAsBV%pe6O`v@KJkjq(6fvkvW*Ut-#+xyg$)-?9}|C=V4!6 zGO3MQza1AGFv*{TU;-g2vLyhfiWFUH?eiE9W1cczGT{xzt>xFxayWo|t}qlanUvIT zaM+VW4R@(@V&Z>FGb6a2_}tU zrpgk7Eh;@^mUDu})phCdMY3P7IWr8YVqn&kq%v17(&St6G0}67eRccXAvF^#zgYTz zjA&;f%)Xib8PR_Hd8nACGaI&>AUm36NwFt9??gF~j0kc3Y}B@8si#W);m*(^j~wAD z&E5AT>WwV9ce)f`QrWV~72LDpm>yVUVEce`jWbzSLbio_fErYRCml?Y15te_U-%Zy z0SD7pN_w%(ftZLk#Y*pK(}7nqBWZmf(=P{6IbMzDVH>kDICfD*{%?aEI48PvXW1vI zbs|sVmyki5;0kpsWxoWdf-9V+j#!x{r_&1+ymumMhwFn&Zv_cSkohB@Dl$0fqpG*3 zeHF6)Fz9xfKAeV4^fdvMM^U)gH|M8OzbdBJc=|l{Z{hFnk)D?WhR+0F--Wza@#$}h zSHJJC9S^cKl3cmkJm&&_#7qija+L;k5@)qZ0#<_sx*}J0qjxf}H~ZLgaZE(ng)1oS zIB?WQMc+9a7i4L=`h_yxzR|gHqBdaz4mdp(2`0LAr{vems$&%e9F!ttpwIutmW}&X zBR3n{=CnSYdMOk)WpY6z6^CG`I((V>bX z@#qIwL!_fI)U|;Bj}je3uhGA&68})aJl!B^4LruuwEBOL=o_*3KmVeYk_6VX>si_? zh0ZyCfjM_9a%DhP8cElscNN`dQO9alnJsy(cu8W1$y{MBOY(C%1J1O~zM_7QbB>>h zv()@Fx&GpG)fGqDl7c7i4Yl3TAZ4SeQzx?5x7UXzq6FUV+7~%~7Zp;5heA8(*)S7Z zFr}xOb`F!jBaDbo;IiA4%4iI`c5#rh5o>tsJc#^}rQZzxP>_X$P=y^epWo8j0Nl`c zjbo&#Y_)k|j&-_~_0TggEeoyYTlhcl%hfFrskN-PU+5Z9CCqGM16yt-e(zN`;Ra*g z_rVbrAtRYFky^=;9&L9H$Ko1_b8r90N|$_dm{=oLg+N%xe~z=;Nzk(ui!Et}>K+JT z&s93Qal8kJCVA1et`U6Xob)2+K=L?A5KQ0Swa|7L-Oh;#GJRFmpnHCut$22lWhc`B8-D-Ep7yjj1 zd0_k^n!m>z^6p zNeo;xfE@c{iS6x&zT^1GA?c)fNH2LpOj>VM6OCp(VV0RP+J>{}T|j;`hq4m>h=mrk zy)}ttLR|5k<@E~w%eol6T&;#nkHTH*`G zmoyK#ejU@k0=KoZv|!mz>vBYe--mSd@`GGJ5hCDN0!+QAVgk5fAfeE|yML z*ye}hX7-ERSV88tg*107ueL}9IjqlB3r$vlN^&Zm z?`?C?TNhCF^kE;*dhigDm{cM+?i%S!a+_xXE+&)HLMn;=qW?iukzFiW%<_geWP{+A zl_3wr?;k=zOQ~2?^K5$>RZlQ&lBsL}Qow!&rVs(e!-iJ)vdxZ?09Skn>AKFn=#-S} zEbPs!PvNULBqoT9FEjEy4*w-Lta-BfyJBFJ zS3=j{MbwX9^K^#@!9vDe;>KF2SQ~`${-@%0?Zxkdns$TKk&@TUfA1hb7P+JNo9Tt5 ze?dxfYqM~4G6BQxSRAHL$xB2gG;Kp(`=AzQ3m}{=!)~HV=l(Tv^;#vedzEaJv&6&z zo@-c9jVc@1<$QHF8w-2Ywaa7Cr&cL~x-$mSnwC(y97h#x{q4O^ z6*jf*d(|nG32hC;lH$zxx!QO6J+Fmmm2cAYSFrNpJ+MCQ+yJpzQkj}Fw|7Dy2`)z$ zGOg5uU2Y4ZS%^NxnW@9>ZQI!^|ET6EwxQ{w-}G=O#fEIO+1l)W)~M&$45!!t%2U`1L`1;Lgov5OEG!d3>7Kze|oi$oP^^#J^@W zuotQUb95luuJoe-I3Ap}kwQ1WI)H@DR`Rp}RHXnLfWV8l~u^)DUx zFz|qTThc-=S#;{P(^9lUK@%F7U-uPwkWAJ(x-UnHhcN3}#)7%|zj^|cVDmd3?Gd|T zf6QoiL*ilkHy$>b|=Lr{Fu=cj{fbm>uE?DB{y>IB4OA824NdpV=AO42!pH`q-_Ve zWwa?he=ivKziWJr&c{N$G>9n^lSJ@PaJQ2NIn%fgN$i!k?Km3XG%ht2%C!8jniBof zGX&4|?}g68m2pn>+wq~ zsuV*NmD)(wrpxi&Q%`Y)(WOhMA{SI7N57Em+)EtBSp+!xf{_`H_RndhgHz5w94rho zd#P)^(M~#K4ondW-hk{8yt8HF-K--49oGMgv3q*2q;23fosMnWM#r{o+qToOZM#=i zY#SZh=&)nk&g6N&*_b(w*_eNzcGgDSwd$Q>H_1GLfdhsjAolW+xI?EU#yGYVc@BF@b@rbk)RWt#?E! zoEnLm+36!7K{Q?_M~j2%oqqumb}4fuiD@MO@S1~#ETa7 z8!7Z6ls+*eU4p_Z3vBL7{D`Xej9h@>v<$DHIfNNbtF_jra>OZJFxtOWPPytujhB>f z(*01?26)zN7e$cfd#I6-SVLr$fhHHV{>CrAt-<`?s{XC1AHCy|n9W+9byuFbS3DXd z{3KH?p|LnU3(ryf>{m$IliZ@8O{A#hWvrNJ^xGdW~^wgxT0 zZ!zsuU#OzxfsATbbnPj9%$E`f?Q*jAB==zKuH!7S#1(&$JtdiZuQ^AtOAQxpd3g3S z_dYa*Sm-18w5k0V{6r+s88Xi;b)I$MSG4-Y1jStd{xX;pDq!Z0nie#vRVTfxJG!c7 z`O-gC+kzbsGjGgnShE%+eQY{IJ}?_lh>CrY*!n}L5yLOEsjk^IEFq@x^c6F&otloH zGy=LMsdJRga3O3sCI;h>ea5yQ!|in!&Foj(S=X%a#r6t=l380^PHQZH|5)e<2-;6J zsp=*2D1s1V_cX|MvHla3us9+iEm_Wsc)Coa;qsO7A#6(2Qo~uc?{Jxv{dWKO*%6LJ zvR9$F5PdE%0yg;+rX5R-aezUn@ddYZ>fXT0o;40-@#1-=hc?2waHg&(|GJ@w z0nsaVtr4bH^``HV8xIf7I)kZV)%VzF;#+Q1_B;da-!%X-3zxPw^e)CGtJ46Gr*%}Y z6;1g!&*$~cI?-0Z`_558&)fCcdI7lK4b(_U?p=nX;m5^Z1d;#i$~x9R-@Avmr{4GH z?=-Xj$X}*^{F$x_o!W&Y8?q*ViuS)F?D!PlYD- z?(2fcZSzNB?e=XVn6(nDq!FN&psfe7kud@$&#^JdY$fTf&Gy06!NnM;w9exsbQf}S z>F>z%`4CT@?70}Lp4g}0Arv$^qNG-#>$#p-F8FLF$#;P)`eO4GD5>M)8HPWTdaC1H zC_9S$$KjV{isZ;uJRU-S5R8nHiv+Tb?#ZQ|#6{IpNc|?6qi-a)73P&tkmeMR`)!NN zQ-lQ+&w*NOb?c&jII$KI{VEJCSLVfVT8v8Z4aAjTIwWXPRk{`MF|pR= z2$CjN=hpKqFeNkBy$$1?E!`}uD$Y!BP);p33&Yw!U?)_A{}(e3i256|QIZ(1^uq2I zfPxOPb)hN8j?0iOe0Lzz$r`?S8{k_PV`k&@m}Zh`7nCIa1~a=wV>0b++lM|!71!-E ze+Jdi_D8%%L{C@@tFw*#I8LmrCC$lQ(0*VnGaNHm;5vnGM!5gN&_~C8m$hcmKK4MK zU~GG0Hx!)r-tnBlPTf_xTgaG&ZYZ z3;y^O$Rq#;84Q_)S=@*4CIfdIVrxXAiWLbNfB`wCHL0@H{`1}nQyUmi<#cq=FTuqk zZFs5@#tlhGPOi^X7VWPDuB+H{9yN3@(M}epb8ajKmSZYh?@?$oc<9W?gUpH*`ECzu zR%2OG6vU6BzTFnH_$#+ch0n<57uOLwu8O?h=T+~EnV@9~E#bdm+1BLCH!}Gm2i|ex1F6^%aG=WK7G%zk(k9~v z`P{AuY$q1{)5BX*jsk;W!fZ~(AGnvgxCuq8PRVqXd_$c2FBrmgcQ@KdGRPN#H&*o&agIUDY9trZcmVdwkd_7UOE3uGMBGQxkPYR<}MMR zep;pavdV)$`j!PEvJyDDG@$KC+Y(ba+JG$)IHEg%I49puo|d_V8HM~UF395K-y7$v zcT~_s)RSO&`6IQuOGBXAT&fgjYcUce0psLD^I12E5MS0uUHSJgKN2O8;rCxu6@Y7n zn$V=(Xl?HCM;dZida548=FnBmBt7Ku z9!`p8CSG@vFIJq^s8(VikfMn1vbSoy{kCZW9m95APMSkeE=(VlBK0)HYhdEiUZ4!$ z38;_Ah}Fh1|Kaz*_w@&e0O6iYwHQ?Y1h*sYt5GujD&gTG~X4Jm-5|EV#Pv@}`?57GnwqJf%l`BPn zwA0!&`*^~A3)b{S*6pK%_f!eZCNE17MPsAow|{^D!|HWtbE-V!7d;DUO8}gz+7CU;c)>4{6ju#6d268{$p`+p%Rymv#DQ=x(rplJU)SvC!m)l_ zCS}f8A69gjK@MKtae`m~6~YSCA1KR4OW=DQ&+vojXt|Ym&?YBRWMaJ3Snh*tM<~sOD9mBwl2< zew2Ixyq7GEMNLmt5n59)m&yV}Dl4L75Ww){1WV0OpR=H0leLPzJg+l#X3FPJm;XWa z8o!?6s^Gm2`$+WtFgd~U(82>owU2}HiM6*^pd1VSfCO3coO${G7&T)1|2t~L%Fgxw z8a2vTk2jD+2`dQn{L3kY7@T)`%Br`DD3T;3ER1q!W44OeK%74sn{@l=TUqfHLe?f~ zWZ6|x>FG|OQLSU~P}W}1UgE#FI?Z9r`#fSzN8}D%kckga4CL&6J`k-wYg&6ls5Z}S ztaew^J+gUkoo21J`5e&Oc%DayGmW}?HKv?S^FG{w6ufVo*11Bq{mx?h&d;&DnEe?T zLbA^@ukuN4pH`z9t+KqBh;JBPtw}GmpFXu>@_TMiVmf+`0|TptSX6#na>C@p(($43 zcPP=rXI2Ffdes?7+NcUW*p`K}oM6$TBOIFNso9lf+=Gnuop!EY)5>WWuKqKiGoZMu zuR}t&v2u6HDHa}=xG#2HYbASzpLd+GJpBiOvk>c6rmp`w`c$^7G-*~1?+@<5d3SPo z^Ym8a8iuo>CcvmHX1#rNChhQ=30;=Y=cdKPUa;&t_*=8VDKq-ML3`tHiD0A+XgFw<2KB(}V75_jv-78=s8NF$f#E%U5A`J|h8f~-1a^vhUP=QmXu zuBcNV>}XdHgiBV|b~!{!vZnyKuj0)EGU3s}Mrx^3S)VF`3%6r=mk6d(H)9XTr9&F5 zG-lKqf)(p5D1{|TF^4S5R7cj*+qmM~w%v+7-C`frFECZ)VsSs%RqNwo%WfoylC!-% zYdl1xUKcgOsh*&(3jBI+qgBqUsiJEvw1g9I*&wEf{^?EmHS2lK{rHYX^XiyE&zGKJ z0kP%xM~`o0+KTlVIwj>D9iyu*Z)xq`!{=|9Fxh!WiQU!{!L^p$Kj>Elr4JO5&bczE zPQv?)x(#!?3t_8fDND|5gaP$dQi+eOSKs~Qn!@6MYh!1VyP z!Ass@{rByRhrtqel|a*MGbwysPnotk>W!A6EjKQUhBd~*I2;B)PP{GHK<^9lp;RpoBriHM zB1xuv>(2eH^$5h!Ey7$*DmTh420b_>#XNby+G1ZDY4cnVMXQ{hvPY8RMfkCj5T4zdn6m-uD(Xum16M2fSau z?kSEq2IYT10(RObdboPtCl*GYh(7)Ke%;OFfAM`FuZW zBOL#IJYPO6bhaX450-wuwF`Fpf9>oX>?H{D^Z9>Wf2XSpz8~vv77_}$TWbh?=Q{=4 zJq=&}{X2G4NqEZ>WX-=p`qJ+Sh*$StnCiODBz$=`F4|(%RD-tX00{ARQvl87rIBUE zQmj0}9`^nRn;Vupy=jI6I<>%dFT|1+-1BKue_zX9SD&x;li#Q&_v9}BUFVa-lH zy=uN_tNv5le;mB~#2dl`;HEU&N8waoW2e{M#6@nQfe?szo1sXr)>oWw+|lRHmYmI7 zrQ6spx>OLFO^(0kWSwS5p->Fc)e*+Wpp{k<{cCQ`b_IVk)HHDX3f-gV_uQ|td5bEe zYhB6wdv*t2ZIBYEi@?v$%LqpTKae!H(5557DdQ;($~AA3NSSKqtl9j0s1cv8w40UMQ=tM($;l>JNHp>7M=-mZP=vrBl z!_E+772jrh?mp`>t{(#ar{)UK#)%^wES7ldN)Ti`=@#=sQ}YLi#AnIBdVdt#o}>7K zsmrr98|Y)RMY!E9_U#l=Ej2hq;*pX>TJ-QrPC(SMW@4_rBEHa*X`l{Z;#5(kfT((dYVdLn?)tzq@bO+zOR3PPV9m!J?5d50`8z49^TqHXd36= zR(#4D_cZVhs)EyZS~J`J{etasG$#U!+Z732bqG-YSCfvqCT zZjHQ)iB`oVdUVmjr>?YeoV0S@uEww@XQ?z0WTA=nIu~?_Gbc|hu#K$VV=^fj=}~;q z3#?n$`sSi6K=!7j{WiBQmQCZTQI{{Ng5=RJ(RaAqE$b)HaDvE^22fwhp;}INc)Zb5UoX zryrQok#cZv>*!(;0M}T$&2Xwd1b%_SKk-!pJ^qF;CT~D4RMbV=u z>#UTFaqmMxVujZ2gPrh&Nf3^kDko>v3RPwffIi{^7d`Nlf#o_%0O&I@_LJi zTL$KIJnNsTAEnZf43PIPn2Ul2^|i7VA`iPYHRBwq;CYj+ktT)w9yHf})G<{H*urJ@ zgxxBuVGFaWT*MtDkNO@)W|s2ySh)1yABohHmxTiGAe|6Q4T2P z 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 2>NUL if errorlevel 9009 ( echo. - echo.The Sphinx module was not found. Make sure you have Sphinx installed, - echo.then set the SPHINXBUILD environment variable to point to the full - echo.path of the 'sphinx-build' executable. Alternatively you may add the - echo.Sphinx directory to PATH. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 00000000..a1d200e5 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,66 @@ +# Configuration file for the Sphinx documentation builder. +# +# This file only contains a selection of the most common options. For a full +# list see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Path setup -------------------------------------------------------------- + +# If extensions (or modules to document with autodoc) are in another directory, +# add these directories to sys.path here. If the directory is relative to the +# documentation root, use os.path.abspath to make it absolute, like shown here. + +import os +import sys + +sys.path.insert(0, os.path.abspath("../../organize")) +print(sys.path) + + +# -- Project information ----------------------------------------------------- + +project = "organize" +copyright = "Thomas Feldmann" +author = "Thomas Feldmann" + + +# -- General configuration --------------------------------------------------- + +# Add any Sphinx extension module names here, as strings. They can be +# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom +# ones. +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.todo", + "sphinx_rtd_theme", + "myst_parser", + "sphinx.ext.autosectionlabel", + "sphinx.ext.viewcode", +] + +# Prefix document path to section labels, to use: +# `path/to/file:heading` instead of just `heading` +autosectionlabel_prefix_document = True + +source_suffix = ".rst" + +# Add any paths that contain templates here, relative to this directory. +templates_path = [] + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +# +html_theme = "sphinx_rtd_theme" + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ["images"] diff --git a/docs/_static/organize.pdf b/docs/source/images/organize.pdf similarity index 100% rename from docs/_static/organize.pdf rename to docs/source/images/organize.pdf diff --git a/docs/images/organize.svg b/docs/source/images/organize.svg similarity index 100% rename from docs/images/organize.svg rename to docs/source/images/organize.svg diff --git a/docs/index.rst b/docs/source/index.rst similarity index 100% rename from docs/index.rst rename to docs/source/index.rst diff --git a/docs/page/actions.rst b/docs/source/page/actions.rst similarity index 100% rename from docs/page/actions.rst rename to docs/source/page/actions.rst diff --git a/docs/page/config.rst b/docs/source/page/config.rst similarity index 100% rename from docs/page/config.rst rename to docs/source/page/config.rst diff --git a/docs/page/filters.rst b/docs/source/page/filters.rst similarity index 100% rename from docs/page/filters.rst rename to docs/source/page/filters.rst diff --git a/docs/page/migration.md b/docs/source/page/migration.md similarity index 100% rename from docs/page/migration.md rename to docs/source/page/migration.md diff --git a/docs/page/quickstart.rst b/docs/source/page/quickstart.rst similarity index 100% rename from docs/page/quickstart.rst rename to docs/source/page/quickstart.rst diff --git a/makedocs.sh b/makedocs.sh new file mode 100755 index 00000000..a93166c4 --- /dev/null +++ b/makedocs.sh @@ -0,0 +1,2 @@ +#!sh +poetry run sphinx-build docs/source docs/build From 75c50f62fe63c3627b2517565d3b5ebc11b779ab Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 25 Jan 2022 15:17:51 +0100 Subject: [PATCH 034/108] add hash filter, cleanup --- organize/filters/hash.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 organize/filters/hash.py diff --git a/organize/filters/hash.py b/organize/filters/hash.py new file mode 100644 index 00000000..b9566720 --- /dev/null +++ b/organize/filters/hash.py @@ -0,0 +1,27 @@ +import logging + +from fs.base import FS + +from organize.utils import JinjaEnv + +from .filter import Filter + +logger = logging.getLogger(__name__) + + +class Hash(Filter): + + name = "hash" + + def __init__(self, algorithm="md5"): + self.algorithm = JinjaEnv.from_string(algorithm) + + def pipeline(self, args: dict): + fs = args["fs"] # type: FS + fs_path = args["fs_path"] # type: str + algo = self.algorithm.render(**args) + hash_ = fs.hash(fs_path, name=algo) + return {"hash": hash_} + + def __str__(self) -> str: + return "Hash(algorithm={})".format(self.algorithm) From bb47aecdc4d469d2fc8c85e744f837da2d8452c3 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 25 Jan 2022 15:17:54 +0100 Subject: [PATCH 035/108] cleanup --- organize/actions/__init__.py | 3 ++- organize/config.py | 6 +++--- organize/core.py | 4 ++-- organize/filters/__init__.py | 5 ++++- organize/filters/duplicate.py | 4 ++-- organize/filters/extension.py | 2 -- organize/filters/filecontent.py | 4 ++-- organize/output.py | 4 +++- testconf.yaml | 6 +++--- 9 files changed, 21 insertions(+), 17 deletions(-) diff --git a/organize/actions/__init__.py b/organize/actions/__init__.py index 4604c5e6..3ebe70bf 100644 --- a/organize/actions/__init__.py +++ b/organize/actions/__init__.py @@ -1,3 +1,4 @@ +from .action import Action from .confirm import Confirm from .copy import Copy from .delete import Delete @@ -9,7 +10,7 @@ from .shell import Shell from .trash import Trash -ALL = { +ACTIONS = { Confirm.name: Confirm, Copy.name: Copy, Delete.name: Delete, diff --git a/organize/config.py b/organize/config.py index 453c96ae..0325a183 100644 --- a/organize/config.py +++ b/organize/config.py @@ -4,8 +4,8 @@ from rich.console import Console from schema import And, Optional, Or, Schema, SchemaError, Literal -from organize.actions import ALL as ACTIONS -from organize.filters import ALL as FILTERS +from organize.actions import ACTIONS +from organize.filters import FILTERS console = Console() @@ -14,7 +14,7 @@ Optional("version"): int, "rules": [ { - Optional("name", description="The name of the rule"): And(str, len), + Optional("name", description="The name of the rule"): str, Optional("targets"): Or("dirs", "files"), "locations": Or( str, diff --git a/organize/core.py b/organize/core.py index f2ff1240..5c65fd36 100644 --- a/organize/core.py +++ b/organize/core.py @@ -8,9 +8,9 @@ from fs.walk import Walker from schema import SchemaError -from .actions import ALL as ACTIONS +from .actions import ACTIONS from .actions.action import Action -from .filters import ALL as FILTERS +from .filters import FILTERS from .filters.filter import Filter from .output import RichOutput, console from .utils import deep_merge_inplace, JinjaEnv, ensure_list diff --git a/organize/filters/__init__.py b/organize/filters/__init__.py index 5d56081b..8ce48105 100644 --- a/organize/filters/__init__.py +++ b/organize/filters/__init__.py @@ -3,6 +3,8 @@ from .exif import Exif from .extension import Extension from .filecontent import FileContent +from .filter import Filter +from .hash import Hash from .lastmodified import LastModified from .mimetype import MimeType from .name import Name @@ -10,12 +12,13 @@ from .regex import Regex from .size import Size -ALL = { +FILTERS = { Created.name: Created, Duplicate.name: Duplicate, Exif.name: Exif, Extension.name: Extension, FileContent.name: FileContent, + Hash.name: Hash, Name.name: Name, Size.name: Size, LastModified.name: LastModified, diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index c3788a3a..5d483c26 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -42,8 +42,6 @@ def get_hash(filename, first_chunk_only=False, hash_algo=hashlib.sha1): class Duplicate(Filter): - name = "duplicate" - """ Finds duplicate files. @@ -71,6 +69,8 @@ class Duplicate(Filter): - echo: "{path} is a duplicate of {duplicate}" """ + name = "duplicate" + def __init__(self) -> None: self.files_for_size = defaultdict(list) # type: DDict[int, List[str]] diff --git a/organize/filters/extension.py b/organize/filters/extension.py index bd484d0b..5539d6f6 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -23,8 +23,6 @@ def __str__(self): class Extension(Filter): - name = "extension" - """ Filter by file extension diff --git a/organize/filters/filecontent.py b/organize/filters/filecontent.py index c947acb3..6886f91d 100644 --- a/organize/filters/filecontent.py +++ b/organize/filters/filecontent.py @@ -12,8 +12,6 @@ class FileContent(Filter): - name = "filecontent" - r""" Matches file content with the given regular expression @@ -55,6 +53,8 @@ class FileContent(Filter): - move: '~/Documents/Invoices/{filecontent.customer}/' """ + name = "filecontent" + def __init__(self, expr) -> None: self.expr = re.compile(expr, re.MULTILINE | re.DOTALL) diff --git a/organize/output.py b/organize/output.py index 5da3b291..ee82c059 100644 --- a/organize/output.py +++ b/organize/output.py @@ -92,7 +92,9 @@ def print_location_spacer(self): console.print() def print_path(self, path): - console.print(indent(str(path), " " * 2), style="purple bold") + # "page_facing_up": "📄", + # "file_folder": "📁", + console.print(indent(":file_folder: %s" % path, " " * 2), style="purple bold") def print_not_found(self, path): msg = "Path not found: {}".format(path) diff --git a/testconf.yaml b/testconf.yaml index 9d13eb26..97887f66 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -5,10 +5,10 @@ rules: - path: ~/Desktop/test max_depth: 3 filters: - - name: "Bildschirmfoto {date} um {time}_*" - - extension: png + - hash: + algorithm: md5 actions: - - move: "~/Desktop/Fotos/{name}.png" + - echo: "{hash}" # - name: Find some folders # targets: dirs From 52ebf0121e94d96d55a442ee8a01ae516fe447b7 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 25 Jan 2022 15:23:18 +0100 Subject: [PATCH 036/108] update hash docs --- organize/filters/hash.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/organize/filters/hash.py b/organize/filters/hash.py index b9566720..205573ae 100644 --- a/organize/filters/hash.py +++ b/organize/filters/hash.py @@ -11,6 +11,23 @@ class Hash(Filter): + """ + Args: + algorithm (str) + Any hashing algorithm available to python's `hashlib`. + + List the available algorithms on your installation: + + >>> import hashlib + >>> hashlib.algorithms_guaranteed + {'shake_256', 'sha3_256', 'sha1', 'sha3_224', 'sha384', 'sha512', 'blake2b', 'blake2s', 'sha256', 'sha224', 'shake_128', 'sha3_512', 'sha3_384', 'md5'} + >>> hashlib.algorithms_available + {'shake_256', 'whirlpool', 'mdc2', 'blake2s', 'sha224', 'shake_128', 'sha3_512', 'sha3_224', 'sha384', 'md5', 'sha1', 'sha512_256', 'blake2b', 'sha256', 'sha512_224', 'ripemd160', 'sha3_384', 'md4', 'sm3', 'sha3_256', 'md5-sha1', 'sha512'} + + Returns: + {"hash": str} where str is the hash of the file. + """ + name = "hash" def __init__(self, algorithm="md5"): From cdb59195caea4682054984d68ab7aa39e1a472f1 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 25 Jan 2022 15:51:33 +0100 Subject: [PATCH 037/108] cleanup imports --- organize/__main__.py | 4 +--- organize/actions/__init__.py | 2 +- organize/actions/confirm.py | 6 ++++-- organize/actions/copy.py | 2 +- organize/actions/delete.py | 2 +- organize/actions/echo.py | 2 +- organize/actions/macos_tags.py | 2 +- organize/actions/move.py | 2 +- organize/actions/python.py | 2 +- organize/actions/rename.py | 2 +- organize/actions/shell.py | 2 +- organize/actions/trash.py | 2 +- organize/config.py | 2 +- organize/filters/__init__.py | 2 +- organize/filters/created.py | 2 +- organize/filters/duplicate.py | 2 +- organize/filters/exif.py | 2 +- organize/filters/extension.py | 2 +- organize/filters/filecontent.py | 2 +- organize/filters/hash.py | 4 +++- organize/filters/lastmodified.py | 2 +- organize/filters/mimetype.py | 2 +- organize/filters/name.py | 2 +- organize/filters/python.py | 2 +- organize/filters/regex.py | 2 +- organize/filters/size.py | 2 +- 26 files changed, 31 insertions(+), 29 deletions(-) diff --git a/organize/__main__.py b/organize/__main__.py index 02ea28da..710a2d1f 100644 --- a/organize/__main__.py +++ b/organize/__main__.py @@ -1,6 +1,4 @@ -import sys - if __name__ == "__main__": from .cli import main - sys.exit(main()) + main() diff --git a/organize/actions/__init__.py b/organize/actions/__init__.py index 3ebe70bf..8ea89ea8 100644 --- a/organize/actions/__init__.py +++ b/organize/actions/__init__.py @@ -1,4 +1,4 @@ -from .action import Action +from . import Action from .confirm import Confirm from .copy import Copy from .delete import Delete diff --git a/organize/actions/confirm.py b/organize/actions/confirm.py index 1ee76fd5..3a5ff6a0 100644 --- a/organize/actions/confirm.py +++ b/organize/actions/confirm.py @@ -1,8 +1,10 @@ import logging from rich.prompt import Prompt -from ..output import console -from .action import Action + +from organize.output import console + +from . import Action logger = logging.getLogger(__name__) diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 7df73535..473001ea 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -8,7 +8,7 @@ from organize.utils import JinjaEnv, file_desc -from .action import Action +from . import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict logger = logging.getLogger(__name__) diff --git a/organize/actions/delete.py b/organize/actions/delete.py index 0546617a..c2cbe2eb 100644 --- a/organize/actions/delete.py +++ b/organize/actions/delete.py @@ -1,6 +1,6 @@ import logging from fs.base import FS -from .action import Action +from . import Action logger = logging.getLogger(__name__) diff --git a/organize/actions/echo.py b/organize/actions/echo.py index b3e55549..65570c17 100644 --- a/organize/actions/echo.py +++ b/organize/actions/echo.py @@ -1,6 +1,6 @@ import logging -from .action import Action +from . import Action logger = logging.getLogger(__name__) diff --git a/organize/actions/macos_tags.py b/organize/actions/macos_tags.py index 63134d0c..261d39db 100644 --- a/organize/actions/macos_tags.py +++ b/organize/actions/macos_tags.py @@ -4,7 +4,7 @@ import simplematch as sm # type: ignore from schema import Or -from .action import Action +from . import Action logger = logging.getLogger(__name__) diff --git a/organize/actions/move.py b/organize/actions/move.py index 89afd4c6..7e012557 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -8,7 +8,7 @@ from organize.utils import JinjaEnv, file_desc -from .action import Action +from . import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict logger = logging.getLogger(__name__) diff --git a/organize/actions/python.py b/organize/actions/python.py index e28d7d16..32eed2bf 100644 --- a/organize/actions/python.py +++ b/organize/actions/python.py @@ -5,7 +5,7 @@ from schema import Optional, Or -from .action import Action +from . import Action logger = logging.getLogger(__name__) diff --git a/organize/actions/rename.py b/organize/actions/rename.py index 43dc7a7e..53a516b7 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -8,7 +8,7 @@ from organize.utils import JinjaEnv, file_desc -from .action import Action +from . import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict logger = logging.getLogger(__name__) diff --git a/organize/actions/shell.py b/organize/actions/shell.py index 5517a62e..efff7c30 100644 --- a/organize/actions/shell.py +++ b/organize/actions/shell.py @@ -5,7 +5,7 @@ from subprocess import PIPE from ..utils import JinjaEnv -from .action import Action +from . import Action logger = logging.getLogger(__name__) diff --git a/organize/actions/trash.py b/organize/actions/trash.py index 06cc95d1..63d4a9ea 100644 --- a/organize/actions/trash.py +++ b/organize/actions/trash.py @@ -1,6 +1,6 @@ import logging -from .action import Action +from . import Action logger = logging.getLogger(__name__) diff --git a/organize/config.py b/organize/config.py index 0325a183..3a63ec0b 100644 --- a/organize/config.py +++ b/organize/config.py @@ -23,7 +23,6 @@ str, { "path": And(str, len), - Optional("filesystem"): str, Optional("max_depth"): Or(int, None), Optional("search"): Or("depth", "breadth"), Optional("exclude_files"): [str], @@ -33,6 +32,7 @@ Optional("ignore_errors"): bool, Optional("filter"): [str], Optional("filter_dirs"): [str], + Optional("filesystem"): str, }, ), ], diff --git a/organize/filters/__init__.py b/organize/filters/__init__.py index 8ce48105..5ebb5c97 100644 --- a/organize/filters/__init__.py +++ b/organize/filters/__init__.py @@ -3,7 +3,7 @@ from .exif import Exif from .extension import Extension from .filecontent import FileContent -from .filter import Filter +from . import Filter from .hash import Hash from .lastmodified import LastModified from .mimetype import MimeType diff --git a/organize/filters/created.py b/organize/filters/created.py index 5bc90fe1..30c38c0b 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from typing import Dict, Optional as tyOptional -from .filter import Filter +from . import Filter class Created(Filter): diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index 5d483c26..59ec88a5 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -18,7 +18,7 @@ from organize.utils import fullpath -from .filter import Filter +from . import Filter def chunk_reader(fobj, chunk_size=1024): diff --git a/organize/filters/exif.py b/organize/filters/exif.py index 4e98d32b..0fc58b02 100644 --- a/organize/filters/exif.py +++ b/organize/filters/exif.py @@ -5,7 +5,7 @@ from pathlib import Path -from .filter import Filter +from . import Filter ExifDict = Mapping[str, Union[str, Mapping[str, str]]] diff --git a/organize/filters/extension.py b/organize/filters/extension.py index 5539d6f6..dbf019ea 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -3,7 +3,7 @@ from pathlib import Path from organize.utils import flatten -from .filter import Filter +from . import Filter class ExtensionResult: diff --git a/organize/filters/filecontent.py b/organize/filters/filecontent.py index 6886f91d..364edbfc 100644 --- a/organize/filters/filecontent.py +++ b/organize/filters/filecontent.py @@ -3,7 +3,7 @@ from fs.errors import NoSysPath -from .filter import Filter +from . import Filter SUPPORTED_EXTENSIONS = ( # not supported: .gif, .jpg, .mp3, .ogg, .png, .tiff, .wav diff --git a/organize/filters/hash.py b/organize/filters/hash.py index 205573ae..be590779 100644 --- a/organize/filters/hash.py +++ b/organize/filters/hash.py @@ -4,7 +4,7 @@ from organize.utils import JinjaEnv -from .filter import Filter +from . import Filter logger = logging.getLogger(__name__) @@ -12,6 +12,8 @@ class Hash(Filter): """ + Calculates the hash of a file. + Args: algorithm (str) Any hashing algorithm available to python's `hashlib`. diff --git a/organize/filters/lastmodified.py b/organize/filters/lastmodified.py index 1cc3f39d..2296ac38 100644 --- a/organize/filters/lastmodified.py +++ b/organize/filters/lastmodified.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from typing import Dict, Optional as tyOptional -from .filter import Filter +from . import Filter class LastModified(Filter): diff --git a/organize/filters/mimetype.py b/organize/filters/mimetype.py index 266ff7da..1518cc54 100644 --- a/organize/filters/mimetype.py +++ b/organize/filters/mimetype.py @@ -2,7 +2,7 @@ from organize.utils import flatten -from .filter import Filter +from . import Filter class MimeType(Filter): diff --git a/organize/filters/name.py b/organize/filters/name.py index 3965a7a2..575cc45d 100644 --- a/organize/filters/name.py +++ b/organize/filters/name.py @@ -3,7 +3,7 @@ import simplematch # type: ignore from fs import path -from .filter import Filter +from . import Filter class Name(Filter): diff --git a/organize/filters/python.py b/organize/filters/python.py index 15ecfeaf..450c84cc 100644 --- a/organize/filters/python.py +++ b/organize/filters/python.py @@ -1,7 +1,7 @@ import textwrap from typing import Any, Dict, Optional, Sequence -from .filter import Filter +from . import Filter class Python(Filter): diff --git a/organize/filters/regex.py b/organize/filters/regex.py index 7816aef3..f47b1010 100644 --- a/organize/filters/regex.py +++ b/organize/filters/regex.py @@ -1,7 +1,7 @@ import re from typing import Any, Dict, Mapping, Optional -from .filter import Filter +from . import Filter class Regex(Filter): diff --git a/organize/filters/size.py b/organize/filters/size.py index f70c8715..24c0988b 100644 --- a/organize/filters/size.py +++ b/organize/filters/size.py @@ -9,7 +9,7 @@ from organize.utils import flattened_string_list, fullpath -from .filter import Filter +from . import Filter OPERATORS = { "<": operator.lt, From 9b0afab57875889dde85fb7e5168701c18c9d183 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 25 Jan 2022 20:56:58 +0100 Subject: [PATCH 038/108] add empty filter --- organize/filters/empty.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 organize/filters/empty.py diff --git a/organize/filters/empty.py b/organize/filters/empty.py new file mode 100644 index 00000000..316c7d43 --- /dev/null +++ b/organize/filters/empty.py @@ -0,0 +1,25 @@ +from fs.base import FS + +from .filter import Filter + + +class Empty(Filter): + + """Only lets through empty dirs and folders""" + + name = "empty" + + @classmethod + def get_schema(cls): + return cls.name + + def pipeline(self, args: dict): + fs = args["fs"] # type: FS + fs_path = args["fs_path"] # type: str + + if fs.isdir(fs_path): + return fs.isempty(fs_path) + return fs.getsize(fs_path) == 0 + + def __str__(self) -> str: + return "Empty()" From 70050b7980ca23e5e70c52cf07706ba6ef264bf5 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 25 Jan 2022 21:02:03 +0100 Subject: [PATCH 039/108] switch from sphinx to mkdocs for docs --- docs/Makefile | 20 - docs/actions.md | 183 ++++ docs/changelog.md | 3 + docs/cli.md | 1 + docs/config.md | 1 + docs/filters.md | 66 ++ docs/{source/images => img}/organize.pdf | Bin docs/{source/images => img}/organize.svg | 0 docs/index.md | 5 + docs/locations.md | 1 + docs/make.bat | 35 - docs/source/conf.py | 66 -- docs/source/index.rst | 26 - docs/source/page/actions.rst | 41 - docs/source/page/filters.rst | 49 - docs/source/page/migration.md | 1 - docs/{source/page => sphinx}/config.rst | 5 +- docs/{source/page => sphinx}/quickstart.rst | 0 mkdocs.yml | 32 + organize/actions/__init__.py | 2 +- organize/actions/confirm.py | 2 +- organize/actions/copy.py | 2 +- organize/actions/delete.py | 19 +- organize/actions/echo.py | 64 +- organize/actions/macos_tags.py | 79 +- organize/actions/move.py | 2 +- organize/actions/python.py | 2 +- organize/actions/rename.py | 2 +- organize/actions/shell.py | 2 +- organize/actions/trash.py | 24 +- organize/filters/__init__.py | 4 +- organize/filters/created.py | 2 +- organize/filters/duplicate.py | 7 +- organize/filters/exif.py | 2 +- organize/filters/extension.py | 2 +- organize/filters/filecontent.py | 2 +- organize/filters/hash.py | 28 +- organize/filters/lastmodified.py | 2 +- organize/filters/mimetype.py | 2 +- organize/filters/name.py | 2 +- organize/filters/python.py | 2 +- organize/filters/regex.py | 2 +- organize/filters/size.py | 4 +- poetry.lock | 1069 +++++-------------- pyproject.toml | 14 +- testconf.yaml | 10 +- 46 files changed, 627 insertions(+), 1262 deletions(-) delete mode 100644 docs/Makefile create mode 100644 docs/actions.md create mode 100644 docs/changelog.md create mode 100644 docs/cli.md create mode 100644 docs/config.md create mode 100644 docs/filters.md rename docs/{source/images => img}/organize.pdf (100%) rename docs/{source/images => img}/organize.svg (100%) create mode 100644 docs/index.md create mode 100644 docs/locations.md delete mode 100644 docs/make.bat delete mode 100644 docs/source/conf.py delete mode 100644 docs/source/index.rst delete mode 100644 docs/source/page/actions.rst delete mode 100644 docs/source/page/filters.rst delete mode 100644 docs/source/page/migration.md rename docs/{source/page => sphinx}/config.rst (99%) rename docs/{source/page => sphinx}/quickstart.rst (100%) create mode 100644 mkdocs.yml diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d0c3cbf1..00000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = source -BUILDDIR = build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/actions.md b/docs/actions.md new file mode 100644 index 00000000..e1a3462d --- /dev/null +++ b/docs/actions.md @@ -0,0 +1,183 @@ +# Actions + +## copy + +::: organize.actions.copy.Copy + +## delete + +::: organize.actions.delete.Delete + +```yaml +rules: + - locations: "~/Downloads" + - filters: + - lastmodified: + days: 365 + - extension: + - png + - jpg + - actions: + - delete +``` + +## echo + +::: organize.actions.Echo + +

Examples +Prints "Found old file" for each file older than one year: + +```yaml +rules: + - locations: ~/Desktop + filters: + - lastmodified: + days: 365 + actions: + - echo: "Found old file" +``` + +Prints "Hello World!" and filepath for each file on the desktop: + +```yaml +:caption: config.yaml + +rules: + - locations: + - ~/Desktop + actions: + - echo: "Hello World! {path}" +``` + +This will print something like `Found a PNG: "test.png"` for each file on your desktop + +```yaml +:caption: config.yaml + +rules: + - locations: + - ~/Desktop + filters: + - Extension + actions: + - echo: 'Found a {extension.upper}: "{path.name}"' +``` + +Show the `{basedir}` and `{path}` of all files in '~/Downloads', '~/Desktop' and their subfolders: + +```yaml +:caption: config.yaml + +rules: + - locations: + - ~/Desktop + - ~/Downloads + subfolders: true + actions: + - echo: "Basedir: {basedir}" + - echo: "Path: {path}" +``` + +
+ +## macos_tags + +::: organize.actions.MacOSTags + +
+Examples + +Add a single tag + +```yaml +rules: + - locations: "~/Documents/Invoices" + - filters: + - filename: + startswith: "Invoice" + - extension: pdf + - actions: + - macos_tags: Invoice +``` + +Adding multiple tags ("Invoice" and "Important") + +```yaml +rules: + - locations: "~/Documents/Invoices" + - filters: + - filename: + startswith: "Invoice" + - extension: pdf + - actions: + - macos_tags: + - Important + - Invoice +``` + +Specify tag colors + +```yaml +rules: + - locations: "~/Documents/Invoices" + - filters: + - filename: + startswith: "Invoice" + - extension: pdf + - actions: + - macos_tags: + - Important (green) + - Invoice (purple) +``` + +Add a templated tag with color + +```yaml +rules: + - locations: "~/Documents/Invoices" + - filters: + - created + - actions: + - macos_tags: + - Year-{created.year} (red) +``` + +
+ +## move + +::: organize.actions.Move + +## python + +::: organize.actions.Python + +## rename + +::: organize.actions.Rename + +## shell + +::: organize.actions.Shell + +## trash + +::: organize.actions.Trash + +Example: + +```yaml +rules: + - name: Move all JPGs and PNGs on the desktop which are older than one year into the trash + - locations: "~/Desktop" + - filters: + - lastmodified: + years: 1 + mode: older + - extension: + - png + - jpg + - actions: + - trash +``` diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 00000000..15fe40c9 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,3 @@ +{% + include-markdown "../CHANGELOG.md" +%} diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..69e4e8dd --- /dev/null +++ b/docs/cli.md @@ -0,0 +1 @@ +# Command line interface diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 00000000..a025a48b --- /dev/null +++ b/docs/config.md @@ -0,0 +1 @@ +# Configuration diff --git a/docs/filters.md b/docs/filters.md new file mode 100644 index 00000000..29c4b404 --- /dev/null +++ b/docs/filters.md @@ -0,0 +1,66 @@ +# Filters + +## created + +::: organize.filters.Created + +## duplicate + +::: organize.filters.Duplicate + +## empty + +::: organize.filters.Empty + +```yaml +rules: + - name: "Recursively delete empty folders" + targets: dirs + locations: + - path: ~/Desktop + max_depth: null + filters: + - empty + actions: + - delete +``` + +## exif + +::: organize.filters.Exif + +## extension + +::: organize.filters.Extension + +## filecontent + +::: organize.filters.FileContent + +## hash + +::: organize.filters.Hash + +## name + +::: organize.filters.Name + +## size + +::: organize.filters.Size + +## lastmodified + +::: organize.filters.LastModified + +## mimetype + +::: organize.filters.MimeType + +## python + +::: organize.filters.Python + +## regex + +::: organize.filters.Regex diff --git a/docs/source/images/organize.pdf b/docs/img/organize.pdf similarity index 100% rename from docs/source/images/organize.pdf rename to docs/img/organize.pdf diff --git a/docs/source/images/organize.svg b/docs/img/organize.svg similarity index 100% rename from docs/source/images/organize.svg rename to docs/img/organize.svg diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..d064e031 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,5 @@ +# Welcome to the documentation for organize + +{% + include-markdown "../README.md" +%} diff --git a/docs/locations.md b/docs/locations.md new file mode 100644 index 00000000..b7a7f0de --- /dev/null +++ b/docs/locations.md @@ -0,0 +1 @@ +# Locations diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 922152e9..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index a1d200e5..00000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,66 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. - -import os -import sys - -sys.path.insert(0, os.path.abspath("../../organize")) -print(sys.path) - - -# -- Project information ----------------------------------------------------- - -project = "organize" -copyright = "Thomas Feldmann" -author = "Thomas Feldmann" - - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.todo", - "sphinx_rtd_theme", - "myst_parser", - "sphinx.ext.autosectionlabel", - "sphinx.ext.viewcode", -] - -# Prefix document path to section labels, to use: -# `path/to/file:heading` instead of just `heading` -autosectionlabel_prefix_document = True - -source_suffix = ".rst" - -# Add any paths that contain templates here, relative to this directory. -templates_path = [] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "sphinx_rtd_theme" - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["images"] diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index 62d3710a..00000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,26 +0,0 @@ -.. image:: https://github.com/tfeldmann/organize/raw/master/docs/images/organize.svg?sanitize=true - -organize -======== -organize is a command line utility to automate file organization tasks. - -http://github.com/tfeldmann/organize - -Contents: ---------- -.. toctree:: - - page/quickstart - page/config - page/filters - page/actions - page/migration - -If you find any bugs or have an idea for a new feature please don't hesitate to `open an issue `_ on GitHub. - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/source/page/actions.rst b/docs/source/page/actions.rst deleted file mode 100644 index 8e027a38..00000000 --- a/docs/source/page/actions.rst +++ /dev/null @@ -1,41 +0,0 @@ -.. _actions: -.. py:module:: actions - -Actions -======= - -Copy ----- -.. autoclass:: Copy(dest, [overwrite=False], [counter_separator=' ']) - -Delete ------- -.. autoclass:: Delete - -Echo ----- -.. autoclass:: Echo - -Move ----- -.. autoclass:: Move(dest, [overwrite=False], [counter_separator=' ']) - -Python ------- -.. autoclass:: actions.Python - -Rename ------- -.. autoclass:: Rename(dest, [overwrite=False], [counter_separator=' ']) - -Shell ------ -.. autoclass:: Shell - -Trash ------ -.. autoclass:: Trash - -macOS Tags ----------- -.. autoclass:: MacOSTags diff --git a/docs/source/page/filters.rst b/docs/source/page/filters.rst deleted file mode 100644 index b18cdfca..00000000 --- a/docs/source/page/filters.rst +++ /dev/null @@ -1,49 +0,0 @@ -.. _filters: -.. py:module:: filters - -Filters -======= - -Created -------- -.. autoclass:: Created - -Duplicate ---------- -.. autoclass:: Duplicate - -Exif ----- -.. autoclass:: Exif - -Extension ---------- -.. autoclass:: Extension - -FileContent ------------ -.. autoclass:: FileContent - -Name --------- -.. autoclass:: Name - -Size --------- -.. autoclass:: Size - -LastModified ------------- -.. autoclass:: LastModified - -MimeType ------------- -.. autoclass:: MimeType - -Python ------- -.. autoclass:: filters.Python - -Regex ------ -.. autoclass:: Regex diff --git a/docs/source/page/migration.md b/docs/source/page/migration.md deleted file mode 100644 index 2d1bc598..00000000 --- a/docs/source/page/migration.md +++ /dev/null @@ -1 +0,0 @@ -# New in `organize` v2 diff --git a/docs/source/page/config.rst b/docs/sphinx/config.rst similarity index 99% rename from docs/source/page/config.rst rename to docs/sphinx/config.rst index 6ed296ec..ea89d4fa 100644 --- a/docs/source/page/config.rst +++ b/docs/sphinx/config.rst @@ -1,7 +1,4 @@ -************* -Configuration -************* - +# Configuration Editing the configuration ========================= diff --git a/docs/source/page/quickstart.rst b/docs/sphinx/quickstart.rst similarity index 100% rename from docs/source/page/quickstart.rst rename to docs/sphinx/quickstart.rst diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..5ec348fd --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,32 @@ +site_name: organize +nav: + - Home: index.md + - Configuration: config.md + - Locations: locations.md + - Filters: filters.md + - Actions: actions.md + - Changelog: changelog.md +plugins: + - search + - include-markdown + - autorefs + - mkdocstrings: + default_handler: python + handlers: + python: + selection: + members: false + rendering: + show_bases: false + show_root_toc_entry: false + show_root_heading: false + show_source: false + watch: + - organize + +markdown_extensions: + - toc: + permalink: "#" + +theme: + name: readthedocs diff --git a/organize/actions/__init__.py b/organize/actions/__init__.py index 8ea89ea8..3ebe70bf 100644 --- a/organize/actions/__init__.py +++ b/organize/actions/__init__.py @@ -1,4 +1,4 @@ -from . import Action +from .action import Action from .confirm import Confirm from .copy import Copy from .delete import Delete diff --git a/organize/actions/confirm.py b/organize/actions/confirm.py index 3a5ff6a0..9d2df1b1 100644 --- a/organize/actions/confirm.py +++ b/organize/actions/confirm.py @@ -4,7 +4,7 @@ from organize.output import console -from . import Action +from .action import Action logger = logging.getLogger(__name__) diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 473001ea..7df73535 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -8,7 +8,7 @@ from organize.utils import JinjaEnv, file_desc -from . import Action +from .action import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict logger = logging.getLogger(__name__) diff --git a/organize/actions/delete.py b/organize/actions/delete.py index c2cbe2eb..88f5798f 100644 --- a/organize/actions/delete.py +++ b/organize/actions/delete.py @@ -1,6 +1,6 @@ import logging from fs.base import FS -from . import Action +from .action import Action logger = logging.getLogger(__name__) @@ -12,23 +12,6 @@ class Delete(Action): Deleted files have no recovery option! Using the `Trash` action is strongly advised for most use-cases! - - Example: - - Delete all JPGs and PNGs on the desktop which are older than one year: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - - filters: - - lastmodified: - - days: 365 - - extension: - - png - - jpg - - actions: - - delete """ name = "delete" diff --git a/organize/actions/echo.py b/organize/actions/echo.py index 65570c17..78002a8a 100644 --- a/organize/actions/echo.py +++ b/organize/actions/echo.py @@ -1,6 +1,6 @@ import logging -from . import Action +from .action import Action logger = logging.getLogger(__name__) @@ -9,65 +9,13 @@ class Echo(Action): - """ - Prints the given (formatted) message. This can be useful to test your rules, - especially if you use formatted messages. - - :param str msg: The message to print (can be formatted) - - Example: - - Prints "Found old file" for each file older than one year: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Desktop - filters: - - lastmodified: - days: 365 - actions: - - echo: 'Found old file' - - - Prints "Hello World!" and filepath for each file on the desktop: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: - - ~/Desktop - actions: - - echo: 'Hello World! {path}' - - - This will print something like ``Found a PNG: "test.png"`` for each - file on your desktop: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: - - ~/Desktop - filters: - - Extension - actions: - - echo: 'Found a {extension.upper}: "{path.name}"' - - - Show the ``{basedir}`` and ``{path}`` of all files in '~/Downloads', - '~/Desktop' and their subfolders: + """Prints the given message. - .. code-block:: yaml - :caption: config.yaml + This can be useful to test your rules, especially in combination with placeholder + variables. - rules: - - folders: - - ~/Desktop - - ~/Downloads - subfolders: true - actions: - - echo: 'Basedir: {basedir}' - - echo: 'Path: {path}' + Args: + msg(str): The message to print. Accepts placeholder variables. """ name = "echo" diff --git a/organize/actions/macos_tags.py b/organize/actions/macos_tags.py index 261d39db..51e1aed0 100644 --- a/organize/actions/macos_tags.py +++ b/organize/actions/macos_tags.py @@ -4,78 +4,29 @@ import simplematch as sm # type: ignore from schema import Or -from . import Action +from .action import Action logger = logging.getLogger(__name__) class MacOSTags(Action): - name = "macos_tags" + """Add macOS tags. + Args: + *tags (str): A list of tags or a single tag. + + The color can be specified in brackets after the tag name, for example: + + ```yaml + macos_tags: "Invoices (red)" + ``` + + Available colors are `none`, `gray`, `green`, `purple`, `blue`, `yellow`, `red` and + `orange`. """ - Add macOS tags. - - Example: - - Add a single tag: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Documents/Invoices' - - filters: - - filename: - startswith: "Invoice" - - extension: pdf - - actions: - - macos_tags: Invoice - - - Adding multiple tags ("Invoice" and "Important"): - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Documents/Invoices' - - filters: - - filename: - startswith: "Invoice" - - extension: pdf - - actions: - - macos_tags: - - Important - - Invoice - - - Specify tag colors. Available colors are `none`, `gray`, `green`, `purple`, `blue`, `yellow`, `red`, `orange`. - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Documents/Invoices' - - filters: - - filename: - startswith: "Invoice" - - extension: pdf - - actions: - - macos_tags: - - Important (green) - - Invoice (purple) - - - Add a templated tag with color: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Documents/Invoices' - - filters: - - created - - actions: - - macos_tags: - - Year-{created.year} (red) - """ + + name = "macos_tags" @classmethod def get_schema(cls): diff --git a/organize/actions/move.py b/organize/actions/move.py index 7e012557..89afd4c6 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -8,7 +8,7 @@ from organize.utils import JinjaEnv, file_desc -from . import Action +from .action import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict logger = logging.getLogger(__name__) diff --git a/organize/actions/python.py b/organize/actions/python.py index 32eed2bf..e28d7d16 100644 --- a/organize/actions/python.py +++ b/organize/actions/python.py @@ -5,7 +5,7 @@ from schema import Optional, Or -from . import Action +from .action import Action logger = logging.getLogger(__name__) diff --git a/organize/actions/rename.py b/organize/actions/rename.py index 53a516b7..43dc7a7e 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -8,7 +8,7 @@ from organize.utils import JinjaEnv, file_desc -from . import Action +from .action import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict logger = logging.getLogger(__name__) diff --git a/organize/actions/shell.py b/organize/actions/shell.py index efff7c30..5517a62e 100644 --- a/organize/actions/shell.py +++ b/organize/actions/shell.py @@ -5,7 +5,7 @@ from subprocess import PIPE from ..utils import JinjaEnv -from . import Action +from .action import Action logger = logging.getLogger(__name__) diff --git a/organize/actions/trash.py b/organize/actions/trash.py index 63d4a9ea..2fffc63d 100644 --- a/organize/actions/trash.py +++ b/organize/actions/trash.py @@ -1,33 +1,13 @@ import logging -from . import Action +from .action import Action logger = logging.getLogger(__name__) class Trash(Action): - """ - Move a file into the trash. - - Example: - - Move all JPGs and PNGs on the desktop which are older than one year - into the trash: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - - filters: - - lastmodified: - - days: 365 - - extension: - - png - - jpg - - actions: - - trash - """ + """Move a file or dir into the trash.""" name = "trash" diff --git a/organize/filters/__init__.py b/organize/filters/__init__.py index 5ebb5c97..cb2c9d45 100644 --- a/organize/filters/__init__.py +++ b/organize/filters/__init__.py @@ -1,9 +1,10 @@ from .created import Created from .duplicate import Duplicate +from .empty import Empty from .exif import Exif from .extension import Extension from .filecontent import FileContent -from . import Filter +from .filter import Filter from .hash import Hash from .lastmodified import LastModified from .mimetype import MimeType @@ -15,6 +16,7 @@ FILTERS = { Created.name: Created, Duplicate.name: Duplicate, + Empty.name: Empty, Exif.name: Exif, Extension.name: Extension, FileContent.name: FileContent, diff --git a/organize/filters/created.py b/organize/filters/created.py index 30c38c0b..5bc90fe1 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from typing import Dict, Optional as tyOptional -from . import Filter +from .filter import Filter class Created(Filter): diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index 59ec88a5..fae75f50 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -4,11 +4,8 @@ Based on this stackoverflow answer: https://stackoverflow.com/a/36113168/300783 -Which was updated for python3 in: +Which I updated for python3 in: https://gist.github.com/tfeldmann/fc875e6630d11f2256e746f67a09c1ae - -The script on stackoverflow has a bug which could lead to false positives. This is fixed -here by using a tuple (file_size, hash) as key in the comparison dictionaries. """ import hashlib import os @@ -18,7 +15,7 @@ from organize.utils import fullpath -from . import Filter +from .filter import Filter def chunk_reader(fobj, chunk_size=1024): diff --git a/organize/filters/exif.py b/organize/filters/exif.py index 0fc58b02..4e98d32b 100644 --- a/organize/filters/exif.py +++ b/organize/filters/exif.py @@ -5,7 +5,7 @@ from pathlib import Path -from . import Filter +from .filter import Filter ExifDict = Mapping[str, Union[str, Mapping[str, str]]] diff --git a/organize/filters/extension.py b/organize/filters/extension.py index dbf019ea..5539d6f6 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -3,7 +3,7 @@ from pathlib import Path from organize.utils import flatten -from . import Filter +from .filter import Filter class ExtensionResult: diff --git a/organize/filters/filecontent.py b/organize/filters/filecontent.py index 364edbfc..6886f91d 100644 --- a/organize/filters/filecontent.py +++ b/organize/filters/filecontent.py @@ -3,7 +3,7 @@ from fs.errors import NoSysPath -from . import Filter +from .filter import Filter SUPPORTED_EXTENSIONS = ( # not supported: .gif, .jpg, .mp3, .ogg, .png, .tiff, .wav diff --git a/organize/filters/hash.py b/organize/filters/hash.py index be590779..068a78c7 100644 --- a/organize/filters/hash.py +++ b/organize/filters/hash.py @@ -4,7 +4,7 @@ from organize.utils import JinjaEnv -from . import Filter +from .filter import Filter logger = logging.getLogger(__name__) @@ -15,19 +15,27 @@ class Hash(Filter): Calculates the hash of a file. Args: - algorithm (str) - Any hashing algorithm available to python's `hashlib`. + algorithm (str): Any hashing algorithm available to python's `hashlib`. + `md5` by default. - List the available algorithms on your installation: + Algorithms guaranteed to be available are + `shake_256`, `sha3_256`, `sha1`, `sha3_224`, `sha384`, `sha512`, `blake2b`, + `blake2s`, `sha256`, `sha224`, `shake_128`, `sha3_512`, `sha3_384` and `md5`. - >>> import hashlib - >>> hashlib.algorithms_guaranteed - {'shake_256', 'sha3_256', 'sha1', 'sha3_224', 'sha384', 'sha512', 'blake2b', 'blake2s', 'sha256', 'sha224', 'shake_128', 'sha3_512', 'sha3_384', 'md5'} - >>> hashlib.algorithms_available - {'shake_256', 'whirlpool', 'mdc2', 'blake2s', 'sha224', 'shake_128', 'sha3_512', 'sha3_224', 'sha384', 'md5', 'sha1', 'sha512_256', 'blake2b', 'sha256', 'sha512_224', 'ripemd160', 'sha3_384', 'md4', 'sm3', 'sha3_256', 'md5-sha1', 'sha512'} + Depending on your python installation and installed libs there may be additional + hash algorithms to chose from. + + To list the available algorithms on your installation run this in a python + interpreter: + + ```py + >>> import hashlib + >>> hashlib.algorithms_available + {'shake_256', 'whirlpool', 'mdc2', 'blake2s', 'sha224', 'shake_128', 'sha3_512', 'sha3_224', 'sha384', 'md5', 'sha1', 'sha512_256', 'blake2b', 'sha256', 'sha512_224', 'ripemd160', 'sha3_384', 'md4', 'sm3', 'sha3_256', 'md5-sha1', 'sha512'} + ``` Returns: - {"hash": str} where str is the hash of the file. + str: The hash of the file. """ name = "hash" diff --git a/organize/filters/lastmodified.py b/organize/filters/lastmodified.py index 2296ac38..1cc3f39d 100644 --- a/organize/filters/lastmodified.py +++ b/organize/filters/lastmodified.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from typing import Dict, Optional as tyOptional -from . import Filter +from .filter import Filter class LastModified(Filter): diff --git a/organize/filters/mimetype.py b/organize/filters/mimetype.py index 1518cc54..266ff7da 100644 --- a/organize/filters/mimetype.py +++ b/organize/filters/mimetype.py @@ -2,7 +2,7 @@ from organize.utils import flatten -from . import Filter +from .filter import Filter class MimeType(Filter): diff --git a/organize/filters/name.py b/organize/filters/name.py index 575cc45d..3965a7a2 100644 --- a/organize/filters/name.py +++ b/organize/filters/name.py @@ -3,7 +3,7 @@ import simplematch # type: ignore from fs import path -from . import Filter +from .filter import Filter class Name(Filter): diff --git a/organize/filters/python.py b/organize/filters/python.py index 450c84cc..15ecfeaf 100644 --- a/organize/filters/python.py +++ b/organize/filters/python.py @@ -1,7 +1,7 @@ import textwrap from typing import Any, Dict, Optional, Sequence -from . import Filter +from .filter import Filter class Python(Filter): diff --git a/organize/filters/regex.py b/organize/filters/regex.py index f47b1010..7816aef3 100644 --- a/organize/filters/regex.py +++ b/organize/filters/regex.py @@ -1,7 +1,7 @@ import re from typing import Any, Dict, Mapping, Optional -from . import Filter +from .filter import Filter class Regex(Filter): diff --git a/organize/filters/size.py b/organize/filters/size.py index 24c0988b..3e3d8606 100644 --- a/organize/filters/size.py +++ b/organize/filters/size.py @@ -9,7 +9,7 @@ from organize.utils import flattened_string_list, fullpath -from . import Filter +from .filter import Filter OPERATORS = { "<": operator.lt, @@ -133,7 +133,7 @@ def pipeline(self, args: dict) -> Opt[Dict[str, Dict[str, int]]]: for _, info in fs.walk.info(path=fs_path, namespaces=["details"]) ) else: - size = fs.getinfo(fs_path, namespaces=["details"]).size + size = fs.getsize(fs_path) if self.matches(size): return { self.name: { diff --git a/poetry.lock b/poetry.lock index c95554cb..08865b77 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,11 +1,3 @@ -[[package]] -name = "alabaster" -version = "0.7.12" -description = "A configurable sidebar-enabled Sphinx theme" -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "appdirs" version = "1.4.4" @@ -14,14 +6,6 @@ category = "main" optional = false python-versions = "*" -[[package]] -name = "appnope" -version = "0.1.2" -description = "Disable App Nap on macOS >= 10.9" -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "argcomplete" version = "1.10.3" @@ -34,18 +18,15 @@ python-versions = "*" test = ["coverage", "flake8", "pexpect", "wheel"] [[package]] -name = "astroid" -version = "2.9.3" -description = "An abstract syntax tree for Python with inference support." +name = "astunparse" +version = "1.6.3" +description = "An AST unparser for Python" category = "dev" optional = false -python-versions = ">=3.6.2" +python-versions = "*" [package.dependencies] -lazy-object-proxy = ">=1.4.0" -typed-ast = {version = ">=1.4.0,<2.0", markers = "implementation_name == \"cpython\" and python_version < \"3.8\""} -typing-extensions = {version = ">=3.10", markers = "python_version < \"3.10\""} -wrapt = ">=1.11,<1.14" +six = ">=1.6.1,<2.0" [[package]] name = "atomicwrites" @@ -69,25 +50,6 @@ docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] -[[package]] -name = "babel" -version = "2.9.1" -description = "Internationalization utilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.dependencies] -pytz = ">=2015.7" - -[[package]] -name = "backcall" -version = "0.2.0" -description = "Specifications for callback functions passed in to an API" -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "backports.zoneinfo" version = "0.2.1" @@ -118,9 +80,9 @@ html5lib = ["html5lib"] lxml = ["lxml"] [[package]] -name = "certifi" -version = "2021.10.8" -description = "Python package for providing Mozilla's CA Bundle." +name = "cached-property" +version = "1.5.2" +description = "A decorator for caching properties in classes." category = "dev" optional = false python-versions = "*" @@ -145,15 +107,16 @@ optional = true python-versions = "*" [[package]] -name = "charset-normalizer" -version = "2.0.10" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +name = "click" +version = "8.0.3" +description = "Composable command line interface toolkit" category = "dev" optional = false -python-versions = ">=3.5.0" +python-versions = ">=3.6" -[package.extras] -unicode_backport = ["unicodedata2"] +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} +importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "colorama" @@ -198,14 +161,6 @@ category = "main" optional = false python-versions = ">=3.6, <3.7" -[[package]] -name = "decorator" -version = "5.1.1" -description = "Decorators for Humans" -category = "dev" -optional = false -python-versions = ">=3.5" - [[package]] name = "docopt" version = "0.6.2" @@ -214,14 +169,6 @@ category = "main" optional = false python-versions = "*" -[[package]] -name = "docutils" -version = "0.16" -description = "Docutils -- Python Documentation Utilities" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - [[package]] name = "docx2txt" version = "0.8" @@ -273,20 +220,6 @@ imapclient = "2.1.0" olefile = ">=0.46" tzlocal = ">=2.1" -[[package]] -name = "flake8" -version = "3.9.2" -description = "the modular source code checker: pep8 pyflakes and co" -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" - -[package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} -mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.7.0,<2.8.0" -pyflakes = ">=2.3.0,<2.4.0" - [[package]] name = "fs" version = "2.4.14" @@ -304,20 +237,18 @@ six = ">=1.10,<2.0" scandir = ["scandir (>=1.5,<2.0)"] [[package]] -name = "idna" -version = "3.3" -description = "Internationalized Domain Names in Applications (IDNA)" +name = "ghp-import" +version = "2.0.2" +description = "Copy your docs directly to the gh-pages branch." category = "dev" optional = false -python-versions = ">=3.5" +python-versions = "*" -[[package]] -name = "imagesize" -version = "1.3.0" -description = "Getting image size from png/jpeg/jpeg2000/gif file" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +[package.dependencies] +python-dateutil = ">=2.8.1" + +[package.extras] +dev = ["twine", "markdown", "flake8", "wheel"] [[package]] name = "imapclient" @@ -367,89 +298,18 @@ docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "pytest-black (>=0.3.7)", "pytest-mypy"] [[package]] -name = "ipdb" -version = "0.12.3" -description = "IPython-enabled pdb" -category = "dev" -optional = false -python-versions = ">=2.7" - -[package.dependencies] -ipython = {version = ">=5.1.0", markers = "python_version >= \"3.4\""} - -[[package]] -name = "ipython" -version = "7.16.3" -description = "IPython: Productive Interactive Computing" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -appnope = {version = "*", markers = "sys_platform == \"darwin\""} -backcall = "*" -colorama = {version = "*", markers = "sys_platform == \"win32\""} -decorator = "*" -jedi = ">=0.10,<=0.17.2" -pexpect = {version = "*", markers = "sys_platform != \"win32\""} -pickleshare = "*" -prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" -pygments = "*" -traitlets = ">=4.2" - -[package.extras] -all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.14)", "pygments", "qtconsole", "requests", "testpath"] -doc = ["Sphinx (>=1.3)"] -kernel = ["ipykernel"] -nbconvert = ["nbconvert"] -nbformat = ["nbformat"] -notebook = ["notebook", "ipywidgets"] -parallel = ["ipyparallel"] -qtconsole = ["qtconsole"] -test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.14)"] - -[[package]] -name = "ipython-genutils" -version = "0.2.0" -description = "Vestigial utilities from IPython" +name = "iniconfig" +version = "1.1.1" +description = "iniconfig: brain-dead simple config-ini parsing" category = "dev" optional = false python-versions = "*" -[[package]] -name = "isort" -version = "5.10.1" -description = "A Python utility / library to sort Python imports." -category = "dev" -optional = false -python-versions = ">=3.6.1,<4.0" - -[package.extras] -pipfile_deprecated_finder = ["pipreqs", "requirementslib"] -requirements_deprecated_finder = ["pipreqs", "pip-api"] -colors = ["colorama (>=0.4.3,<0.5.0)"] -plugins = ["setuptools"] - -[[package]] -name = "jedi" -version = "0.17.2" -description = "An autocompletion tool for Python that can be used for text editors." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" - -[package.dependencies] -parso = ">=0.7.0,<0.8.0" - -[package.extras] -qa = ["flake8 (==3.7.9)"] -testing = ["Django (<3.1)", "colorama", "docopt", "pytest (>=3.9.0,<5.0.0)"] - [[package]] name = "jinja2" version = "3.0.3" description = "A very fast and expressive template engine." -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -459,14 +319,6 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] -[[package]] -name = "lazy-object-proxy" -version = "1.7.1" -description = "A fast and thorough lazy object proxy." -category = "dev" -optional = false -python-versions = ">=3.6" - [[package]] name = "lxml" version = "4.7.1" @@ -494,43 +346,27 @@ mdfind-wrapper = ">=0.1.3,<0.2.0" xattr = ">=0.9.7,<0.10.0" [[package]] -name = "markdown-it-py" -version = "2.0.0" -description = "Python port of markdown-it. Markdown parsing, done right!" +name = "markdown" +version = "3.3.6" +description = "Python implementation of Markdown." category = "dev" optional = false -python-versions = "~=3.6" +python-versions = ">=3.6" [package.dependencies] -attrs = ">=19,<22" -mdurl = ">=0.1,<1.0" -typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} [package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark (>=3.2,<4.0)"] -code_style = ["pre-commit (==2.6)"] -compare = ["commonmark (>=0.9.1,<0.10.0)", "markdown (>=3.2.2,<3.3.0)", "mistletoe-ebp (>=0.10.0,<0.11.0)", "mistune (>=0.8.4,<0.9.0)", "panflute (>=1.12,<2.0)"] -linkify = ["linkify-it-py (>=1.0,<2.0)"] -plugins = ["mdit-py-plugins"] -rtd = ["myst-nb (==0.13.0a1)", "pyyaml", "sphinx (>=2,<4)", "sphinx-copybutton", "sphinx-panels (>=0.4.0,<0.5.0)", "sphinx-book-theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] +testing = ["coverage", "pyyaml"] [[package]] name = "markupsafe" version = "2.0.1" description = "Safely add untrusted strings to HTML/XML markup." -category = "dev" +category = "main" optional = false python-versions = ">=3.6" -[[package]] -name = "mccabe" -version = "0.6.1" -description = "McCabe checker, plugin for flake8" -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "mdfind-wrapper" version = "0.1.5" @@ -540,36 +376,76 @@ optional = false python-versions = ">=3.6" [[package]] -name = "mdit-py-plugins" -version = "0.3.0" -description = "Collection of plugins for markdown-it-py" +name = "mergedeep" +version = "1.3.4" +description = "A deep merge function for 🐍." category = "dev" optional = false -python-versions = "~=3.6" +python-versions = ">=3.6" + +[[package]] +name = "mkdocs" +version = "1.2.3" +description = "Project documentation with Markdown." +category = "dev" +optional = false +python-versions = ">=3.6" [package.dependencies] -markdown-it-py = ">=1.0.0,<3.0.0" +click = ">=3.3" +ghp-import = ">=1.0" +importlib-metadata = ">=3.10" +Jinja2 = ">=2.10.1" +Markdown = ">=3.2.1" +mergedeep = ">=1.3.4" +packaging = ">=20.5" +PyYAML = ">=3.10" +pyyaml-env-tag = ">=0.1" +watchdog = ">=2.0" [package.extras] -code_style = ["pre-commit (==2.6)"] -rtd = ["myst-parser (>=0.14.0,<0.15.0)", "sphinx-book-theme (>=0.1.0,<0.2.0)"] -testing = ["coverage", "pytest (>=3.6,<4)", "pytest-cov", "pytest-regressions"] +i18n = ["babel (>=2.9.0)"] + +[[package]] +name = "mkdocs-autorefs" +version = "0.3.1" +description = "Automatically link across pages in MkDocs." +category = "dev" +optional = false +python-versions = ">=3.6.2,<4.0.0" + +[package.dependencies] +Markdown = ">=3.3,<4.0" +mkdocs = ">=1.1,<2.0" [[package]] -name = "mdurl" -version = "0.1.0" -description = "Markdown URL utilities" +name = "mkdocs-include-markdown-plugin" +version = "3.2.3" +description = "Mkdocs Markdown includer plugin." category = "dev" optional = false python-versions = ">=3.6" +[package.extras] +dev = ["bump2version (==1.0.1)", "flake8 (==3.9.2)", "flake8-implicit-str-concat (==0.2.0)", "flake8-print (==4.0.0)", "isort (==5.9.1)", "mdpo (==0.3.61)", "pre-commit (==2.13.0)", "pytest (==6.2.4)", "pytest-cov (==2.12.1)", "pyupgrade (==2.19.4)", "yamllint (==1.26.1)"] +test = ["pytest (==6.2.4)", "pytest-cov (==2.12.1)"] + [[package]] -name = "more-itertools" -version = "8.12.0" -description = "More routines for operating on iterables, beyond itertools" +name = "mkdocstrings" +version = "0.17.0" +description = "Automatic documentation from sources, for MkDocs." category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6.2" + +[package.dependencies] +Jinja2 = ">=2.11.1" +Markdown = ">=3.3" +MarkupSafe = ">=1.1" +mkdocs = ">=1.2" +mkdocs-autorefs = ">=0.1" +pymdown-extensions = ">=6.3" +pytkdocs = ">=0.14.0" [[package]] name = "mypy" @@ -595,28 +471,6 @@ category = "dev" optional = false python-versions = "*" -[[package]] -name = "myst-parser" -version = "0.16.1" -description = "An extended commonmark compliant parser, with bridges to docutils & sphinx." -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -docutils = ">=0.15,<0.18" -jinja2 = "*" -markdown-it-py = ">=1.0.0,<3.0.0" -mdit-py-plugins = ">=0.3.0,<0.4.0" -pyyaml = "*" -sphinx = ">=3.1,<5" - -[package.extras] -code_style = ["pre-commit (>=2.12,<3.0)"] -linkify = ["linkify-it-py (>=1.0,<2.0)"] -rtd = ["ipython", "sphinx-book-theme (>=0.1.0,<0.2.0)", "sphinx-panels (>=0.5.2,<0.6.0)", "sphinxcontrib-bibtex (>=2.1,<3.0)", "sphinxext-rediraffe (>=0.2,<1.0)", "sphinxcontrib.mermaid (>=0.6.3,<0.7.0)", "sphinxext-opengraph (>=0.4.2,<0.5.0)"] -testing = ["beautifulsoup4", "coverage", "docutils (>=0.17.0,<0.18.0)", "pytest (>=3.6,<4)", "pytest-cov", "pytest-regressions"] - [[package]] name = "olefile" version = "0.46" @@ -636,17 +490,6 @@ python-versions = ">=3.6" [package.dependencies] pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" -[[package]] -name = "parso" -version = "0.7.1" -description = "A Python Parser" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.extras] -testing = ["docopt", "pytest (>=3.0.7)"] - [[package]] name = "pdfminer.six" version = "20191110" @@ -665,25 +508,6 @@ sortedcontainers = "*" dev = ["nose", "tox"] docs = ["sphinx", "sphinx-argparse"] -[[package]] -name = "pexpect" -version = "4.8.0" -description = "Pexpect allows easy control of interactive console applications." -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -ptyprocess = ">=0.5" - -[[package]] -name = "pickleshare" -version = "0.7.5" -description = "Tiny 'shelve'-like database with concurrency support" -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "pillow" version = "8.4.0" @@ -692,50 +516,20 @@ category = "main" optional = true python-versions = ">=3.6" -[[package]] -name = "platformdirs" -version = "2.4.0" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.extras] -docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] -test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)"] - [[package]] name = "pluggy" -version = "0.13.1" +version = "1.0.0" description = "plugin and hook calling mechanisms for python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] dev = ["pre-commit", "tox"] - -[[package]] -name = "prompt-toolkit" -version = "3.0.24" -description = "Library for building powerful interactive command lines in Python" -category = "dev" -optional = false -python-versions = ">=3.6.2" - -[package.dependencies] -wcwidth = "*" - -[[package]] -name = "ptyprocess" -version = "0.7.0" -description = "Run a subprocess in a pseudo terminal" -category = "dev" -optional = false -python-versions = "*" +testing = ["pytest", "pytest-benchmark"] [[package]] name = "py" @@ -745,14 +539,6 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -[[package]] -name = "pycodestyle" -version = "2.7.0" -description = "Python style guide checker" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - [[package]] name = "pycparser" version = "2.21" @@ -763,20 +549,12 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pycryptodome" -version = "3.12.0" +version = "3.13.0" description = "Cryptographic library for Python" category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -[[package]] -name = "pyflakes" -version = "2.3.1" -description = "passive checker of Python programs" -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - [[package]] name = "pygments" version = "2.11.2" @@ -786,25 +564,19 @@ optional = false python-versions = ">=3.5" [[package]] -name = "pylint" -version = "2.12.2" -description = "python code static checker" +name = "pymdown-extensions" +version = "9.1" +description = "Extension pack for Python Markdown." category = "dev" optional = false -python-versions = ">=3.6.2" +python-versions = ">=3.6" [package.dependencies] -astroid = ">=2.9.0,<2.10" -colorama = {version = "*", markers = "sys_platform == \"win32\""} -isort = ">=4.2.5,<6" -mccabe = ">=0.6,<0.7" -platformdirs = ">=2.2.0" -toml = ">=0.9.2" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} +Markdown = ">=3.2" [[package]] name = "pyparsing" -version = "3.0.6" +version = "3.0.7" description = "Python parsing module" category = "dev" optional = false @@ -815,26 +587,36 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "4.6.11" +version = "6.2.5" description = "pytest: simple powerful testing with Python" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +python-versions = ">=3.6" [package.dependencies] -atomicwrites = ">=1.0" -attrs = ">=17.4.0" -colorama = {version = "*", markers = "sys_platform == \"win32\" and python_version != \"3.4\""} +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -more-itertools = {version = ">=4.0.0", markers = "python_version > \"2.7\""} +iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<1.0" -py = ">=1.5.0" -six = ">=1.10.0" -wcwidth = "*" +pluggy = ">=0.12,<2.0" +py = ">=1.8.2" +toml = "*" [package.extras] -testing = ["argcomplete", "hypothesis (>=3.56)", "nose", "requests", "mock"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "dev" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" [[package]] name = "python-pptx" @@ -849,6 +631,23 @@ lxml = ">=3.1.0" Pillow = ">=3.3.2" XlsxWriter = ">=0.5.7" +[[package]] +name = "pytkdocs" +version = "0.15.0" +description = "Load Python objects documentation." +category = "dev" +optional = false +python-versions = ">=3.6.2" + +[package.dependencies] +astunparse = {version = ">=1.6", markers = "python_version < \"3.9\""} +cached-property = {version = ">=1.5", markers = "python_version < \"3.8\""} +dataclasses = {version = ">=0.7", markers = "python_version < \"3.7\""} +typing-extensions = {version = ">=3.7", markers = "python_version < \"3.8\""} + +[package.extras] +numpy-style = ["docstring_parser (>=0.7)"] + [[package]] name = "pytz" version = "2021.3" @@ -878,22 +677,15 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" [[package]] -name = "requests" -version = "2.27.1" -description = "Python HTTP for Humans." +name = "pyyaml-env-tag" +version = "0.1" +description = "A custom YAML tag for referencing environment variables in YAML files. " category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.6" [package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} -idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} -urllib3 = ">=1.21.1,<1.27" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] -use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] +pyyaml = "*" [[package]] name = "rich" @@ -953,14 +745,6 @@ category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*" -[[package]] -name = "snowballstemmer" -version = "2.2.0" -description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "sortedcontainers" version = "2.4.0" @@ -985,123 +769,6 @@ category = "main" optional = true python-versions = "*" -[[package]] -name = "sphinx" -version = "3.5.4" -description = "Python documentation generator" -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.dependencies] -alabaster = ">=0.7,<0.8" -babel = ">=1.3" -colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.12,<0.17" -imagesize = "*" -Jinja2 = ">=2.3" -packaging = "*" -Pygments = ">=2.0" -requests = ">=2.5.0" -snowballstemmer = ">=1.1" -sphinxcontrib-applehelp = "*" -sphinxcontrib-devhelp = "*" -sphinxcontrib-htmlhelp = "*" -sphinxcontrib-jsmath = "*" -sphinxcontrib-qthelp = "*" -sphinxcontrib-serializinghtml = "*" - -[package.extras] -docs = ["sphinxcontrib-websupport"] -lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.800)", "docutils-stubs"] -test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] - -[[package]] -name = "sphinx-rtd-theme" -version = "0.5.2" -description = "Read the Docs theme for Sphinx" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -docutils = "<0.17" -sphinx = "*" - -[package.extras] -dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version"] - -[[package]] -name = "sphinxcontrib-applehelp" -version = "1.0.2" -description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "1.0.2" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.0.0" -description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest", "html5lib"] - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -description = "A sphinx extension which renders display math in HTML via JavaScript" -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.extras] -test = ["pytest", "flake8", "mypy"] - -[[package]] -name = "sphinxcontrib-qthelp" -version = "1.0.3" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-serializinghtml" -version = "1.1.5" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -category = "dev" -optional = false -python-versions = ">=3.5" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - [[package]] name = "textract" version = "1.6.4" @@ -1134,22 +801,6 @@ category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -[[package]] -name = "traitlets" -version = "4.3.3" -description = "Traitlets Python config system" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -decorator = "*" -ipython-genutils = "*" -six = "*" - -[package.extras] -test = ["pytest", "mock"] - [[package]] name = "typed-ast" version = "1.4.3" @@ -1192,33 +843,15 @@ devenv = ["black", "pyroma", "pytest-cov", "zest.releaser"] test = ["pytest-mock (>=3.3)", "pytest (>=4.3)"] [[package]] -name = "urllib3" -version = "1.26.8" -description = "HTTP library with thread-safe connection pooling, file post, and more." +name = "watchdog" +version = "2.1.6" +description = "Filesystem events monitoring" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +python-versions = ">=3.6" [package.extras] -brotli = ["brotlipy (>=0.6.0)"] -secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] - -[[package]] -name = "wcwidth" -version = "0.2.5" -description = "Measures the displayed width of unicode strings in a terminal" -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "wrapt" -version = "1.13.3" -description = "Module for decorators, wrappers and monkey patching." -category = "dev" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +watchmedo = ["PyYAML (>=3.10)"] [[package]] name = "xattr" @@ -1265,28 +898,20 @@ textract = ["textract"] [metadata] lock-version = "1.1" python-versions = "^3.6.2" -content-hash = "623aaaf69ce3f4df54a009e645279380156cff816725c54898c15dae3618c1a3" +content-hash = "84bc287638a9d200ca09bf546aadc347560c351e6e7d4c1b8109143355a05db5" [metadata.files] -alabaster = [ - {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, - {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, -] appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] -appnope = [ - {file = "appnope-0.1.2-py2.py3-none-any.whl", hash = "sha256:93aa393e9d6c54c5cd570ccadd8edad61ea0c4b9ea7a01409020c9aa019eb442"}, - {file = "appnope-0.1.2.tar.gz", hash = "sha256:dd83cd4b5b460958838f6eb3000c660b1f9caf2a5b1de4264e941512f603258a"}, -] argcomplete = [ {file = "argcomplete-1.10.3-py2.py3-none-any.whl", hash = "sha256:d8ea63ebaec7f59e56e7b2a386b1d1c7f1a7ae87902c9ee17d377eaa557f06fa"}, {file = "argcomplete-1.10.3.tar.gz", hash = "sha256:a37f522cf3b6a34abddfedb61c4546f60023b3799b22d1cd971eacdc0861530a"}, ] -astroid = [ - {file = "astroid-2.9.3-py3-none-any.whl", hash = "sha256:506daabe5edffb7e696ad82483ad0228245a9742ed7d2d8c9cdb31537decf9f6"}, - {file = "astroid-2.9.3.tar.gz", hash = "sha256:1efdf4e867d4d8ba4a9f6cf9ce07cd182c4c41de77f23814feb27ca93ca9d877"}, +astunparse = [ + {file = "astunparse-1.6.3-py2.py3-none-any.whl", hash = "sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8"}, + {file = "astunparse-1.6.3.tar.gz", hash = "sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872"}, ] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, @@ -1296,14 +921,6 @@ attrs = [ {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] -babel = [ - {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, - {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, -] -backcall = [ - {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, - {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, -] "backports.zoneinfo" = [ {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"}, {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"}, @@ -1327,9 +944,9 @@ beautifulsoup4 = [ {file = "beautifulsoup4-4.8.2-py3-none-any.whl", hash = "sha256:9fbb4d6e48ecd30bcacc5b63b94088192dcda178513b2ae3c394229f8911b887"}, {file = "beautifulsoup4-4.8.2.tar.gz", hash = "sha256:05fd825eb01c290877657a56df4c6e4c311b3965bda790c613a3d6fb01a5462a"}, ] -certifi = [ - {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, - {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, +cached-property = [ + {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, + {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"}, ] cffi = [ {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, @@ -1387,9 +1004,9 @@ chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, ] -charset-normalizer = [ - {file = "charset-normalizer-2.0.10.tar.gz", hash = "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd"}, - {file = "charset_normalizer-2.0.10-py3-none-any.whl", hash = "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"}, +click = [ + {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, + {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -1410,17 +1027,9 @@ dataclasses = [ {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, ] -decorator = [ - {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, - {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, -] docopt = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, ] -docutils = [ - {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, - {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, -] docx2txt = [ {file = "docx2txt-0.8.tar.gz", hash = "sha256:2c06d98d7cfe2d3947e5760a57d924e3ff07745b379c8737723922e7009236e5"}, ] @@ -1438,21 +1047,13 @@ extract-msg = [ {file = "extract_msg-0.29.0-py2.py3-none-any.whl", hash = "sha256:a8885dc385d0c88c4b87fb2a573727c0115cd2ef5157956cf183878f940eef28"}, {file = "extract_msg-0.29.0.tar.gz", hash = "sha256:ae6ce5f78fddb582350cb49bbf2776eadecdbf3c74b7a305dced42bd187a5401"}, ] -flake8 = [ - {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, - {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, -] fs = [ {file = "fs-2.4.14-py2.py3-none-any.whl", hash = "sha256:b298013377f51125b3d7f0c86920de4e3e2d4a83731bd5caf1f1e5bddabe7798"}, {file = "fs-2.4.14.tar.gz", hash = "sha256:9555dc2bc58c58cac03478ac7e9f622d29fe2d20a4384c24c90ab50de2c7b36c"}, ] -idna = [ - {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, - {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, -] -imagesize = [ - {file = "imagesize-1.3.0-py2.py3-none-any.whl", hash = "sha256:1db2f82529e53c3e929e8926a1fa9235aa82d0bd0c580359c67ec31b2fddaa8c"}, - {file = "imagesize-1.3.0.tar.gz", hash = "sha256:cd1750d452385ca327479d45b64d9c7729ecf0b3969a58148298c77092261f9d"}, +ghp-import = [ + {file = "ghp-import-2.0.2.tar.gz", hash = "sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071"}, + {file = "ghp_import-2.0.2-py3-none-any.whl", hash = "sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46"}, ] imapclient = [ {file = "IMAPClient-2.1.0-py2.py3-none-any.whl", hash = "sha256:3eeb97b9aa8faab0caa5024d74bfde59408fbd542781246f6960873c7bf0dd01"}, @@ -1466,68 +1067,14 @@ importlib-resources = [ {file = "importlib_resources-5.4.0-py3-none-any.whl", hash = "sha256:33a95faed5fc19b4bc16b29a6eeae248a3fe69dd55d4d229d2b480e23eeaad45"}, {file = "importlib_resources-5.4.0.tar.gz", hash = "sha256:d756e2f85dd4de2ba89be0b21dba2a3bbec2e871a42a3a16719258a11f87506b"}, ] -ipdb = [ - {file = "ipdb-0.12.3.tar.gz", hash = "sha256:5d9a4a0e3b7027a158fc6f2929934341045b9c3b0b86ed5d7e84e409653f72fd"}, -] -ipython = [ - {file = "ipython-7.16.3-py3-none-any.whl", hash = "sha256:c0427ed8bc33ac481faf9d3acf7e84e0010cdaada945e0badd1e2e74cc075833"}, - {file = "ipython-7.16.3.tar.gz", hash = "sha256:5ac47dc9af66fc2f5530c12069390877ae372ac905edca75a92a6e363b5d7caa"}, -] -ipython-genutils = [ - {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, - {file = "ipython_genutils-0.2.0.tar.gz", hash = "sha256:eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"}, -] -isort = [ - {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, - {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, -] -jedi = [ - {file = "jedi-0.17.2-py2.py3-none-any.whl", hash = "sha256:98cc583fa0f2f8304968199b01b6b4b94f469a1f4a74c1560506ca2a211378b5"}, - {file = "jedi-0.17.2.tar.gz", hash = "sha256:86ed7d9b750603e4ba582ea8edc678657fb4007894a12bcf6f4bb97892f31d20"}, +iniconfig = [ + {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, + {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] jinja2 = [ {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, ] -lazy-object-proxy = [ - {file = "lazy-object-proxy-1.7.1.tar.gz", hash = "sha256:d609c75b986def706743cdebe5e47553f4a5a1da9c5ff66d76013ef396b5a8a4"}, - {file = "lazy_object_proxy-1.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb8c5fd1684d60a9902c60ebe276da1f2281a318ca16c1d0a96db28f62e9166b"}, - {file = "lazy_object_proxy-1.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a57d51ed2997e97f3b8e3500c984db50a554bb5db56c50b5dab1b41339b37e36"}, - {file = "lazy_object_proxy-1.7.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd45683c3caddf83abbb1249b653a266e7069a09f486daa8863fb0e7496a9fdb"}, - {file = "lazy_object_proxy-1.7.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8561da8b3dd22d696244d6d0d5330618c993a215070f473b699e00cf1f3f6443"}, - {file = "lazy_object_proxy-1.7.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fccdf7c2c5821a8cbd0a9440a456f5050492f2270bd54e94360cac663398739b"}, - {file = "lazy_object_proxy-1.7.1-cp310-cp310-win32.whl", hash = "sha256:898322f8d078f2654d275124a8dd19b079080ae977033b713f677afcfc88e2b9"}, - {file = "lazy_object_proxy-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:85b232e791f2229a4f55840ed54706110c80c0a210d076eee093f2b2e33e1bfd"}, - {file = "lazy_object_proxy-1.7.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:46ff647e76f106bb444b4533bb4153c7370cdf52efc62ccfc1a28bdb3cc95442"}, - {file = "lazy_object_proxy-1.7.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12f3bb77efe1367b2515f8cb4790a11cffae889148ad33adad07b9b55e0ab22c"}, - {file = "lazy_object_proxy-1.7.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c19814163728941bb871240d45c4c30d33b8a2e85972c44d4e63dd7107faba44"}, - {file = "lazy_object_proxy-1.7.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:e40f2013d96d30217a51eeb1db28c9ac41e9d0ee915ef9d00da639c5b63f01a1"}, - {file = "lazy_object_proxy-1.7.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:2052837718516a94940867e16b1bb10edb069ab475c3ad84fd1e1a6dd2c0fcfc"}, - {file = "lazy_object_proxy-1.7.1-cp36-cp36m-win32.whl", hash = "sha256:6a24357267aa976abab660b1d47a34aaf07259a0c3859a34e536f1ee6e76b5bb"}, - {file = "lazy_object_proxy-1.7.1-cp36-cp36m-win_amd64.whl", hash = "sha256:6aff3fe5de0831867092e017cf67e2750c6a1c7d88d84d2481bd84a2e019ec35"}, - {file = "lazy_object_proxy-1.7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6a6e94c7b02641d1311228a102607ecd576f70734dc3d5e22610111aeacba8a0"}, - {file = "lazy_object_proxy-1.7.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4ce15276a1a14549d7e81c243b887293904ad2d94ad767f42df91e75fd7b5b6"}, - {file = "lazy_object_proxy-1.7.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e368b7f7eac182a59ff1f81d5f3802161932a41dc1b1cc45c1f757dc876b5d2c"}, - {file = "lazy_object_proxy-1.7.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6ecbb350991d6434e1388bee761ece3260e5228952b1f0c46ffc800eb313ff42"}, - {file = "lazy_object_proxy-1.7.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:553b0f0d8dbf21890dd66edd771f9b1b5f51bd912fa5f26de4449bfc5af5e029"}, - {file = "lazy_object_proxy-1.7.1-cp37-cp37m-win32.whl", hash = "sha256:c7a683c37a8a24f6428c28c561c80d5f4fd316ddcf0c7cab999b15ab3f5c5c69"}, - {file = "lazy_object_proxy-1.7.1-cp37-cp37m-win_amd64.whl", hash = "sha256:df2631f9d67259dc9620d831384ed7732a198eb434eadf69aea95ad18c587a28"}, - {file = "lazy_object_proxy-1.7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07fa44286cda977bd4803b656ffc1c9b7e3bc7dff7d34263446aec8f8c96f88a"}, - {file = "lazy_object_proxy-1.7.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dca6244e4121c74cc20542c2ca39e5c4a5027c81d112bfb893cf0790f96f57e"}, - {file = "lazy_object_proxy-1.7.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91ba172fc5b03978764d1df5144b4ba4ab13290d7bab7a50f12d8117f8630c38"}, - {file = "lazy_object_proxy-1.7.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:043651b6cb706eee4f91854da4a089816a6606c1428fd391573ef8cb642ae4f7"}, - {file = "lazy_object_proxy-1.7.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b9e89b87c707dd769c4ea91f7a31538888aad05c116a59820f28d59b3ebfe25a"}, - {file = "lazy_object_proxy-1.7.1-cp38-cp38-win32.whl", hash = "sha256:9d166602b525bf54ac994cf833c385bfcc341b364e3ee71e3bf5a1336e677b55"}, - {file = "lazy_object_proxy-1.7.1-cp38-cp38-win_amd64.whl", hash = "sha256:8f3953eb575b45480db6568306893f0bd9d8dfeeebd46812aa09ca9579595148"}, - {file = "lazy_object_proxy-1.7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dd7ed7429dbb6c494aa9bc4e09d94b778a3579be699f9d67da7e6804c422d3de"}, - {file = "lazy_object_proxy-1.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70ed0c2b380eb6248abdef3cd425fc52f0abd92d2b07ce26359fcbc399f636ad"}, - {file = "lazy_object_proxy-1.7.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7096a5e0c1115ec82641afbdd70451a144558ea5cf564a896294e346eb611be1"}, - {file = "lazy_object_proxy-1.7.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f769457a639403073968d118bc70110e7dce294688009f5c24ab78800ae56dc8"}, - {file = "lazy_object_proxy-1.7.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:39b0e26725c5023757fc1ab2a89ef9d7ab23b84f9251e28f9cc114d5b59c1b09"}, - {file = "lazy_object_proxy-1.7.1-cp39-cp39-win32.whl", hash = "sha256:2130db8ed69a48a3440103d4a520b89d8a9405f1b06e2cc81640509e8bf6548f"}, - {file = "lazy_object_proxy-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:677ea950bef409b47e51e733283544ac3d660b709cfce7b187f5ace137960d61"}, - {file = "lazy_object_proxy-1.7.1-pp37.pp38-none-any.whl", hash = "sha256:d66906d5785da8e0be7360912e99c9188b70f52c422f9fc18223347235691a84"}, -] lxml = [ {file = "lxml-4.7.1-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:d546431636edb1d6a608b348dd58cc9841b81f4116745857b6cb9f8dadb2725f"}, {file = "lxml-4.7.1-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6308062534323f0d3edb4e702a0e26a76ca9e0e23ff99be5d82750772df32a9e"}, @@ -1594,9 +1141,9 @@ macos-tags = [ {file = "macos-tags-1.5.1.tar.gz", hash = "sha256:f144c5bc05d01573966d8aca2483cb345b20b76a5b32e9967786e086a38712e7"}, {file = "macos_tags-1.5.1-py3-none-any.whl", hash = "sha256:56419233af32242b703dd35bcf38c9f198abd969faddbe986eb8aaa6d95349cf"}, ] -markdown-it-py = [ - {file = "markdown-it-py-2.0.0.tar.gz", hash = "sha256:c138a596f6c9988e0b5fa3299bc38ffa76c75076bc178e8dfac40a84343c7022"}, - {file = "markdown_it_py-2.0.0-py3-none-any.whl", hash = "sha256:15cc69c5b7c493ba8603722b710e39ce3fab2961994179fb4fa1c99b070d2059"}, +markdown = [ + {file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"}, + {file = "Markdown-3.3.6.tar.gz", hash = "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006"}, ] markupsafe = [ {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, @@ -1669,25 +1216,29 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, ] -mccabe = [ - {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, - {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, -] mdfind-wrapper = [ {file = "mdfind-wrapper-0.1.5.tar.gz", hash = "sha256:c0dbd5bc99c6d1fb4678bfa1841a3380ccac61e9b43a26a8d658aa9cafe27441"}, {file = "mdfind_wrapper-0.1.5-py3-none-any.whl", hash = "sha256:fd00e65684b47f2d286eb7394eb172f4766f2926d95eddff6eb948352f620cbc"}, ] -mdit-py-plugins = [ - {file = "mdit-py-plugins-0.3.0.tar.gz", hash = "sha256:ecc24f51eeec6ab7eecc2f9724e8272c2fb191c2e93cf98109120c2cace69750"}, - {file = "mdit_py_plugins-0.3.0-py3-none-any.whl", hash = "sha256:b1279701cee2dbf50e188d3da5f51fee8d78d038cdf99be57c6b9d1aa93b4073"}, +mergedeep = [ + {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, + {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, +] +mkdocs = [ + {file = "mkdocs-1.2.3-py3-none-any.whl", hash = "sha256:a1fa8c2d0c1305d7fc2b9d9f607c71778572a8b110fb26642aa00296c9e6d072"}, + {file = "mkdocs-1.2.3.tar.gz", hash = "sha256:89f5a094764381cda656af4298727c9f53dc3e602983087e1fe96ea1df24f4c1"}, +] +mkdocs-autorefs = [ + {file = "mkdocs-autorefs-0.3.1.tar.gz", hash = "sha256:12baad29359f468b44d980ed35b713715409097a1d8e3d0ef90962db95205eda"}, + {file = "mkdocs_autorefs-0.3.1-py3-none-any.whl", hash = "sha256:f0fd7c115eaafda7fb16bf5ff5d70eda55d7c0599eac64f8b25eacf864312a85"}, ] -mdurl = [ - {file = "mdurl-0.1.0-py3-none-any.whl", hash = "sha256:40654d6dcb8d21501ed13c21cc0bd6fc42ff07ceb8be30029e5ae63ebc2ecfda"}, - {file = "mdurl-0.1.0.tar.gz", hash = "sha256:94873a969008ee48880fb21bad7de0349fef529f3be178969af5817239e9b990"}, +mkdocs-include-markdown-plugin = [ + {file = "mkdocs_include_markdown_plugin-3.2.3-py3-none-any.whl", hash = "sha256:5a8b0c60d8981225c012b8f657b6557910997e46dacae4aff039b181487236cf"}, + {file = "mkdocs_include_markdown_plugin-3.2.3.tar.gz", hash = "sha256:64dd8c408a1b5b7422d4a5a826c434e5372af2fb7bd244dd5c87e09ff8f13302"}, ] -more-itertools = [ - {file = "more-itertools-8.12.0.tar.gz", hash = "sha256:7dc6ad46f05f545f900dd59e8dfb4e84a4827b97b3cfecb175ea0c7d247f6064"}, - {file = "more_itertools-8.12.0-py3-none-any.whl", hash = "sha256:43e6dd9942dffd72661a2c4ef383ad7da1e6a3e968a927ad7a6083ab410a688b"}, +mkdocstrings = [ + {file = "mkdocstrings-0.17.0-py3-none-any.whl", hash = "sha256:103fc1dd58cb23b7e0a6da5292435f01b29dc6fa0ba829132537f3f556f985de"}, + {file = "mkdocstrings-0.17.0.tar.gz", hash = "sha256:75b5cfa2039aeaf3a5f5cf0aa438507b0330ce76c8478da149d692daa7213a98"}, ] mypy = [ {file = "mypy-0.812-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49"}, @@ -1717,10 +1268,6 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] -myst-parser = [ - {file = "myst-parser-0.16.1.tar.gz", hash = "sha256:a6473b9735c8c74959b49b36550725464f4aecc4481340c9a5f9153829191f83"}, - {file = "myst_parser-0.16.1-py3-none-any.whl", hash = "sha256:617a90ceda2162ebf81cd13ad17d879bd4f49e7fb5c4f177bb905272555a2268"}, -] olefile = [ {file = "olefile-0.46.zip", hash = "sha256:133b031eaf8fd2c9399b78b8bc5b8fcbe4c31e85295749bb17a87cba8f3c3964"}, ] @@ -1728,22 +1275,10 @@ packaging = [ {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] -parso = [ - {file = "parso-0.7.1-py2.py3-none-any.whl", hash = "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea"}, - {file = "parso-0.7.1.tar.gz", hash = "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9"}, -] "pdfminer.six" = [ {file = "pdfminer.six-20191110-py2.py3-none-any.whl", hash = "sha256:ca2ca58f3ac66a486bce53a6ddba95dc2b27781612915fa41c444790ba9cd2a8"}, {file = "pdfminer.six-20191110.tar.gz", hash = "sha256:141a53ec491bee6d45bf9b2c7f82601426fb5d32636bcf6b9c8a8f3b6431fea6"}, ] -pexpect = [ - {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"}, - {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"}, -] -pickleshare = [ - {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, - {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, -] pillow = [ {file = "Pillow-8.4.0-cp310-cp310-macosx_10_10_universal2.whl", hash = "sha256:81f8d5c81e483a9442d72d182e1fb6dcb9723f289a57e8030811bac9ea3fef8d"}, {file = "Pillow-8.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3f97cfb1e5a392d75dd8b9fd274d205404729923840ca94ca45a0af57e13dbe6"}, @@ -1787,89 +1322,77 @@ pillow = [ {file = "Pillow-8.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:244cf3b97802c34c41905d22810846802a3329ddcb93ccc432870243211c79fc"}, {file = "Pillow-8.4.0.tar.gz", hash = "sha256:b8e2f83c56e141920c39464b852de3719dfbfb6e3c99a2d8da0edf4fb33176ed"}, ] -platformdirs = [ - {file = "platformdirs-2.4.0-py3-none-any.whl", hash = "sha256:8868bbe3c3c80d42f20156f22e7131d2fb321f5bc86a2a345375c6481a67021d"}, - {file = "platformdirs-2.4.0.tar.gz", hash = "sha256:367a5e80b3d04d2428ffa76d33f124cf11e8fff2acdaa9b43d545f5c7d661ef2"}, -] pluggy = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, -] -prompt-toolkit = [ - {file = "prompt_toolkit-3.0.24-py3-none-any.whl", hash = "sha256:e56f2ff799bacecd3e88165b1e2f5ebf9bcd59e80e06d395fa0cc4b8bd7bb506"}, - {file = "prompt_toolkit-3.0.24.tar.gz", hash = "sha256:1bb05628c7d87b645974a1bad3f17612be0c29fa39af9f7688030163f680bad6"}, -] -ptyprocess = [ - {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, - {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] py = [ {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] -pycodestyle = [ - {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, - {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, -] pycparser = [ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] pycryptodome = [ - {file = "pycryptodome-3.12.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:90ad3381ccdc6a24cc2841e295706a168f32abefe64c679695712acac71fd5da"}, - {file = "pycryptodome-3.12.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e80f7469b0b3ea0f694230477d8501dc5a30a717e94fddd4821e6721f3053eae"}, - {file = "pycryptodome-3.12.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:b91404611767a7485837a6f1fd20cf9a5ae0ad362040a022cd65827ecb1b0d00"}, - {file = "pycryptodome-3.12.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:db66ccda65d5d20c17b00768e462a86f6f540f9aea8419a7f76cc7d9effd82cd"}, - {file = "pycryptodome-3.12.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:dc88355c4b261ed259268e65705b28b44d99570337694d593f06e3b1698eaaf3"}, - {file = "pycryptodome-3.12.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:6f8f5b7b53516da7511951910ab458e799173722c91fea54e2ba2f56d102e4aa"}, - {file = "pycryptodome-3.12.0-cp27-cp27m-win32.whl", hash = "sha256:93acad54a72d81253242eb0a15064be559ec9d989e5173286dc21cad19f01765"}, - {file = "pycryptodome-3.12.0-cp27-cp27m-win_amd64.whl", hash = "sha256:5a8c24d39d4a237dbfe181ea6593792bf9b5582c7fcfa7b8e0e12fda5eec07af"}, - {file = "pycryptodome-3.12.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:32d15da81959faea6cbed95df2bb44f7f796211c110cf90b5ad3b2aeeb97fc8e"}, - {file = "pycryptodome-3.12.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:aed7eb4b64c600fbc5e6d4238991ad1b4179a558401f203d1fcbd24883748982"}, - {file = "pycryptodome-3.12.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:341c6bbf932c406b4f3ee2372e8589b67ac0cf4e99e7dc081440f43a3cde9f0f"}, - {file = "pycryptodome-3.12.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:de0b711d673904dd6c65307ead36cb76622365a393569bf880895cba21195b7a"}, - {file = "pycryptodome-3.12.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:3558616f45d8584aee3eba27559bc6fd0ba9be6c076610ed3cc62bd5229ffdc3"}, - {file = "pycryptodome-3.12.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:a78e4324e566b5fbc2b51e9240950d82fa9e1c7eb77acdf27f58712f65622c1d"}, - {file = "pycryptodome-3.12.0-cp35-abi3-manylinux1_i686.whl", hash = "sha256:3f2f3dd596c6128d91314e60a6bcf4344610ef0e97f4ae4dd1770f86dd0748d8"}, - {file = "pycryptodome-3.12.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:e05f994f30f1cda3cbe57441f41220d16731cf99d868bb02a8f6484c454c206b"}, - {file = "pycryptodome-3.12.0-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:4cded12e13785bbdf4ba1ff5fb9d261cd98162145f869e4fbc4a4b9083392f0b"}, - {file = "pycryptodome-3.12.0-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:1181c90d1a6aee68a84826825548d0db1b58d8541101f908d779d601d1690586"}, - {file = "pycryptodome-3.12.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:6bb0d340c93bcb674ea8899e2f6408ec64c6c21731a59481332b4b2a8143cc60"}, - {file = "pycryptodome-3.12.0-cp35-abi3-win32.whl", hash = "sha256:39da5807aa1ff820799c928f745f89432908bf6624b9e981d2d7f9e55d91b860"}, - {file = "pycryptodome-3.12.0-cp35-abi3-win_amd64.whl", hash = "sha256:212c7f7fe11cad9275fbcff50ca977f1c6643f13560d081e7b0f70596df447b8"}, - {file = "pycryptodome-3.12.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:b07a4238465eb8c65dd5df2ab8ba6df127e412293c0ed7656c003336f557a100"}, - {file = "pycryptodome-3.12.0-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:a6e1bcd9d5855f1a3c0f8d585f44c81b08f39a02754007f374fb8db9605ba29c"}, - {file = "pycryptodome-3.12.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:aceb1d217c3a025fb963849071446cf3aca1353282fe1c3cb7bd7339a4d47947"}, - {file = "pycryptodome-3.12.0-pp27-pypy_73-win32.whl", hash = "sha256:f699360ae285fcae9c8f53ca6acf33796025a82bb0ccd7c1c551b04c1726def3"}, - {file = "pycryptodome-3.12.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d845c587ceb82ac7cbac7d0bf8c62a1a0fe7190b028b322da5ca65f6e5a18b9e"}, - {file = "pycryptodome-3.12.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:d8083de50f6dec56c3c6f270fb193590999583a1b27c9c75bc0b5cac22d438cc"}, - {file = "pycryptodome-3.12.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:9ea2f6674c803602a7c0437fccdc2ea036707e60456974fe26ca263bd501ec45"}, - {file = "pycryptodome-3.12.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:5d4264039a2087977f50072aaff2346d1c1c101cb359f9444cf92e3d1f42b4cd"}, - {file = "pycryptodome-3.12.0.zip", hash = "sha256:12c7343aec5a3b3df5c47265281b12b611f26ec9367b6129199d67da54b768c1"}, -] -pyflakes = [ - {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, - {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, + {file = "pycryptodome-3.13.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e468724173df02f9d83f3fea830bf0d04aa291b5add22b4a78e01c97aab04873"}, + {file = "pycryptodome-3.13.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1fb7a6f222072412f320b9e48d3ce981920efbfce37b06d028ec9bd94093b37f"}, + {file = "pycryptodome-3.13.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4f1b594d0cf35bd12ec4244df1155a7f565bf6e6245976ac36174c1564688c90"}, + {file = "pycryptodome-3.13.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:9ea70f6c3f6566159e3798e4593a4a8016994a0080ac29a45200615b45091a1b"}, + {file = "pycryptodome-3.13.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:f7aad304575d075faf2806977b726b67da7ba294adc97d878f92a062e357a56a"}, + {file = "pycryptodome-3.13.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:702446a012fd9337b9327d168bb0c7dc714eb93ad361f6f61af9ca8305a301f1"}, + {file = "pycryptodome-3.13.0-cp27-cp27m-win32.whl", hash = "sha256:681ac47c538c64305d710eaed2bb49532f62b3f4c93aa7c423c520df981392e5"}, + {file = "pycryptodome-3.13.0-cp27-cp27m-win_amd64.whl", hash = "sha256:7b3478a187d897f003b2aa1793bcc59463e8d57a42e2aafbcbbe9cd47ec46863"}, + {file = "pycryptodome-3.13.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:eec02d9199af4b1ccfe1f9c587691a07a1fa39d949d2c1dc69d079ab9af8212f"}, + {file = "pycryptodome-3.13.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:9c8e0e6c5e982699801b20fa74f43c19aa080d2b53a39f3c132d35958e153bd4"}, + {file = "pycryptodome-3.13.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:f5457e44d3f26d9946091e92b28f3e970a56538b96c87b4b155a84e32a40b7b5"}, + {file = "pycryptodome-3.13.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:88d6d54e83cf9bbd665ce1e7b9079983ee2d97a05f42e0569ff00a70f1dd8b1e"}, + {file = "pycryptodome-3.13.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:72de8c4d71e6b11d54528bb924447fa4fdabcbb3d76cc0e7f61d3b6075def6b3"}, + {file = "pycryptodome-3.13.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:008ef2c631f112cd5a58736e0b29f4a28b4bb853e68878689f8b476fd56e0691"}, + {file = "pycryptodome-3.13.0-cp35-abi3-manylinux1_i686.whl", hash = "sha256:51ebe9624ad0a0b4da1aaaa2d43aabadf8537737fd494cee0ffa37cd6326de02"}, + {file = "pycryptodome-3.13.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:deede160bdf87ddb71f0a1314ad5a267b1a960be314ea7dc6b7ad86da6da89a3"}, + {file = "pycryptodome-3.13.0-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:857c16bffd938254e3a834cd6b2a755ed24e1a953b1a86e33da136d3e4c16a6f"}, + {file = "pycryptodome-3.13.0-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:ca6db61335d07220de0b665bfee7b8e9615b2dfc67a54016db4826dac34c2dd2"}, + {file = "pycryptodome-3.13.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:073dedf0f9c490ae22ca081b86357646ac9b76f3e2bd89119d137fc697a9e3b6"}, + {file = "pycryptodome-3.13.0-cp35-abi3-win32.whl", hash = "sha256:e3affa03c49cce7b0a9501cc7f608d4f8e61fb2522b276d599ac049b5955576d"}, + {file = "pycryptodome-3.13.0-cp35-abi3-win_amd64.whl", hash = "sha256:e5d72be02b17e6bd7919555811264403468d1d052fa67c946e402257c3c29a27"}, + {file = "pycryptodome-3.13.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:0896d5d15ffe584d46cb9b69a75cf14a2bc8f6daf635b7bf16c1b041342a44b1"}, + {file = "pycryptodome-3.13.0-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:e420cdfca73f80fe15f79bb34756959945231a052440813e5fce531e6e96331a"}, + {file = "pycryptodome-3.13.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:720fafdf3e5c5de93039d8308f765cc60b8e9e7e852ad7135aa65dd89238191f"}, + {file = "pycryptodome-3.13.0-pp27-pypy_73-win32.whl", hash = "sha256:7a8b0e526ff239b4f4c61dd6898e2474d609843ffc437267f3a27ddff626e6f6"}, + {file = "pycryptodome-3.13.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d92a5eddffb0ad39f582f07c1de26e9daf6880e3e782a94bb7ebaf939567f8bf"}, + {file = "pycryptodome-3.13.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:cb9453c981554984c6f5c5ce7682d7286e65e2173d7416114c3593a977a01bf5"}, + {file = "pycryptodome-3.13.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:765b8b16bc1fd699e183dde642c7f2653b8f3c9c1a50051139908e9683f97732"}, + {file = "pycryptodome-3.13.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:b3af53dddf848afb38b3ac2bae7159ddad1feb9bac14aa3acec6ef1797b82f8d"}, + {file = "pycryptodome-3.13.0.tar.gz", hash = "sha256:95bacf9ff7d1b90bba537d3f5f6c834efe6bfbb1a0195cb3573f29e6716ef08d"}, ] pygments = [ {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, ] -pylint = [ - {file = "pylint-2.12.2-py3-none-any.whl", hash = "sha256:daabda3f7ed9d1c60f52d563b1b854632fd90035bcf01443e234d3dc794e3b74"}, - {file = "pylint-2.12.2.tar.gz", hash = "sha256:9d945a73640e1fec07ee34b42f5669b770c759acd536ec7b16d7e4b87a9c9ff9"}, +pymdown-extensions = [ + {file = "pymdown-extensions-9.1.tar.gz", hash = "sha256:74247f2c80f1d9e3c7242abe1c16317da36c6f26c7ad4b8a7f457f0ec20f0365"}, + {file = "pymdown_extensions-9.1-py3-none-any.whl", hash = "sha256:b03e66f91f33af4a6e7a0e20c740313522995f69a03d86316b1449766c473d0e"}, ] pyparsing = [ - {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, - {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, + {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, + {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, ] pytest = [ - {file = "pytest-4.6.11-py2.py3-none-any.whl", hash = "sha256:a00a7d79cbbdfa9d21e7d0298392a8dd4123316bfac545075e6f8f24c94d8c97"}, - {file = "pytest-4.6.11.tar.gz", hash = "sha256:50fa82392f2120cc3ec2ca0a75ee615be4c479e66669789771f1758332be4353"}, + {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, + {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, + {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] python-pptx = [ {file = "python-pptx-0.6.21.tar.gz", hash = "sha256:7798a2aaf89563565b3c7120c0acfe9aff775db0db3580544e3bf4840c2e378f"}, ] +pytkdocs = [ + {file = "pytkdocs-0.15.0-py3-none-any.whl", hash = "sha256:d6b2aec34448ec89acb8c1c25062cc1e70c6b26395d46fc7ee753b7e5a4e736a"}, + {file = "pytkdocs-0.15.0.tar.gz", hash = "sha256:4b45af89d6fa5fa50f979b0f9f54539286b84e245c81991bb838149aa2d9d9c9"}, +] pytz = [ {file = "pytz-2021.3-py2.py3-none-any.whl", hash = "sha256:3672058bc3453457b622aab7a1c3bfd5ab0bdae451512f6cf25f64ed37f5b87c"}, {file = "pytz-2021.3.tar.gz", hash = "sha256:acad2d8b20a1af07d4e4c9d2e9285c5ed9104354062f275f3fcd88dcef4f1326"}, @@ -1909,9 +1432,9 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, ] -requests = [ - {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, - {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, +pyyaml-env-tag = [ + {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, + {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, ] rich = [ {file = "rich-11.0.0-py3-none-any.whl", hash = "sha256:d7a8086aa1fa7e817e3bba544eee4fd82047ef59036313147759c11475f0dafd"}, @@ -1933,10 +1456,6 @@ six = [ {file = "six-1.12.0-py2.py3-none-any.whl", hash = "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c"}, {file = "six-1.12.0.tar.gz", hash = "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"}, ] -snowballstemmer = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] sortedcontainers = [ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"}, {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"}, @@ -1948,38 +1467,6 @@ soupsieve = [ speechrecognition = [ {file = "SpeechRecognition-3.8.1-py2.py3-none-any.whl", hash = "sha256:4d8f73a0c05ec70331c3bacaa89ecc06dfa8d9aba0899276664cda06ab597e8e"}, ] -sphinx = [ - {file = "Sphinx-3.5.4-py3-none-any.whl", hash = "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8"}, - {file = "Sphinx-3.5.4.tar.gz", hash = "sha256:19010b7b9fa0dc7756a6e105b2aacd3a80f798af3c25c273be64d7beeb482cb1"}, -] -sphinx-rtd-theme = [ - {file = "sphinx_rtd_theme-0.5.2-py2.py3-none-any.whl", hash = "sha256:4a05bdbe8b1446d77a01e20a23ebc6777c74f43237035e76be89699308987d6f"}, - {file = "sphinx_rtd_theme-0.5.2.tar.gz", hash = "sha256:32bd3b5d13dc8186d7a42fc816a23d32e83a4827d7d9882948e7b837c232da5a"}, -] -sphinxcontrib-applehelp = [ - {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, - {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, -] -sphinxcontrib-devhelp = [ - {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, - {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, -] -sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, - {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, -] -sphinxcontrib-jsmath = [ - {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, - {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, -] -sphinxcontrib-qthelp = [ - {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, - {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, -] -sphinxcontrib-serializinghtml = [ - {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, - {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, -] textract = [ {file = "textract-1.6.4.tar.gz", hash = "sha256:35ac0302e2dbe53eb8d513b4cf0741264ea89a695fd89a3d48e3bd94d517cef6"}, ] @@ -1987,10 +1474,6 @@ toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] -traitlets = [ - {file = "traitlets-4.3.3-py2.py3-none-any.whl", hash = "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44"}, - {file = "traitlets-4.3.3.tar.gz", hash = "sha256:d023ee369ddd2763310e4c3eae1ff649689440d4ae59d7485eb4cfbbe3e359f7"}, -] typed-ast = [ {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, @@ -2035,66 +1518,30 @@ tzlocal = [ {file = "tzlocal-4.1-py3-none-any.whl", hash = "sha256:28ba8d9fcb6c9a782d6e0078b4f6627af1ea26aeaa32b4eab5324abc7df4149f"}, {file = "tzlocal-4.1.tar.gz", hash = "sha256:0f28015ac68a5c067210400a9197fc5d36ba9bc3f8eaf1da3cbd59acdfed9e09"}, ] -urllib3 = [ - {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, - {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, -] -wcwidth = [ - {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, - {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, -] -wrapt = [ - {file = "wrapt-1.13.3-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e05e60ff3b2b0342153be4d1b597bbcfd8330890056b9619f4ad6b8d5c96a81a"}, - {file = "wrapt-1.13.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:85148f4225287b6a0665eef08a178c15097366d46b210574a658c1ff5b377489"}, - {file = "wrapt-1.13.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:2dded5496e8f1592ec27079b28b6ad2a1ef0b9296d270f77b8e4a3a796cf6909"}, - {file = "wrapt-1.13.3-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:e94b7d9deaa4cc7bac9198a58a7240aaf87fe56c6277ee25fa5b3aa1edebd229"}, - {file = "wrapt-1.13.3-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:498e6217523111d07cd67e87a791f5e9ee769f9241fcf8a379696e25806965af"}, - {file = "wrapt-1.13.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ec7e20258ecc5174029a0f391e1b948bf2906cd64c198a9b8b281b811cbc04de"}, - {file = "wrapt-1.13.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:87883690cae293541e08ba2da22cacaae0a092e0ed56bbba8d018cc486fbafbb"}, - {file = "wrapt-1.13.3-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:f99c0489258086308aad4ae57da9e8ecf9e1f3f30fa35d5e170b4d4896554d80"}, - {file = "wrapt-1.13.3-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6a03d9917aee887690aa3f1747ce634e610f6db6f6b332b35c2dd89412912bca"}, - {file = "wrapt-1.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:936503cb0a6ed28dbfa87e8fcd0a56458822144e9d11a49ccee6d9a8adb2ac44"}, - {file = "wrapt-1.13.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f9c51d9af9abb899bd34ace878fbec8bf357b3194a10c4e8e0a25512826ef056"}, - {file = "wrapt-1.13.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:220a869982ea9023e163ba915077816ca439489de6d2c09089b219f4e11b6785"}, - {file = "wrapt-1.13.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0877fe981fd76b183711d767500e6b3111378ed2043c145e21816ee589d91096"}, - {file = "wrapt-1.13.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:43e69ffe47e3609a6aec0fe723001c60c65305784d964f5007d5b4fb1bc6bf33"}, - {file = "wrapt-1.13.3-cp310-cp310-win32.whl", hash = "sha256:78dea98c81915bbf510eb6a3c9c24915e4660302937b9ae05a0947164248020f"}, - {file = "wrapt-1.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:ea3e746e29d4000cd98d572f3ee2a6050a4f784bb536f4ac1f035987fc1ed83e"}, - {file = "wrapt-1.13.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:8c73c1a2ec7c98d7eaded149f6d225a692caa1bd7b2401a14125446e9e90410d"}, - {file = "wrapt-1.13.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:086218a72ec7d986a3eddb7707c8c4526d677c7b35e355875a0fe2918b059179"}, - {file = "wrapt-1.13.3-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:e92d0d4fa68ea0c02d39f1e2f9cb5bc4b4a71e8c442207433d8db47ee79d7aa3"}, - {file = "wrapt-1.13.3-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:d4a5f6146cfa5c7ba0134249665acd322a70d1ea61732723c7d3e8cc0fa80755"}, - {file = "wrapt-1.13.3-cp35-cp35m-win32.whl", hash = "sha256:8aab36778fa9bba1a8f06a4919556f9f8c7b33102bd71b3ab307bb3fecb21851"}, - {file = "wrapt-1.13.3-cp35-cp35m-win_amd64.whl", hash = "sha256:944b180f61f5e36c0634d3202ba8509b986b5fbaf57db3e94df11abee244ba13"}, - {file = "wrapt-1.13.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:2ebdde19cd3c8cdf8df3fc165bc7827334bc4e353465048b36f7deeae8ee0918"}, - {file = "wrapt-1.13.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:610f5f83dd1e0ad40254c306f4764fcdc846641f120c3cf424ff57a19d5f7ade"}, - {file = "wrapt-1.13.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5601f44a0f38fed36cc07db004f0eedeaadbdcec90e4e90509480e7e6060a5bc"}, - {file = "wrapt-1.13.3-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:e6906d6f48437dfd80464f7d7af1740eadc572b9f7a4301e7dd3d65db285cacf"}, - {file = "wrapt-1.13.3-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:766b32c762e07e26f50d8a3468e3b4228b3736c805018e4b0ec8cc01ecd88125"}, - {file = "wrapt-1.13.3-cp36-cp36m-win32.whl", hash = "sha256:5f223101f21cfd41deec8ce3889dc59f88a59b409db028c469c9b20cfeefbe36"}, - {file = "wrapt-1.13.3-cp36-cp36m-win_amd64.whl", hash = "sha256:f122ccd12fdc69628786d0c947bdd9cb2733be8f800d88b5a37c57f1f1d73c10"}, - {file = "wrapt-1.13.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:46f7f3af321a573fc0c3586612db4decb7eb37172af1bc6173d81f5b66c2e068"}, - {file = "wrapt-1.13.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:778fd096ee96890c10ce96187c76b3e99b2da44e08c9e24d5652f356873f6709"}, - {file = "wrapt-1.13.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0cb23d36ed03bf46b894cfec777eec754146d68429c30431c99ef28482b5c1df"}, - {file = "wrapt-1.13.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:96b81ae75591a795d8c90edc0bfaab44d3d41ffc1aae4d994c5aa21d9b8e19a2"}, - {file = "wrapt-1.13.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7dd215e4e8514004c8d810a73e342c536547038fb130205ec4bba9f5de35d45b"}, - {file = "wrapt-1.13.3-cp37-cp37m-win32.whl", hash = "sha256:47f0a183743e7f71f29e4e21574ad3fa95676136f45b91afcf83f6a050914829"}, - {file = "wrapt-1.13.3-cp37-cp37m-win_amd64.whl", hash = "sha256:fd76c47f20984b43d93de9a82011bb6e5f8325df6c9ed4d8310029a55fa361ea"}, - {file = "wrapt-1.13.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b73d4b78807bd299b38e4598b8e7bd34ed55d480160d2e7fdaabd9931afa65f9"}, - {file = "wrapt-1.13.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ec9465dd69d5657b5d2fa6133b3e1e989ae27d29471a672416fd729b429eb554"}, - {file = "wrapt-1.13.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:dd91006848eb55af2159375134d724032a2d1d13bcc6f81cd8d3ed9f2b8e846c"}, - {file = "wrapt-1.13.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:ae9de71eb60940e58207f8e71fe113c639da42adb02fb2bcbcaccc1ccecd092b"}, - {file = "wrapt-1.13.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:51799ca950cfee9396a87f4a1240622ac38973b6df5ef7a41e7f0b98797099ce"}, - {file = "wrapt-1.13.3-cp38-cp38-win32.whl", hash = "sha256:4b9c458732450ec42578b5642ac53e312092acf8c0bfce140ada5ca1ac556f79"}, - {file = "wrapt-1.13.3-cp38-cp38-win_amd64.whl", hash = "sha256:7dde79d007cd6dfa65afe404766057c2409316135cb892be4b1c768e3f3a11cb"}, - {file = "wrapt-1.13.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:981da26722bebb9247a0601e2922cedf8bb7a600e89c852d063313102de6f2cb"}, - {file = "wrapt-1.13.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:705e2af1f7be4707e49ced9153f8d72131090e52be9278b5dbb1498c749a1e32"}, - {file = "wrapt-1.13.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:25b1b1d5df495d82be1c9d2fad408f7ce5ca8a38085e2da41bb63c914baadff7"}, - {file = "wrapt-1.13.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:77416e6b17926d953b5c666a3cb718d5945df63ecf922af0ee576206d7033b5e"}, - {file = "wrapt-1.13.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:865c0b50003616f05858b22174c40ffc27a38e67359fa1495605f96125f76640"}, - {file = "wrapt-1.13.3-cp39-cp39-win32.whl", hash = "sha256:0a017a667d1f7411816e4bf214646d0ad5b1da2c1ea13dec6c162736ff25a374"}, - {file = "wrapt-1.13.3-cp39-cp39-win_amd64.whl", hash = "sha256:81bd7c90d28a4b2e1df135bfbd7c23aee3050078ca6441bead44c42483f9ebfb"}, - {file = "wrapt-1.13.3.tar.gz", hash = "sha256:1fea9cd438686e6682271d36f3481a9f3636195578bab9ca3382e2f5f01fc185"}, +watchdog = [ + {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9693f35162dc6208d10b10ddf0458cc09ad70c30ba689d9206e02cd836ce28a3"}, + {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aba5c812f8ee8a3ff3be51887ca2d55fb8e268439ed44110d3846e4229eb0e8b"}, + {file = "watchdog-2.1.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ae38bf8ba6f39d5b83f78661273216e7db5b00f08be7592062cb1fc8b8ba542"}, + {file = "watchdog-2.1.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ad6f1796e37db2223d2a3f302f586f74c72c630b48a9872c1e7ae8e92e0ab669"}, + {file = "watchdog-2.1.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:922a69fa533cb0c793b483becaaa0845f655151e7256ec73630a1b2e9ebcb660"}, + {file = "watchdog-2.1.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b2fcf9402fde2672545b139694284dc3b665fd1be660d73eca6805197ef776a3"}, + {file = "watchdog-2.1.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3386b367e950a11b0568062b70cc026c6f645428a698d33d39e013aaeda4cc04"}, + {file = "watchdog-2.1.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f1c00aa35f504197561060ca4c21d3cc079ba29cf6dd2fe61024c70160c990b"}, + {file = "watchdog-2.1.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b52b88021b9541a60531142b0a451baca08d28b74a723d0c99b13c8c8d48d604"}, + {file = "watchdog-2.1.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8047da932432aa32c515ec1447ea79ce578d0559362ca3605f8e9568f844e3c6"}, + {file = "watchdog-2.1.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e92c2d33858c8f560671b448205a268096e17870dcf60a9bb3ac7bfbafb7f5f9"}, + {file = "watchdog-2.1.6-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b7d336912853d7b77f9b2c24eeed6a5065d0a0cc0d3b6a5a45ad6d1d05fb8cd8"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_aarch64.whl", hash = "sha256:cca7741c0fcc765568350cb139e92b7f9f3c9a08c4f32591d18ab0a6ac9e71b6"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_armv7l.whl", hash = "sha256:25fb5240b195d17de949588628fdf93032ebf163524ef08933db0ea1f99bd685"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_i686.whl", hash = "sha256:be9be735f827820a06340dff2ddea1fb7234561fa5e6300a62fe7f54d40546a0"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0d19fb2441947b58fbf91336638c2b9f4cc98e05e1045404d7a4cb7cddc7a65"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:3becdb380d8916c873ad512f1701f8a92ce79ec6978ffde92919fd18d41da7fb"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_s390x.whl", hash = "sha256:ae67501c95606072aafa865b6ed47343ac6484472a2f95490ba151f6347acfc2"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e0f30db709c939cabf64a6dc5babb276e6d823fd84464ab916f9b9ba5623ca15"}, + {file = "watchdog-2.1.6-py3-none-win32.whl", hash = "sha256:e02794ac791662a5eafc6ffeaf9bcc149035a0e48eb0a9d40a8feb4622605a3d"}, + {file = "watchdog-2.1.6-py3-none-win_amd64.whl", hash = "sha256:bd9ba4f332cf57b2c1f698be0728c020399ef3040577cde2939f2e045b39c1e5"}, + {file = "watchdog-2.1.6-py3-none-win_ia64.whl", hash = "sha256:a0f1c7edf116a12f7245be06120b1852275f9506a7d90227648b250755a03923"}, + {file = "watchdog-2.1.6.tar.gz", hash = "sha256:a36e75df6c767cbf46f61a91c70b3ba71811dfa0aca4a324d9407a06a8b7a2e7"}, ] xattr = [ {file = "xattr-0.9.9-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:58a9fb4fd19b467e88f4b75b5243706caa57e312d3aee757b53b57c7fd0f4ba9"}, diff --git a/pyproject.toml b/pyproject.toml index 424548bf..3d28638e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,20 +43,18 @@ textract = { version = "^1.6.4", optional = true } simplematch = "^1.3" macos-tags = { version = "^1.5.1", markers = "sys_platform == 'darwin'" } schema = "^0.7.5" +Jinja2 = "^3.0.3" [tool.poetry.extras] textract = ["textract"] [tool.poetry.dev-dependencies] -pip = "^21.3.1" -pytest = "^4.6" -pylint = "^2.3" -ipdb = "^0.12.0" -sphinx = "^3.1.0" -sphinx-rtd-theme = "^0.5.2" +pytest = "^6.2.5" mypy = "^0.812" -flake8 = "^3.9.1" -myst-parser = "^0.16.1" +mkdocs = "^1.2.3" +mkdocstrings = "^0.17.0" +mkdocs-include-markdown-plugin = "^3.2.3" +mkdocs-autorefs = "^0.3.1" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/testconf.yaml b/testconf.yaml index 97887f66..3ec07f08 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -1,14 +1,14 @@ rules: - name: "Find the last modification date of some png files" - targets: files + targets: dirs locations: - path: ~/Desktop/test - max_depth: 3 + max_depth: null filters: - - hash: - algorithm: md5 + - empty actions: - - echo: "{hash}" + - echo: "{path}" + - delete # - name: Find some folders # targets: dirs From 7be8809d0e1e6e75daa4eae76b33bd5cf6d7cd38 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 25 Jan 2022 21:16:05 +0100 Subject: [PATCH 040/108] rename pages --- docs/01-config.md | 296 +++++++++++++++++++++ docs/{locations.md => 02-locations.md} | 0 docs/{filters.md => 03-filters.md} | 3 + docs/{actions.md => 04-actions.md} | 3 + docs/{changelog.md => 05-changelog.md} | 0 docs/cli.md | 1 - docs/config.md | 1 - docs/sphinx/config.rst | 347 ------------------------- docs/sphinx/quickstart.rst | 64 ----- mkdocs.yml | 10 +- 10 files changed, 307 insertions(+), 418 deletions(-) create mode 100644 docs/01-config.md rename docs/{locations.md => 02-locations.md} (100%) rename docs/{filters.md => 03-filters.md} (84%) rename docs/{actions.md => 04-actions.md} (95%) rename docs/{changelog.md => 05-changelog.md} (100%) delete mode 100644 docs/cli.md delete mode 100644 docs/config.md delete mode 100644 docs/sphinx/config.rst delete mode 100644 docs/sphinx/quickstart.rst diff --git a/docs/01-config.md b/docs/01-config.md new file mode 100644 index 00000000..e572ecfc --- /dev/null +++ b/docs/01-config.md @@ -0,0 +1,296 @@ +# Configuration + +## Editing the configuration + +All configuration takes place in your `config.yaml` file. + +To edit your configuration in `$EDITOR` run: + +```bash +$ organize config # example: "EDITOR=vim organize config" +``` + +To show the full path to your configuration file: + +```bash +$ organize config --path +``` + +To open the folder containing the configuration file: + +```bash +$ organize config --open-folder +``` + +To debug your configuration run: + +```bash +$ organize config --debug +``` + +## Environment variables + +- `$EDITOR` - The editor used to edit the config file. +- `$ORGANIZE_CONFIG` - The config file path. Is overridden by `--config-file` cmd line argument. + +## Rule syntax + +The rule configuration is done in [YAML](https://learnxinyminutes.com/docs/yaml/). +You need a top-level element `rules` which contains a list of rules. +Each rule defines `folders`, `filters` (optional) and `actions`. + +```yaml +rules: +- folders: + - ~/Desktop + - /some/folder/ + filters: + - lastmodified: + days: 40 + mode: newer - extension: pdf + actions: + - move: ~/Desktop/Target/ - trash + + - folders: + - ~/Inbox + filters: + - extension: pdf + actions: + - move: ~/otherinbox +``` + +- `folders` is a list of folders you want to organize. +- `filters` is a list of filters to apply to the files - you can filter by file extension, last modified date, regular expressions and many more. See :ref:`Filters`. +- `actions` is a list of actions to apply to the filtered files. You can put them into the trash, move them into another folder and many more. See :ref:`Actions`. + +Other optional per rule settings: + +- `enabled` can be used to temporarily disable single rules. Default = true +- `subfolders` specifies whether subfolders should be included in the search. Default = false. This setting only applies to folders without glob wildcards. +- `system_files` specifies whether to include system files (desktop.ini, thumbs.db, .DS_Store) in the search. Default = false + +## Folder syntax + +Every rule in your configuration file needs to know the folders it applies to. +The easiest way is to define the rules like this: + +.. code-block:: yaml +:caption: config.yaml + +rules: - folders: - /path/one - /path/two +filters: ... +actions: ... + + - folders: + - /path/one + - /another/path + filters: ... + actions: ... + +.. note:: + +- You can use environment variables in your folder names. On windows this means you can use `%public%/Desktop`, `%APPDATA%`, `%PROGRAMDATA%` etc. + +### Globstrings + +You can use globstrings in the folder lists. For example to get all files with filenames ending with `_ui` and any file extension you can use: + +.. code-block:: yaml +:caption: config.yaml + +rules: - folders: - '~/Downloads/_\_ui._' +actions: - echo: '{path}' + +You can use globstrings to recurse through subdirectories (alternatively you can use the `subfolders: true` setting as shown below) + +.. code-block:: yaml +:caption: config.yaml + +rules: - folders: - '~/Downloads/\*_/_.\*' +actions: - echo: 'base {basedir}, path {path}, relative: {relative_path}' + + # alternative syntax + - folders: + - ~/Downloads + subfolders: true + actions: + - echo: 'base {basedir}, path {path}, relative: {relative_path}' + +The following example recurses through all subdirectories in your downloads folder and finds files with ending in `.c` and `.h`. + +.. code-block:: yaml +:caption: config.yaml + +rules: - folders: - '~/Downloads/\*_/_.[c|h]' +actions: - echo: '{path}' + +.. note:: + +- You have to target files with the globstring, not folders. So to scan through all folders starting with \_log\__ you would write `yourpath/log__/_` + +### Excluding files and folders + +Files and folders can be excluded by prepending an exclamation mark. The following example selects all files +in `~/Downloads` and its subfolders - excluding the folder `Software`: + +.. code-block:: yaml +:caption: config.yaml + +rules: - folders: - '~/Downloads/\*_/_' - '! ~/Downloads/Software' +actions: - echo: '{path}' + +Globstrings can be used to exclude only specific files / folders. This example: + +- adds all files in `~/Downloads` +- exludes files from that list whose name contains the word `system` ending in `.bak` +- adds all files from `~/Documents` +- excludes the file `~/Documents/important.txt`. + +.. code-block:: yaml +:caption: config.yaml + +rules: - folders: - '~/Downloads/**/\*' - '! ~/Downloads/**/_system_.bak' - '~/Documents' - '! ~/Documents/important.txt' +actions: - echo: '{path}' + +.. note:: + +- Files and folders are included and excluded in the order you specify them! +- Please make sure your are putting the exclamation mark within quotation marks. + +### Aliases + +Instead of repeating the same folders in each and every rule you can use an alias for multiple folders which you can then reference in each rule. +Aliases are a standard feature of the YAML syntax. + +.. code-block:: yaml +:caption: config.yaml + +all_my_messy_folders: &all - ~/Desktop - ~/Downloads - ~/Documents - ~/Dropbox + +rules: - folders: \*all +filters: ... +actions: ... + + - folders: *all + filters: ... + actions: ... + +You can even use multiple folder lists: + +.. code-block:: yaml +:caption: config.yaml + +private_folders: &private - '/path/private' - '~/path/private' + +work_folders: &work - '/path/work' - '~/My work folder' + +all_folders: &all - *private - *work + +rules: - folders: \*private +filters: ... +actions: ... + + - folders: *work + filters: ... + actions: ... + + - folders: *all + filters: ... + actions: ... + + # same as *all + - folders: + - *work + - *private + filters: ... + actions: ... + +## Filter syntax + +`filters` is a list of :ref:`Filters`. +Filters are defined like this: + +.. code-block:: yaml +:caption: config.yaml + +rules: - folders: ... +actions: ... +filters: # filter without parameters - FilterName + + # filter with a single parameter + - FilterName: parameter + + # filter expecting a list as parameter + - FilterName: + - first + - second + - third + + # filter with multiple parameters + - FilterName: + parameter1: true + option2: 10.51 + third_argument: test string + +.. note:: +Every filter comes with multiple usage examples which should be easy to adapt for your use case! + +## Action syntax + +`actions` is a list of :ref:`Actions`. +Actions can be defined like this: + +.. code-block:: yaml +:caption: config.yaml + +rules: - folders: ... +actions: # action without parameters - ActionName + + # action with a single parameter + - ActionName: parameter + + # filter with multiple parameters + - ActionName: + parameter1: true + option2: 10.51 + third_argument: test string + +.. note:: +Every action comes with multiple usage examples which should be easy to adapt for your use case! + +### Variable substitution (placeholders) + +**You can use placeholder variables in your actions.** + +Placeholder variables are used with curly braces `{var}`. +You always have access to the variables `{path}`, `{basedir}` and `{relative_path}`: + +- `{path}` -- is the full path to the current file +- `{basedir}` -- the current base folder (the base folder is the folder you + specify in your configuration). +- `{relative_path}` -- the relative path from `{basedir}` to `{path}` + +Use the dot notation to access properties of `{path}`, `{basedir}` and `{relative_path}`: + +- `{path}` -- the full path to the current file +- `{path.name}` -- the full filename including extension +- `{path.stem}` -- just the file name without extension +- `{path.suffix}` -- the file extension +- `{path.parent}` -- the parent folder of the current file +- `{path.parent.parent}` -- parent calls are chainable... + +- `{basedir}` -- the full path to the current base folder +- `{basedir.parent}` -- the full path to the base folder's parent + +and any other property of the python `pathlib.Path` (`official documentation `\_) object. + +Additionally :ref:`Filters` may emit placeholder variables when applied to a +path. Check the documentation and examples of the filter to see available +placeholder variables and usage examples. + +Some examples include: + +- `{lastmodified.year}` -- the year the file was last modified +- `{regex.yournamedgroup}` -- anything you can extract via regular expressions +- `{extension.upper}` -- the file extension in uppercase +- ... and many more. diff --git a/docs/locations.md b/docs/02-locations.md similarity index 100% rename from docs/locations.md rename to docs/02-locations.md diff --git a/docs/filters.md b/docs/03-filters.md similarity index 84% rename from docs/filters.md rename to docs/03-filters.md index 29c4b404..a52660a5 100644 --- a/docs/filters.md +++ b/docs/03-filters.md @@ -1,5 +1,8 @@ # Filters +This page shows the specifics of each filter. For basic filter usage and options have a +look at the [Config](01-config.md) section. + ## created ::: organize.filters.Created diff --git a/docs/actions.md b/docs/04-actions.md similarity index 95% rename from docs/actions.md rename to docs/04-actions.md index e1a3462d..395ba2a0 100644 --- a/docs/actions.md +++ b/docs/04-actions.md @@ -1,5 +1,8 @@ # Actions +This page shows the specifics of each action. For basic action usage and options have a +look at the [Config](01-config.md) section. + ## copy ::: organize.actions.copy.Copy diff --git a/docs/changelog.md b/docs/05-changelog.md similarity index 100% rename from docs/changelog.md rename to docs/05-changelog.md diff --git a/docs/cli.md b/docs/cli.md deleted file mode 100644 index 69e4e8dd..00000000 --- a/docs/cli.md +++ /dev/null @@ -1 +0,0 @@ -# Command line interface diff --git a/docs/config.md b/docs/config.md deleted file mode 100644 index a025a48b..00000000 --- a/docs/config.md +++ /dev/null @@ -1 +0,0 @@ -# Configuration diff --git a/docs/sphinx/config.rst b/docs/sphinx/config.rst deleted file mode 100644 index ea89d4fa..00000000 --- a/docs/sphinx/config.rst +++ /dev/null @@ -1,347 +0,0 @@ -# Configuration - -Editing the configuration -========================= -All configuration takes place in your `config.yaml` file. - -- To edit your configuration in ``$EDITOR`` run: - - .. code-block:: bash - - $ organize config # example: "EDITOR=vim organize config" - -- To show the full path to your configuration file:: - - $ organize config --path - -- To open the folder containing the configuration file:: - - $ organize config --open-folder - -- To debug your configuration run:: - - $ organize config --debug - - -Environment variables -===================== - -- ``$EDITOR`` - The editor used to edit the config file. -- ``$ORGANIZE_CONFIG`` - The config file path. Is overridden by ``--config-file`` cmd line argument. - - -Rule syntax -=========== -The rule configuration is done in `YAML `_. -You need a top-level element ``rules`` which contains a list of rules. -Each rule defines ``folders``, ``filters`` (optional) and ``actions``. - -.. code-block:: yaml - :caption: config.yaml - :emphasize-lines: 1,2,5,10,14,16,18 - - rules: - - folders: - - ~/Desktop - - /some/folder/ - filters: - - lastmodified: - days: 40 - mode: newer - - extension: pdf - actions: - - move: ~/Desktop/Target/ - - trash - - - folders: - - ~/Inbox - filters: - - extension: pdf - actions: - - move: ~/otherinbox - # optional settings: - enabled: true - subfolders: true - system_files: false - -- ``folders`` is a list of folders you want to organize. -- ``filters`` is a list of filters to apply to the files - you can filter by file extension, last modified date, regular expressions and many more. See :ref:`Filters`. -- ``actions`` is a list of actions to apply to the filtered files. You can put them into the trash, move them into another folder and many more. See :ref:`Actions`. - -Other optional per rule settings: - -- ``enabled`` can be used to temporarily disable single rules. Default = true -- ``subfolders`` specifies whether subfolders should be included in the search. Default = false. This setting only applies to folders without glob wildcards. -- ``system_files`` specifies whether to include system files (desktop.ini, thumbs.db, .DS_Store) in the search. Default = false - - -Folder syntax -============= -Every rule in your configuration file needs to know the folders it applies to. -The easiest way is to define the rules like this: - -.. code-block:: yaml - :caption: config.yaml - - rules: - - folders: - - /path/one - - /path/two - filters: ... - actions: ... - - - folders: - - /path/one - - /another/path - filters: ... - actions: ... - -.. note:: - - You can use environment variables in your folder names. On windows this means you can use ``%public%/Desktop``, ``%APPDATA%``, ``%PROGRAMDATA%`` etc. - -Globstrings ------------ -You can use globstrings in the folder lists. For example to get all files with filenames ending with ``_ui`` and any file extension you can use: - -.. code-block:: yaml - :caption: config.yaml - - rules: - - folders: - - '~/Downloads/*_ui.*' - actions: - - echo: '{path}' - -You can use globstrings to recurse through subdirectories (alternatively you can use the ``subfolders: true`` setting as shown below) - -.. code-block:: yaml - :caption: config.yaml - - rules: - - folders: - - '~/Downloads/**/*.*' - actions: - - echo: 'base {basedir}, path {path}, relative: {relative_path}' - - # alternative syntax - - folders: - - ~/Downloads - subfolders: true - actions: - - echo: 'base {basedir}, path {path}, relative: {relative_path}' - - -The following example recurses through all subdirectories in your downloads folder and finds files with ending in ``.c`` and ``.h``. - -.. code-block:: yaml - :caption: config.yaml - - rules: - - folders: - - '~/Downloads/**/*.[c|h]' - actions: - - echo: '{path}' - -.. note:: - - You have to target files with the globstring, not folders. So to scan through all folders starting with *log_* you would write ``yourpath/log_*/*`` - - -Excluding files and folders ---------------------------- -Files and folders can be excluded by prepending an exclamation mark. The following example selects all files -in ``~/Downloads`` and its subfolders - excluding the folder ``Software``: - -.. code-block:: yaml - :caption: config.yaml - - rules: - - folders: - - '~/Downloads/**/*' - - '! ~/Downloads/Software' - actions: - - echo: '{path}' - - -Globstrings can be used to exclude only specific files / folders. This example: - - - adds all files in ``~/Downloads`` - - exludes files from that list whose name contains the word ``system`` ending in ``.bak`` - - adds all files from ``~/Documents`` - - excludes the file ``~/Documents/important.txt``. - -.. code-block:: yaml - :caption: config.yaml - - rules: - - folders: - - '~/Downloads/**/*' - - '! ~/Downloads/**/*system*.bak' - - '~/Documents' - - '! ~/Documents/important.txt' - actions: - - echo: '{path}' - -.. note:: - - Files and folders are included and excluded in the order you specify them! - - Please make sure your are putting the exclamation mark within quotation marks. - - -Aliases -------- -Instead of repeating the same folders in each and every rule you can use an alias for multiple folders which you can then reference in each rule. -Aliases are a standard feature of the YAML syntax. - -.. code-block:: yaml - :caption: config.yaml - - - all_my_messy_folders: &all - - ~/Desktop - - ~/Downloads - - ~/Documents - - ~/Dropbox - - rules: - - folders: *all - filters: ... - actions: ... - - - folders: *all - filters: ... - actions: ... - -You can even use multiple folder lists: - -.. code-block:: yaml - :caption: config.yaml - - private_folders: &private - - '/path/private' - - '~/path/private' - - work_folders: &work - - '/path/work' - - '~/My work folder' - - all_folders: &all - - *private - - *work - - rules: - - folders: *private - filters: ... - actions: ... - - - folders: *work - filters: ... - actions: ... - - - folders: *all - filters: ... - actions: ... - - # same as *all - - folders: - - *work - - *private - filters: ... - actions: ... - - -Filter syntax -============= -``filters`` is a list of :ref:`Filters`. -Filters are defined like this: - -.. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ... - actions: ... - filters: - # filter without parameters - - FilterName - - # filter with a single parameter - - FilterName: parameter - - # filter expecting a list as parameter - - FilterName: - - first - - second - - third - - # filter with multiple parameters - - FilterName: - parameter1: true - option2: 10.51 - third_argument: test string - -.. note:: - Every filter comes with multiple usage examples which should be easy to adapt for your use case! - - -Action syntax -============= -``actions`` is a list of :ref:`Actions`. -Actions can be defined like this: - -.. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ... - actions: - # action without parameters - - ActionName - - # action with a single parameter - - ActionName: parameter - - # filter with multiple parameters - - ActionName: - parameter1: true - option2: 10.51 - third_argument: test string - -.. note:: - Every action comes with multiple usage examples which should be easy to adapt for your use case! - -Variable substitution (placeholders) ------------------------------------- -**You can use placeholder variables in your actions.** - -Placeholder variables are used with curly braces ``{var}``. -You always have access to the variables ``{path}``, ``{basedir}`` and ``{relative_path}``: - -- ``{path}`` -- is the full path to the current file -- ``{basedir}`` -- the current base folder (the base folder is the folder you - specify in your configuration). -- ``{relative_path}`` -- the relative path from ``{basedir}`` to ``{path}`` - -Use the dot notation to access properties of ``{path}``, ``{basedir}`` and ``{relative_path}``: - -- ``{path}`` -- the full path to the current file -- ``{path.name}`` -- the full filename including extension -- ``{path.stem}`` -- just the file name without extension -- ``{path.suffix}`` -- the file extension -- ``{path.parent}`` -- the parent folder of the current file -- ``{path.parent.parent}`` -- parent calls are chainable... - -- ``{basedir}`` -- the full path to the current base folder -- ``{basedir.parent}`` -- the full path to the base folder's parent - -and any other property of the python ``pathlib.Path`` (`official documentation -`_) object. - -Additionally :ref:`Filters` may emit placeholder variables when applied to a -path. Check the documentation and examples of the filter to see available -placeholder variables and usage examples. - -Some examples include: - -- ``{lastmodified.year}`` -- the year the file was last modified -- ``{regex.yournamedgroup}`` -- anything you can extract via regular expressions -- ``{extension.upper}`` -- the file extension in uppercase -- ... and many more. diff --git a/docs/sphinx/quickstart.rst b/docs/sphinx/quickstart.rst deleted file mode 100644 index 824ff2e3..00000000 --- a/docs/sphinx/quickstart.rst +++ /dev/null @@ -1,64 +0,0 @@ -Quickstart -========== - -Installation ------------- -Requirements: Python 3.6+ - -`organize` is installed via pip: - -``$ pip install organize-tool`` - -If you want all the text extraction capabilities, install with `textract` like this: - -``$ pip3 -U "organize-tool[textract]"`` - - -Creating your first config file -------------------------------- -To edit the configuration in your $EDITOR, run: - - ``$ organize config`` - -For example your configuration file could look like this: - -.. code-block:: yaml - :caption: config.yaml - - rules: - # move screenshots into "Screenshots" folder - - folders: - - ~/Desktop - filters: - - filename: - startswith: Screen Shot - actions: - - move: ~/Desktop/Screenshots/ - - # move incomplete downloads older > 30 days into the trash - - folders: - - ~/Downloads - filters: - - extension: - - crdownload - - part - - download - - lastmodified: - days: 30 - actions: - - trash - -.. note:: - You can run ``$ organize config --path`` to show the full path to the configuration file. - - -Simulate and run ----------------- -After you saved the configuration file, run ``$ organize sim`` to show a simulation of how your files would be organized. - -If you like what you see, run ``$ organize run`` to organize your files. - -.. note:: - Congrats! You just automated some tedious cleaning tasks! - Continue to :ref:`Configuration` to see the full potential of organize or skip - directly to the :ref:`Filters` and :ref:`Actions`. diff --git a/mkdocs.yml b/mkdocs.yml index 5ec348fd..dfa0b6ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,11 +1,11 @@ site_name: organize nav: - Home: index.md - - Configuration: config.md - - Locations: locations.md - - Filters: filters.md - - Actions: actions.md - - Changelog: changelog.md + - Configuration: 01-config.md + - Locations: 02-locations.md + - Filters: 03-filters.md + - Actions: 04-actions.md + - Changelog: 05-changelog.md plugins: - search - include-markdown From 1ef6ef939c72d6ef19d7feb949f1ed0a7a767b0c Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Wed, 26 Jan 2022 11:58:49 +0100 Subject: [PATCH 041/108] validate all example configs --- CHANGELOG.md | 32 ++++++----- docs/04-actions.md | 103 ++++++++++++++++++++++++++++-------- docs/06-updating-from-v1.md | 70 ++++++++++++++++++++++++ mkdocs.yml | 1 + organize/actions/copy.py | 87 ++++++++---------------------- tests/test_doc_examples.py | 25 +++++++++ 6 files changed, 219 insertions(+), 99 deletions(-) create mode 100644 docs/06-updating-from-v1.md create mode 100644 tests/test_doc_examples.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 525bf072..efe683d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,27 +7,35 @@ Please backup all your important stuff before running. ### what's new -- completely rewritten core! -- respects your rule order - safer, less magic, less surprises. +- Completely rewritten core! +- Respects your rule order - safer, less magic, less surprises. (v1 tried to be clever. v2 now works your config file from top to bottom) -- Now you can organize (S)FTP, S3 Buckets, Zip archives and many more! - (https://www.pyfilesystem.org/page/index-of-filesystems/) -- Most of the actions like `move` and `copy` even work across file systems! -- You can now target folders with your rules! Like copying a whole folder, renaming etc. +- Now you can organize (S)FTP, S3 Buckets, Zip archives and many more. + - Most of the actions like `move` and `copy` even work across file systems! + - [Available filesystems](https://www.pyfilesystem.org/page/index-of-filesystems/) +- You can now target folders with your rules. Like copying a whole folder, renaming etc. - `max_depth` setting when recursing into subfolders -- starts instantly (does not need to gather all the folders before starting) -- nice terminal output and rule names +- starts instantly (does not need to gather all the files before starting) +- nice terminal output +- rule names - cleaner config file validation and stricter format -- "confirm" and "prompt" action -- `rename_template` option in `move` and `copy` - option to run `python` actions in simulation +- added `empty` filter. +- new conflict resolution settings in `move`, `copy` and `rename` action: + `skip`, `overwrite`, `trash`, `rename_new`, `rename_existing` as well as a + `rename_template` parameter. +- the `shell` action now returns stdout and errorcode. ### changed -- The config file format got a long due overhaul. Please see the migration documentation - for what is new. +- The config file format got a long due overhaul. Please see the + [migration documentation](docs/06-updating-from-v1.md) for what is new. - The `timezone` keyword for `lastmodified` and `created` was removed. The timezone is now the local timezone by default. +- The `filesize` filter was renamed to `size` and can now be used to get directory sizes + as well. +- The `filename` filter was renamed to `name` and can now be used to get directory names + as well. ### removed diff --git a/docs/04-actions.md b/docs/04-actions.md index 395ba2a0..4c435781 100644 --- a/docs/04-actions.md +++ b/docs/04-actions.md @@ -7,20 +7,80 @@ look at the [Config](01-config.md) section. ::: organize.actions.copy.Copy +**Examples** + +Copy all pdfs into `~/Desktop/somefolder/` and keep filenames + +```yaml +rules: + - locations: ~/Desktop + filters: + - extension: pdf + actions: + - copy: "~/Desktop/somefolder/" +``` + +Use a placeholder to copy all .pdf files into a "PDF" folder and all .jpg files into a "JPG" folder. Existing files will be overwritten. + +```yaml +rules: + - locations: ~/Desktop + filters: + - extension: + - pdf + - jpg + actions: + - copy: + dest: "~/Desktop/{extension.upper}/" + on_conflict: overwrite +``` + +Copy into the folder `Invoices`. Keep the filename but do not overwrite existing files. +To prevent overwriting files, an index is added to the filename, so `somefile.jpg` becomes `somefile 2.jpg`. +The counter separator is `' '` by default, but can be changed using the `counter_separator` property. + +```yaml +rules: + - locations: ~/Desktop/Invoices + filters: + - extension: + - pdf + actions: + - copy: + dest: "~/Documents/Invoices/" + on_conflict: "rename_new" + rename_template: "{name} {counter}{extension}" +``` + ## delete ::: organize.actions.delete.Delete +**Examples** + ```yaml rules: - locations: "~/Downloads" - - filters: + filters: - lastmodified: days: 365 - extension: - png - jpg - - actions: + actions: + - delete +``` + +````yaml +rules: + - name: Delete all empty subfolders + locations: + - path: "~/Downloads" + max_depth: null + targets: dirs + filters: + - empty + actions: - delete ``` @@ -29,17 +89,17 @@ rules: ::: organize.actions.Echo
Examples -Prints "Found old file" for each file older than one year: ```yaml rules: - - locations: ~/Desktop + - name: "Find files older than a year" + locations: ~/Desktop filters: - lastmodified: days: 365 actions: - echo: "Found old file" -``` +```` Prints "Hello World!" and filepath for each file on the desktop: @@ -91,16 +151,15 @@ rules:
Examples -Add a single tag - ```yaml rules: - - locations: "~/Documents/Invoices" - - filters: - - filename: + - name: "add a single tag" + locations: "~/Documents/Invoices" + filters: + - name: startswith: "Invoice" - extension: pdf - - actions: + actions: - macos_tags: Invoice ``` @@ -109,11 +168,11 @@ Adding multiple tags ("Invoice" and "Important") ```yaml rules: - locations: "~/Documents/Invoices" - - filters: - - filename: + filters: + - name: startswith: "Invoice" - extension: pdf - - actions: + actions: - macos_tags: - Important - Invoice @@ -124,11 +183,11 @@ Specify tag colors ```yaml rules: - locations: "~/Documents/Invoices" - - filters: - - filename: + filters: + - name: startswith: "Invoice" - extension: pdf - - actions: + actions: - macos_tags: - Important (green) - Invoice (purple) @@ -139,9 +198,9 @@ Add a templated tag with color ```yaml rules: - locations: "~/Documents/Invoices" - - filters: + filters: - created - - actions: + actions: - macos_tags: - Year-{created.year} (red) ``` @@ -173,14 +232,14 @@ Example: ```yaml rules: - name: Move all JPGs and PNGs on the desktop which are older than one year into the trash - - locations: "~/Desktop" - - filters: + locations: "~/Desktop" + filters: - lastmodified: years: 1 mode: older - extension: - png - jpg - - actions: + actions: - trash ``` diff --git a/docs/06-updating-from-v1.md b/docs/06-updating-from-v1.md new file mode 100644 index 00000000..0905b580 --- /dev/null +++ b/docs/06-updating-from-v1.md @@ -0,0 +1,70 @@ +# Updating from organize v1.x + +First of all, thank you for being a long time user of `organize`. + +As this project is only maintained by the single person writing this article it is not +feasible to write an automatic config file migration or a compatibility layer. +Many people use organize for important documents and personal files, this is not +something I want to half-ass. + +So if you want all the new goodies, you'll need to do some changes in your config. +Otherwise feel free to pin organize to the latest v1.x. + +## Config + +- `folders` must be renamed to `locations`. New options: [Locations](02-locations.md). + - the **glob syntax** (eg. `"~/Documents/**"`) has been removed. + - the **exclamation mark exclude** (eg. `"! ~/Desktop"`) syntax has been removed. + - They are replaced by the `max_depth`, `exclude_files`, `exclude_dirs`, `filter` and + `filter_dirs` settings. See [Locations](02-locations.md). +- the `subfolders` setting is removed and replaced by the `max_depth` setting + of a specific location. +- You can now name your rules via `name`. +- The `enabled` setting has been removed. # TODO + +organize v1.x: + +```yaml +rules: + # find some pdf files in various dirs and echo "Hello" for each one + - folders: + - "~/Desktop/**/*.pdf" + - "! ~/Desktop/donotmove/*" + subfolders: true + ... + + # move all pdfs into documents + - folders: + - "~/Downloads/*.pdf" + ... +``` + +becomes (organize v2.x) + +```yaml +rules: + - name: find some pdf files in various dirs and echo "Hello" for each one + locations: + - path: ~/Desktop/ + max_depth: null + filter: "*.pdf" + exclude_dirs: donotmove + ... + + - name: move all pdfs into documents + locations: "~/Downloads" + filters: + - extension: pdf + ... +``` + +## Filters + +- [`created`](03-filters.md#created) no longer accepts a timezone and uses the local timezone by default. +- [`lastmodified`](03-filters.md#lastmodified) no longer accepts a timezone and uses the local timezone by default. +- [`filename`](03-filters.md#name) is renamed to `name`. +- [`filesize`](03-filters.md#size) is renamed to `size`. + +## Actions + +- [`copy`](04-actions.md#copy) arguments changed. diff --git a/mkdocs.yml b/mkdocs.yml index dfa0b6ae..87dbbde7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,6 +6,7 @@ nav: - Filters: 03-filters.md - Actions: 04-actions.md - Changelog: 05-changelog.md + - Updating from organize v1.x: 06-updating-from-v1.md plugins: - search - include-markdown diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 7df73535..bbdb5a34 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -16,73 +16,30 @@ class Copy(Action): - """ - Copy a file to a new location. + """Copy a file or dir to a new location. + If the specified path does not exist it will be created. - :param str dest: - The destination where the file should be copied to. - If `dest` ends with a slash / backslash, the file will be copied into - this folder and keep its original name. - - :param bool overwrite: - specifies whether existing files should be overwritten. - Otherwise it will start enumerating files (append a counter to the - filename) to resolve naming conflicts. [Default: False] - - :param str counter_separator: - specifies the separator between filename and the appended counter. - Only relevant if **overwrite** is disabled. [Default: ``\' \'``] - - Examples: - - Copy all pdfs into `~/Desktop/somefolder/` and keep filenames - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Desktop - filters: - - extension: pdf - actions: - - copy: '~/Desktop/somefolder/' - - - Use a placeholder to copy all .pdf files into a "PDF" folder and all .jpg - files into a "JPG" folder. Existing files will be overwritten. - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Desktop - filters: - - extension: - - pdf - - jpg - actions: - - copy: - dest: '~/Desktop/{extension.upper}/' - overwrite: true - - - Copy into the folder `Invoices`. Keep the filename but do not - overwrite existing files. To prevent overwriting files, an index is - added to the filename, so `somefile.jpg` becomes `somefile 2.jpg`. - The counter separator is `' '` by default, but can be changed using - the `counter_separator` property. - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Desktop/Invoices - filters: - - extension: - - pdf - actions: - - copy: - dest: '~/Documents/Invoices/' - overwrite: false - counter_separator: '_' + Args: + dest (str): + The destination where the file / dir should be copied to. + If `dest` ends with a slash, it is assumed to be a target directory + and the file / dir will be copied into `dest` and keep its name. + + on_conflict (str): + What should happen in case **dest** already exists. + One of `skip`, `overwrite`, `trash`, `rename_new` and `rename_existing`. + Defaults to `rename_new`. + + rename_template (str): + A template for renaming the file / dir in case of a conflict. + Defaults to `{name} {counter}{extension}`. + + dest_filesystem (str): + (Optional) A pyfilesystem opener url of the filesystem you want to copy to. + If this is not given, the local filesystem is used. + + The next action will work with the created copy. """ name = "copy" diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py new file mode 100644 index 00000000..30794c34 --- /dev/null +++ b/tests/test_doc_examples.py @@ -0,0 +1,25 @@ +import re + +import fs + +from organize.config import load_from_string, CONFIG_SCHEMA + +RE_CONFIG = re.compile(r"```yaml\n(?Prules:(?:.*?\n)+?)```", re.MULTILINE) + + +DOCS = ( + "03-filters.md", + "04-actions.md", +) + +docdir = fs.open_fs("docs") +for f in DOCS: + text = docdir.readtext(f) + for match in RE_CONFIG.findall(text): + try: + config = load_from_string(match) + CONFIG_SCHEMA.validate(config) + except Exception as e: + print("invalid config: ") + print(match) + print(str(e)) From 82791657f18463bb95893cb9c7cd968cc0e5b7ed Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 27 Jan 2022 11:48:01 +0100 Subject: [PATCH 042/108] documentation --- docs/03-filters.md | 49 ++++++++++++++++++ docs/06-updating-from-v1.md | 2 +- organize/filters/created.py | 93 +++++++---------------------------- organize/filters/duplicate.py | 17 ------- organize/filters/empty.py | 2 +- 5 files changed, 68 insertions(+), 95 deletions(-) diff --git a/docs/03-filters.md b/docs/03-filters.md index a52660a5..f467aee2 100644 --- a/docs/03-filters.md +++ b/docs/03-filters.md @@ -7,10 +7,59 @@ look at the [Config](01-config.md) section. ::: organize.filters.Created +**Examples** + +```yaml +- : rules: + - name: Show all files on your desktop created at least 10 days ago + folders: "~/Desktop" + filters: + - created: + days: 10 + actions: + - echo: "Was created at least 10 days ago" +``` + +```yaml +rules: + - name: Show all files on your desktop which were created within the last 5 hours + folders: "~/Desktop" + filters: + - created: + hours: 5 + mode: newer + actions: + - echo: "Was created within the last 5 hours" +``` + +```yaml +rules: + - name: Sort pdfs by year of creation + folders: "~/Documents" + filters: + - extension: pdf + - created + actions: + - move: "~/Documents/PDF/{created.year}/" +``` + ## duplicate ::: organize.filters.Duplicate +```yaml +rules: + - name: Show all duplicate files in your desktop and download folder (and their subfolders) + folders: + - ~/Desktop + - ~/Downloads + subfolders: true + filters: + - duplicate + actions: + - echo: "{path} is a duplicate of {duplicate}" +``` + ## empty ::: organize.filters.Empty diff --git a/docs/06-updating-from-v1.md b/docs/06-updating-from-v1.md index 0905b580..3e9de282 100644 --- a/docs/06-updating-from-v1.md +++ b/docs/06-updating-from-v1.md @@ -20,7 +20,7 @@ Otherwise feel free to pin organize to the latest v1.x. - the `subfolders` setting is removed and replaced by the `max_depth` setting of a specific location. - You can now name your rules via `name`. -- The `enabled` setting has been removed. # TODO +- The `enabled` setting has been removed. # TODO: ? organize v1.x: diff --git a/organize/filters/created.py b/organize/filters/created.py index 5bc90fe1..d82b4156 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -7,82 +7,23 @@ class Created(Filter): """ - Matches files by created date - - :param int years: - specify number of years - - :param int months: - specify number of months - - :param float weeks: - specify number of weeks - - :param float days: - specify number of days - - :param float hours: - specify number of hours - - :param float minutes: - specify number of minutes - - :param float seconds: - specify number of seconds - - :param str mode: - either 'older' or 'newer'. 'older' matches all files created before the given - time, 'newer' matches all files created within the given time. - (default = 'older') - - :returns: - - ``{created.year}`` -- the year the file was created - - ``{created.month}`` -- the month the file was created - - ``{created.day}`` -- the day the file was created - - ``{created.hour}`` -- the hour the file was created - - ``{created.minute}`` -- the minute the file was created - - ``{created.second}`` -- the second the file was created - - Examples: - - Show all files on your desktop created at least 10 days ago: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - created: - days: 10 - actions: - - echo: 'Was created at least 10 days ago' - - - Show all files on your desktop which were created within the last 5 hours: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - created: - hours: 5 - mode: newer - actions: - - echo: 'Was created within the last 5 hours' - - - Sort pdfs by year of creation: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Documents' - filters: - - extension: pdf - - created - actions: - - move: '~/Documents/PDF/{created.year}/' + Matches files / folders by created date + + Args: + years (int): specify number of years + months (int): specify number of months + weeks (float): specify number of weeks + days (float): specify number of days + hours (float): specify number of hours + minutes (float): specify number of minutes + seconds (float): specify number of seconds + mode (str): + either 'older' or 'newer'. 'older' matches all files created before the given + time, 'newer' matches all files created within the given time. + (default = 'older') + + Returns: + {created}: The datetime the file / dir was created. """ name = "created" diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index fae75f50..58404d63 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -47,23 +47,6 @@ class Duplicate(Filter): :returns: - ``{duplicate}`` -- path to the duplicate source - - Examples: - - Show all duplicate files in your desktop and download folder (and their - subfolders). - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: - - ~/Desktop - - ~/Downloads - subfolders: true - filters: - - duplicate - actions: - - echo: "{path} is a duplicate of {duplicate}" """ name = "duplicate" diff --git a/organize/filters/empty.py b/organize/filters/empty.py index 316c7d43..d1d1f282 100644 --- a/organize/filters/empty.py +++ b/organize/filters/empty.py @@ -5,7 +5,7 @@ class Empty(Filter): - """Only lets through empty dirs and folders""" + """Finds empty dirs and files""" name = "empty" From 0cb3d51deb3dffb0637b1c7c0b5673638ab547e4 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 27 Jan 2022 12:14:05 +0100 Subject: [PATCH 043/108] add symlink action --- CHANGELOG.md | 1 + docs/04-actions.md | 8 +++++-- organize/actions/__init__.py | 2 ++ organize/actions/symlink.py | 43 ++++++++++++++++++++++++++++++++++++ testconf.yaml | 7 +++--- 5 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 organize/actions/symlink.py diff --git a/CHANGELOG.md b/CHANGELOG.md index efe683d2..01104356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Please backup all your important stuff before running. `skip`, `overwrite`, `trash`, `rename_new`, `rename_existing` as well as a `rename_template` parameter. - the `shell` action now returns stdout and errorcode. +- Added `symlink` action ### changed diff --git a/docs/04-actions.md b/docs/04-actions.md index 4c435781..633addf6 100644 --- a/docs/04-actions.md +++ b/docs/04-actions.md @@ -71,7 +71,7 @@ rules: - delete ``` -````yaml +```yaml rules: - name: Delete all empty subfolders locations: @@ -99,7 +99,7 @@ rules: days: 365 actions: - echo: "Found old file" -```` +``` Prints "Hello World!" and filepath for each file on the desktop: @@ -223,6 +223,10 @@ rules: ::: organize.actions.Shell +## symlink + +::: organize.actions.Symlink + ## trash ::: organize.actions.Trash diff --git a/organize/actions/__init__.py b/organize/actions/__init__.py index 3ebe70bf..20863509 100644 --- a/organize/actions/__init__.py +++ b/organize/actions/__init__.py @@ -8,6 +8,7 @@ from .python import Python from .rename import Rename from .shell import Shell +from .symlink import Symlink from .trash import Trash ACTIONS = { @@ -20,5 +21,6 @@ Python.name: Python, Rename.name: Rename, Shell.name: Shell, + Symlink.name: Symlink, Trash.name: Trash, } diff --git a/organize/actions/symlink.py b/organize/actions/symlink.py new file mode 100644 index 00000000..f4770076 --- /dev/null +++ b/organize/actions/symlink.py @@ -0,0 +1,43 @@ +import os +import logging +from fs.base import FS +from fs import path +from fs.osfs import OSFS +from .action import Action +from organize.utils import JinjaEnv + +logger = logging.getLogger(__name__) + + +class Symlink(Action): + + """Create a symbolic link. + + Args: + dest (str): + The symlink destination. If **dest** ends with a slash `/``, create the + symlink in the given directory. Can contain placeholders. + + Only the local filesystem is supported. + """ + + name = "symlink" + + def __init__(self, dest): + self._dest = JinjaEnv.from_string(dest) + + def pipeline(self, args: dict, simulate: bool): + fs = args["fs"] # type: FS + fs_path = args["fs_path"] # type: str + + if not isinstance(fs, OSFS): + raise EnvironmentError("Symlinks only work on the local filesystem.") + + dest = os.path.expanduser(self._dest.render(**args)) + if dest.endswith("/"): + dest = path.join(dest, path.basename(fs_path)) + + self.print("Creating symlink: %s" % dest) + if not simulate: + os.makedirs(os.path.dirname(dest), exist_ok=True) + os.symlink(fs.getsyspath(fs_path), dest) diff --git a/testconf.yaml b/testconf.yaml index 3ec07f08..ae1c8ee7 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -1,14 +1,13 @@ rules: - name: "Find the last modification date of some png files" - targets: dirs + targets: files locations: - path: ~/Desktop/test max_depth: null filters: - - empty + - name actions: - - echo: "{path}" - - delete + - symlink: ~/Desktop/symlinks/{name}{name} # - name: Find some folders # targets: dirs From 3b7eebb3eb0c3f410d0e3a1a007f4d24ab71241f Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 27 Jan 2022 16:39:27 +0100 Subject: [PATCH 044/108] performance optimizations --- organize/filters/duplicate.py | 150 +++++++++++++++++----------------- 1 file changed, 77 insertions(+), 73 deletions(-) diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index 58404d63..21185407 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -8,34 +8,37 @@ https://gist.github.com/tfeldmann/fc875e6630d11f2256e746f67a09c1ae """ import hashlib -import os from collections import defaultdict -from typing import DefaultDict as DDict -from typing import Dict, List, Set, Tuple, Union - -from organize.utils import fullpath +from fs.base import FS +from typing import Dict, Set, Union, NamedTuple +from organize.output import console from .filter import Filter +HASH_ALGORITHM = "sha1" + + +class File(NamedTuple): + fs: FS + path: str + + +def getsize(f: File): + return f.fs.getsize(f.path) + -def chunk_reader(fobj, chunk_size=1024): - """Generator that reads a file in chunks of bytes""" - while True: - chunk = fobj.read(chunk_size) - if not chunk: - return - yield chunk +def full_hash(f: File): + return f.fs.hash(f.path, name=HASH_ALGORITHM) -def get_hash(filename, first_chunk_only=False, hash_algo=hashlib.sha1): - hashobj = hash_algo() - with open(filename, "rb") as f: - if first_chunk_only: - hashobj.update(f.read(1024)) - else: - for chunk in chunk_reader(f): - hashobj.update(chunk) - return hashobj.digest() +def first_chunk_hash(f: File): + hash_object = hashlib.new(HASH_ALGORITHM) + with f.fs.openbin(f.path) as file_: + hash_object.update(file_.read(1024)) + return hash_object.hexdigest() + + +ORDER_BY = ("location", "created", "lastmodified", "name") class Duplicate(Filter): @@ -50,79 +53,80 @@ class Duplicate(Filter): """ name = "duplicate" + schema_support_instance_without_args = True - def __init__(self) -> None: - self.files_for_size = defaultdict(list) # type: DDict[int, List[str]] + def __init__(self, order_by="location") -> None: + self.files_for_size = defaultdict(list) + self.files_for_size # type: DDict[int, List[PyFSFile]] - # to prevent false positives the keys must be tuples of (file_size, hash). - self.files_for_small_hash = defaultdict( - list - ) # type: DDict[Tuple[int, bytes], List[str]] - self.file_for_full_hash = dict() # type: Dict[Tuple[int, bytes], str] + self.files_for_chunk = defaultdict(list) + self.files_for_chunk # type: Dict[str, List[PyFSFile]] - # we keep track of which files we already computed the hashes for so we only do - # that once. - self.small_hash_known = set() # type: Set[str] - self.full_hash_known = set() # type: Set[str] + self.file_for_hash = dict() + self.file_for_hash # type: Dict[str, PyFSFile] - def matches(self, path: str) -> Union[bool, Dict[str, str]]: - # the exact same path has already been handled. This might happen if path is a - # symlink which resolves to file that is already known. We skip these. - if path in self.small_hash_known: + # we keep track of the files we already computed the hashes for so we only do + # that once. + self.first_chunk_known = set() # type: Set[PyFSFile] + self.hash_known = set() # type: Set[PyFSFile] + + def matches(self, fs: FS, path: str) -> Union[bool, Dict[str, str]]: + file_ = File(fs=fs, path=path) + # the exact same path has already been handled. This happens if multiple + # locations emit this file in a single rule. We skip these. + if file_ in self.first_chunk_known: return False # check for files with equal size - file_size = os.path.getsize(path) # type: int + file_size = getsize(file_) same_size = self.files_for_size[file_size] - candidates_fsize = same_size[:] - same_size.append(path) - if not candidates_fsize: + same_size.append(file_) + if len(same_size) == 1: # the file is unique in size and cannot be a duplicate return False - # for all other files with the same file size, get their hash of the first 1024 - # bytes - for c in candidates_fsize: - if c not in self.small_hash_known: - try: - c_small_hash = get_hash(c, first_chunk_only=True) - self.files_for_small_hash[(file_size, c_small_hash)].append(c) - self.small_hash_known.add(c) - except OSError: - pass - - # check small hash collisions with the current file - small_hash = get_hash(path, first_chunk_only=True) - same_small_hash = self.files_for_small_hash[(file_size, small_hash)] - candidates_shash = same_small_hash[:] - same_small_hash.append(path) - self.small_hash_known.add(path) - if not candidates_shash: + # for all other files with the same file size: + # make sure we know their hash of their first 1024 byte chunk + for f in same_size[:-1]: + if f not in self.first_chunk_known: + chunk_hash = first_chunk_hash(f) + self.first_chunk_known.add(f) + self.files_for_chunk[chunk_hash].append(f) + + # check first chunk hash collisions with the current file + chunk_hash = first_chunk_hash(file_) + same_first_chunk = self.files_for_chunk[chunk_hash] + same_first_chunk.append(file_) + self.first_chunk_known.add(file_) + if len(same_first_chunk) == 1: # the file has a unique small hash and cannot be a duplicate return False - # For all other files with the same file size and small hash get the full hash - for c in candidates_shash: - if c not in self.full_hash_known: - try: - c_full_hash = get_hash(c, first_chunk_only=False) - self.file_for_full_hash[(file_size, c_full_hash)] = c - self.full_hash_known.add(c) - except OSError: - pass + # Ensure we know the full hashes of all files with the same first chunk as + # the investigated file + for f in same_first_chunk[:-1]: + if f not in self.hash_known: + hash_ = full_hash(f) + self.hash_known.add(f) + self.file_for_hash[hash_] = f # check full hash collisions with the current file - full_hash = get_hash(path, first_chunk_only=False) - duplicate = self.file_for_full_hash.get((file_size, full_hash)) - if duplicate: - return {"duplicate": duplicate} - self.file_for_full_hash[(file_size, full_hash)] = path + hash_ = full_hash(file_) + original = self.file_for_hash.get(hash_) + if original: + return {"duplicate": original} + return False def pipeline(self, args): fs = args["fs"] fs_path = args["fs_path"] - return self.matches(fs.getsyspath(fs_path)) + if fs.isdir(fs_path): + raise EnvironmentError("Dirs are not supported") + try: + return self.matches(fs=fs, path=fs_path) + except Exception: + console.print_exception() def __str__(self) -> str: return "Duplicate()" From f555335cae610727868e4fc787a9ff4f1cf8aa50 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 27 Jan 2022 16:39:38 +0100 Subject: [PATCH 045/108] JinjaEnv -> Template --- docs/06-updating-from-v1.md | 6 ++++++ organize/actions/action.py | 14 -------------- organize/actions/copy.py | 8 ++++---- organize/actions/echo.py | 4 ++-- organize/actions/move.py | 8 ++++---- organize/actions/rename.py | 6 +++--- organize/actions/shell.py | 4 ++-- organize/actions/symlink.py | 4 ++-- organize/core.py | 12 ++++++------ organize/filters/hash.py | 4 ++-- organize/utils.py | 12 ++++++++++-- testconf.yaml | 11 ++++++++--- 12 files changed, 49 insertions(+), 44 deletions(-) diff --git a/docs/06-updating-from-v1.md b/docs/06-updating-from-v1.md index 3e9de282..bbdd2f1c 100644 --- a/docs/06-updating-from-v1.md +++ b/docs/06-updating-from-v1.md @@ -10,6 +10,12 @@ something I want to half-ass. So if you want all the new goodies, you'll need to do some changes in your config. Otherwise feel free to pin organize to the latest v1.x. + + ## Config - `folders` must be renamed to `locations`. New options: [Locations](02-locations.md). diff --git a/organize/actions/action.py b/organize/actions/action.py index 22cc214d..0f4205ab 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -6,10 +6,6 @@ class Error(Exception): pass -class TemplateAttributeError(Error): - pass - - class Action: print_hook = None # type: Optional[Callable] print_error_hook = None # type: Optional[Callable] @@ -60,16 +56,6 @@ def print_error(self, msg: str): if callable(self.print_error_hook): self.print_error_hook(name=self.name, msg=msg) - @staticmethod - def fill_template_tags(msg: str, args) -> str: - try: - return msg.format(**args) - except AttributeError as exc: - cause = exc.args[0] - raise TemplateAttributeError( - 'Missing template variable %s for "%s"' % (cause, msg) - ) - def __str__(self) -> str: return self.__class__.__name__ diff --git a/organize/actions/copy.py b/organize/actions/copy.py index bbdb5a34..7afd2bd4 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -6,7 +6,7 @@ from fs.path import basename, dirname, join from schema import Optional, Or -from organize.utils import JinjaEnv, file_desc +from organize.utils import Template, file_desc from .action import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict @@ -65,9 +65,9 @@ def __init__( "on_conflict must be one of %s" % ", ".join(CONFLICT_OPTIONS) ) - self.dest = JinjaEnv.from_string(dest) + self.dest = Template.from_string(dest) self.conflict_mode = on_conflict - self.rename_template = JinjaEnv.from_string(rename_template) + self.rename_template = Template.from_string(rename_template) self.dest_filesystem = dest_filesystem def pipeline(self, args: dict, simulate: bool): @@ -83,7 +83,7 @@ def pipeline(self, args: dict, simulate: bool): dst_fs_ = self.dest_filesystem # render if we have a template if isinstance(dst_fs_, str): - dst_fs_ = JinjaEnv.from_string(dst_fs_).render(**args) + dst_fs_ = Template.from_string(dst_fs_).render(**args) dst_fs = open_fs(dst_fs_, writeable=True, create=True) dst_path = dst_path else: diff --git a/organize/actions/echo.py b/organize/actions/echo.py index 78002a8a..6567a079 100644 --- a/organize/actions/echo.py +++ b/organize/actions/echo.py @@ -4,7 +4,7 @@ logger = logging.getLogger(__name__) -from ..utils import JinjaEnv +from ..utils import Template class Echo(Action): @@ -25,7 +25,7 @@ def get_schema(cls): return {cls.name: str} def __init__(self, msg) -> None: - self.msg = JinjaEnv.from_string(msg) + self.msg = Template.from_string(msg) self.log = logging.getLogger(__name__) def pipeline(self, args: dict, simulate: bool) -> None: diff --git a/organize/actions/move.py b/organize/actions/move.py index 89afd4c6..c8c4c6bc 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -6,7 +6,7 @@ from fs.path import basename, dirname, join from schema import Optional, Or -from organize.utils import JinjaEnv, file_desc +from organize.utils import Template, file_desc from .action import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict @@ -112,9 +112,9 @@ def __init__( "on_conflict must be one of %s" % ", ".join(CONFLICT_OPTIONS) ) - self.dest = JinjaEnv.from_string(dest) + self.dest = Template.from_string(dest) self.conflict_mode = on_conflict - self.rename_template = JinjaEnv.from_string(rename_template) + self.rename_template = Template.from_string(rename_template) self.dest_filesystem = dest_filesystem def pipeline(self, args: dict, simulate: bool): @@ -130,7 +130,7 @@ def pipeline(self, args: dict, simulate: bool): dst_fs_ = self.dest_filesystem # render if we have a template if isinstance(dst_fs_, str): - dst_fs_ = JinjaEnv.from_string(dst_fs_).render(**args) + dst_fs_ = Template.from_string(dst_fs_).render(**args) dst_fs = open_fs(dst_fs_, writeable=True, create=True) dst_path = dst_path else: diff --git a/organize/actions/rename.py b/organize/actions/rename.py index 43dc7a7e..691190af 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -6,7 +6,7 @@ from fs.move import move_dir, move_file from schema import Optional, Or -from organize.utils import JinjaEnv, file_desc +from organize.utils import Template, file_desc from .action import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict @@ -79,9 +79,9 @@ def __init__( "on_conflict must be one of %s" % ", ".join(CONFLICT_OPTIONS) ) - self.new_name = JinjaEnv.from_string(new_name) + self.new_name = Template.from_string(new_name) self.conflict_mode = on_conflict - self.rename_template = JinjaEnv.from_string(rename_template) + self.rename_template = Template.from_string(rename_template) def pipeline(self, args: dict, simulate: bool): fs = args["fs"] # type: FS diff --git a/organize/actions/shell.py b/organize/actions/shell.py index 5517a62e..2207ac73 100644 --- a/organize/actions/shell.py +++ b/organize/actions/shell.py @@ -4,7 +4,7 @@ import subprocess from subprocess import PIPE -from ..utils import JinjaEnv +from ..utils import Template from .action import Action logger = logging.getLogger(__name__) @@ -41,7 +41,7 @@ class Shell(Action): ) def __init__(self, cmd: str, run_in_simulation=False, ignore_errors=False): - self.cmd = JinjaEnv.from_string(cmd) + self.cmd = Template.from_string(cmd) self.run_in_simulation = run_in_simulation self.ignore_errors = ignore_errors diff --git a/organize/actions/symlink.py b/organize/actions/symlink.py index f4770076..e2689839 100644 --- a/organize/actions/symlink.py +++ b/organize/actions/symlink.py @@ -4,7 +4,7 @@ from fs import path from fs.osfs import OSFS from .action import Action -from organize.utils import JinjaEnv +from organize.utils import Template logger = logging.getLogger(__name__) @@ -24,7 +24,7 @@ class Symlink(Action): name = "symlink" def __init__(self, dest): - self._dest = JinjaEnv.from_string(dest) + self._dest = Template.from_string(dest) def pipeline(self, args: dict, simulate: bool): fs = args["fs"] # type: FS diff --git a/organize/core.py b/organize/core.py index 5c65fd36..70e1d73e 100644 --- a/organize/core.py +++ b/organize/core.py @@ -13,7 +13,7 @@ from .filters import FILTERS from .filters.filter import Filter from .output import RichOutput, console -from .utils import deep_merge_inplace, JinjaEnv, ensure_list +from .utils import deep_merge_inplace, Template, ensure_list logger = logging.getLogger(__name__) @@ -69,8 +69,8 @@ def instantiate_location(loc): else: walker = loc["walker"] - if "fs" in loc: - base_fs = loc["fs"] + if "filesystem" in loc: + base_fs = loc["filesystem"] path = loc.get("path", "/") else: base_fs = loc["path"] @@ -78,8 +78,8 @@ def instantiate_location(loc): return Location( walker=walker, - base_fs=fs.open_fs(JinjaEnv.from_string(base_fs).render(env=os.environ)), - path=JinjaEnv.from_string(path).render(env=os.environ), + base_fs=fs.open_fs(Template.from_string(base_fs).render(env=os.environ)), + path=Template.from_string(path).render(env=os.environ), ) @@ -200,7 +200,7 @@ def run(config, simulate: bool = True): # console.print(CONFIG_SCHEMA.json_schema(None)) CONFIG_SCHEMA.validate(conf) replace_with_instances(conf) - run(conf, simulate=False) + run(conf, simulate=True) except SchemaError as e: console.print("Invalid config file") console.print(e.autos[-1]) diff --git a/organize/filters/hash.py b/organize/filters/hash.py index 068a78c7..b662c14a 100644 --- a/organize/filters/hash.py +++ b/organize/filters/hash.py @@ -2,7 +2,7 @@ from fs.base import FS -from organize.utils import JinjaEnv +from organize.utils import Template from .filter import Filter @@ -41,7 +41,7 @@ class Hash(Filter): name = "hash" def __init__(self, algorithm="md5"): - self.algorithm = JinjaEnv.from_string(algorithm) + self.algorithm = Template.from_string(algorithm) def pipeline(self, args: dict): fs = args["fs"] # type: FS diff --git a/organize/utils.py b/organize/utils.py index 08ef5246..7b8e1ca8 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -7,9 +7,17 @@ from fs.base import FS from fs.osfs import OSFS from fs.errors import NoSysPath -from jinja2 import Environment, Template +from jinja2 import Environment +from jinja2.nativetypes import NativeEnvironment -JinjaEnv = Environment( +Template = Environment( + variable_start_string="{", + variable_end_string="}", + finalize=lambda x: x() if callable(x) else x, + autoescape=False, +) + +NativeTemplate = NativeEnvironment( variable_start_string="{", variable_end_string="}", finalize=lambda x: x() if callable(x) else x, diff --git a/testconf.yaml b/testconf.yaml index ae1c8ee7..c50b669c 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -2,12 +2,17 @@ rules: - name: "Find the last modification date of some png files" targets: files locations: - - path: ~/Desktop/test + - path: ~/Desktop/Testfolder max_depth: null + - path: "~/Desktop/Testfolder 2" + max_depth: null + - filesystem: zip:///Users/thomasfeldmann/Desktop/Testfolder.zip + max_depth: null + path: "/" filters: - - name + - duplicate actions: - - symlink: ~/Desktop/symlinks/{name}{name} + - echo: "{fs_path}" # - name: Find some folders # targets: dirs From 055c99cddf6cded8c65440eb81789e7043a226a8 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 28 Jan 2022 12:42:26 +0100 Subject: [PATCH 046/108] update docs --- docs/01-config.md | 2 +- docs/03-filters.md | 117 ++++++++++++++++++++++++++++++++-- organize/filters/duplicate.py | 33 +++++++--- organize/filters/mimetype.py | 52 --------------- organize/filters/python.py | 65 ------------------- organize/output.py | 4 +- organize/utils.py | 64 +++++++++++++++---- testconf.yaml | 2 + 8 files changed, 191 insertions(+), 148 deletions(-) diff --git a/docs/01-config.md b/docs/01-config.md index e572ecfc..57877ced 100644 --- a/docs/01-config.md +++ b/docs/01-config.md @@ -126,7 +126,7 @@ actions: - echo: '{path}' .. note:: -- You have to target files with the globstring, not folders. So to scan through all folders starting with \_log\__ you would write `yourpath/log__/_` +- You have to target files with the globstring, not folders. So to scan through all folders starting with \_log\__ you would write `yourpath/log\_\_/_` ### Excluding files and folders diff --git a/docs/03-filters.md b/docs/03-filters.md index f467aee2..a7f6b5c9 100644 --- a/docs/03-filters.md +++ b/docs/03-filters.md @@ -12,7 +12,7 @@ look at the [Config](01-config.md) section. ```yaml - : rules: - name: Show all files on your desktop created at least 10 days ago - folders: "~/Desktop" + locations: "~/Desktop" filters: - created: days: 10 @@ -23,7 +23,7 @@ look at the [Config](01-config.md) section. ```yaml rules: - name: Show all files on your desktop which were created within the last 5 hours - folders: "~/Desktop" + locations: "~/Desktop" filters: - created: hours: 5 @@ -35,7 +35,7 @@ rules: ```yaml rules: - name: Sort pdfs by year of creation - folders: "~/Documents" + locations: "~/Documents" filters: - extension: pdf - created @@ -50,10 +50,10 @@ rules: ```yaml rules: - name: Show all duplicate files in your desktop and download folder (and their subfolders) - folders: + locations: - ~/Desktop - ~/Downloads - subfolders: true + sublocations: true filters: - duplicate actions: @@ -109,10 +109,117 @@ rules: ::: organize.filters.MimeType +**Examples** + +```yaml +rules: + - name: "Show MIME types" + locations: "~/Downloads" + filters: + - mimetype + actions: + - echo: "{mimetype}" +``` + +```yaml +rules: + - name: "Filter by 'image' mimetype" + locations: "~/Downloads" + filters: + - mimetype: image + actions: + - echo: "This file is an image: {mimetype}" +``` + +```yaml +rules: + - name: Filter by specific MIME type + locations: "~/Desktop" + filters: + - mimetype: application/pdf + actions: + - echo: "Found a PDF file" +``` + +```yaml +rules: + - name: Filter by multiple specific MIME types + locations: "~/Music" + filters: + - mimetype: + - application/pdf + - audio/midi + actions: + - echo: "Found Midi or PDF." +``` + ## python ::: organize.filters.Python +**Examples** + +```yaml +rules: + - name: A file name reverser. + locations: ~/Documents + filters: + - extension + - python: | + return {"reversed_name": path.stem[::-1]} + actions: + - rename: "{python.reversed_name}.{extension}" +``` + +A filter for odd student numbers. Assuming the folder `~/Students` contains +the files `student-01.jpg`, `student-01.txt`, `student-02.txt` and +`student-03.txt` this rule will print +`"Odd student numbers: student-01.txt"` and +`"Odd student numbers: student-03.txt"` + +```yaml +rules: + - name: "Filter odd student numbers" + locations: ~/Students/ + filters: + - python: | + return int(path.stem.split('-')[1]) % 2 == 1 + actions: + - echo: "Odd student numbers: {path.name}" +``` + +Advanced usecase. You can access data from previous filters in your python code. +This can be used to match files and capturing names with a regular expression +and then renaming the files with the output of your python script. + +```yaml +rules: + - name: "Access placeholders in python filter" + locations: files + filters: + - extension: txt + - regex: (?P\w+)-(?P\w+)\..* + - python: | + emails = { + "Betts": "dbetts@mail.de", + "Cornish": "acornish@google.com", + "Bean": "dbean@aol.com", + "Frey": "l-frey@frey.org", + } + if regex.lastname in emails: # get emails from wherever + return {"mail": emails[regex.lastname]} + actions: + - rename: "{python.mail}.txt" +``` + +Result: + +- `Devonte-Betts.txt` becomes `dbetts@mail.de.txt` +- `Alaina-Cornish.txt` becomes `acornish@google.com.txt` +- `Dimitri-Bean.txt` becomes `dbean@aol.com.txt` +- `Lowri-Frey.txt` becomes `l-frey@frey.org.txt` +- `Someunknown-User.txt` remains unchanged because the email is not found + ## regex ::: organize.filters.Regex diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index 21185407..c470b1db 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -12,10 +12,14 @@ from fs.base import FS from typing import Dict, Set, Union, NamedTuple from organize.output import console +from organize.utils import is_same_resource +from itertools import chain from .filter import Filter HASH_ALGORITHM = "sha1" +ORDER_BY = ("location", "created", "lastmodified", "name") +ORDER_BY_REGEX = r"(-?)\s*?({})".format("|".join(ORDER_BY)) class File(NamedTuple): @@ -38,7 +42,9 @@ def first_chunk_hash(f: File): return hash_object.hexdigest() -ORDER_BY = ("location", "created", "lastmodified", "name") +def original_duplicate(a: File, b: File, ordering, reverse): + if ordering == "location": + return (a, b) if not reverse else (b, a) class Duplicate(Filter): @@ -55,28 +61,37 @@ class Duplicate(Filter): name = "duplicate" schema_support_instance_without_args = True - def __init__(self, order_by="location") -> None: + def __init__(self, select_original_by="location"): + self.select_original_by = select_original_by + self.files_for_size = defaultdict(list) - self.files_for_size # type: DDict[int, List[PyFSFile]] + self.files_for_size # type: DDict[int, List[File]] self.files_for_chunk = defaultdict(list) - self.files_for_chunk # type: Dict[str, List[PyFSFile]] + self.files_for_chunk # type: Dict[str, List[File]] self.file_for_hash = dict() - self.file_for_hash # type: Dict[str, PyFSFile] + self.file_for_hash # type: Dict[str, File] # we keep track of the files we already computed the hashes for so we only do # that once. - self.first_chunk_known = set() # type: Set[PyFSFile] - self.hash_known = set() # type: Set[PyFSFile] + self.handled_files = set() # type: Set[File] + self.first_chunk_known = set() # type: Set[File] + self.hash_known = set() # type: Set[File] def matches(self, fs: FS, path: str) -> Union[bool, Dict[str, str]]: file_ = File(fs=fs, path=path) # the exact same path has already been handled. This happens if multiple - # locations emit this file in a single rule. We skip these. - if file_ in self.first_chunk_known: + # locations emit this file in a single rule or if we follow symlinks. + # We skip these. + if file_ in self.handled_files or any( + is_same_resource(file_.fs, file_.path, x.fs, x.path) + for x in self.handled_files + ): return False + self.handled_files.add(file_) + # check for files with equal size file_size = getsize(file_) same_size = self.files_for_size[file_size] diff --git a/organize/filters/mimetype.py b/organize/filters/mimetype.py index 266ff7da..b646e530 100644 --- a/organize/filters/mimetype.py +++ b/organize/filters/mimetype.py @@ -19,58 +19,6 @@ class MimeType(Filter): .. code-block:: yaml python3 -c "import mimetypes as m; print('\\n'.join(sorted(set(m.common_types.values()) | set(m.types_map.values()))))" - - - Examples: - - Show MIME types: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Downloads' - filters: - - mimetype - actions: - - echo: '{mimetype}' - - - Filter by "image" mimetype: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Downloads' - filters: - - mimetype: image - actions: - - echo: This file is an image: {mimetype} - - - Filter by specific MIME type: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - mimetype: application/pdf - actions: - - echo: 'Found a PDF file' - - - Filter by multiple specific MIME types: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Music' - filters: - - mimetype: - - application/pdf - - audio/midi - actions: - - echo: 'Found Midi or PDF.' """ name = "mimetype" diff --git a/organize/filters/python.py b/organize/filters/python.py index 15ecfeaf..467c37fa 100644 --- a/organize/filters/python.py +++ b/organize/filters/python.py @@ -20,71 +20,6 @@ class Python(Filter): example ``return {"some_key": some_value, "nested": {"k": 2}}``) it will be accessible via dot syntax in your actions: ``{python.some_key}``, ``{python.nested.k}``. - - Examples: - - A file name reverser. - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Documents - filters: - - extension - - python: | - return {"reversed_name": path.stem[::-1]} - actions: - - rename: '{python.reversed_name}.{extension}' - - - A filter for odd student numbers. Assuming the folder ``~/Students`` contains - the files ``student-01.jpg``, ``student-01.txt``, ``student-02.txt`` and - ``student-03.txt`` this rule will print - ``"Odd student numbers: student-01.txt"`` and - ``"Odd student numbers: student-03.txt"`` - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Students/ - filters: - - python: | - return int(path.stem.split('-')[1]) % 2 == 1 - actions: - - echo: 'Odd student numbers: {path.name}' - - - - Advanced usecase. You can access data from previous filters in your python code. - This can be used to match files and capturing names with a regular expression - and then renaming the files with the output of your python script. - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: files - filters: - - extension: txt - - regex: (?P\w+)-(?P\w+)\..* - - python: | - emails = { - "Betts": "dbetts@mail.de", - "Cornish": "acornish@google.com", - "Bean": "dbean@aol.com", - "Frey": "l-frey@frey.org", - } - if regex.lastname in emails: # get emails from wherever - return {"mail": emails[regex.lastname]} - actions: - - rename: '{python.mail}.txt' - - Result: - - ``Devonte-Betts.txt`` becomes ``dbetts@mail.de.txt`` - - ``Alaina-Cornish.txt`` becomes ``acornish@google.com.txt`` - - ``Dimitri-Bean.txt`` becomes ``dbean@aol.com.txt`` - - ``Lowri-Frey.txt`` becomes ``l-frey@frey.org.txt`` - - ``Someunknown-User.txt`` remains unchanged because the email is not found - """ name = "python" diff --git a/organize/output.py b/organize/output.py index ee82c059..f293d37a 100644 --- a/organize/output.py +++ b/organize/output.py @@ -92,8 +92,8 @@ def print_location_spacer(self): console.print() def print_path(self, path): - # "page_facing_up": "📄", - # "file_folder": "📁", + # file "page_facing_up": "📄", + # dirs "file_folder": "📁", console.print(indent(":file_folder: %s" % path, " " * 2), style="purple bold") def print_not_found(self, path): diff --git a/organize/utils.py b/organize/utils.py index 7b8e1ca8..824c156c 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -2,11 +2,10 @@ from collections.abc import Mapping from copy import deepcopy from pathlib import Path -from typing import Any, Hashable, List, Sequence, Union +from typing import Any, Hashable, List, NamedTuple, Sequence, Union from fs.base import FS from fs.osfs import OSFS -from fs.errors import NoSysPath from jinja2 import Environment from jinja2.nativetypes import NativeEnvironment @@ -25,11 +24,44 @@ ) +def is_same_resource(fs1, path1, fs2, path2): + from fs.zipfs import WriteZipFS, ReadZipFS + from fs.tarfs import WriteTarFS, ReadTarFS + from fs.errors import NoSysPath, NoURL + + if isinstance(fs1, fs2.__class__): + try: + return fs1.getsyspath(path1) == fs2.getsyspath(path2) + except NoSysPath: + pass + try: + return fs1.geturl(path1) == fs2.geturl(path2) + except NoURL: + pass + if isinstance(fs1, (WriteZipFS, ReadZipFS, WriteTarFS, ReadTarFS)): + return path1 == path2 and fs1._file == fs2._file + return False + + +def resource_description(fs, path): + if isinstance(fs, OSFS): + return fs.getsyspath(path) + elif path == "/": + return str(fs) + return "{} on {}".format(path, fs) + + def fullpath(path: Union[str, Path]) -> Path: """Expand '~' and resolve the given path. Path can be a string or a Path obj.""" return Path(os.path.expandvars(str(path))).expanduser().resolve(strict=False) +def ensure_list(inp): + if not isinstance(inp, list): + return [inp] + return inp + + def flatten(arr: List[Any]) -> List[Any]: if arr == []: return [] @@ -69,13 +101,23 @@ def deep_merge_inplace(base: dict, updates: dict) -> None: base[bk] = bv -def file_desc(fs, path): - if isinstance(fs, OSFS): - return fs.getsyspath(path) - return "{} on {}".format(path, fs) - - def next_free_name(fs: FS, template: Template, name: str, extension: str) -> str: + """ + Increments {counter} in the template until the given resource does not exist. + + Args: + fs (FS): the filesystem to work on + template (jinja2.Template): + A jinja2 template with placeholders for {name}, {extension} and {counter} + name (str): The wanted filename + extension (str): the wanted extension + + Raises: + ValueError if no free name can be found with the given template. + + Returns: + (str) A filename according to the given template that does not exist on **fs**. + """ counter = 1 prev_candidate = "" while True: @@ -89,9 +131,3 @@ def next_free_name(fs: FS, template: Template, name: str, extension: str) -> str ) prev_candidate = candidate counter += 1 - - -def ensure_list(inp): - if not isinstance(inp, list): - return [inp] - return inp diff --git a/testconf.yaml b/testconf.yaml index c50b669c..f14df8c0 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -2,6 +2,8 @@ rules: - name: "Find the last modification date of some png files" targets: files locations: + - path: ~/Desktop/Testfolder + max_depth: null - path: ~/Desktop/Testfolder max_depth: null - path: "~/Desktop/Testfolder 2" From 01ce3dda6f025a169fea444326611cc093a39312 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 28 Jan 2022 14:01:49 +0100 Subject: [PATCH 047/108] use FilterResult in all filters --- organize/actions/copy.py | 6 +++--- organize/actions/move.py | 6 +++--- organize/actions/rename.py | 6 +++--- organize/actions/utils.py | 4 ++-- organize/core.py | 9 +++------ organize/filters/created.py | 19 +++++++++++-------- organize/filters/duplicate.py | 11 +++++------ organize/filters/empty.py | 11 +++++++---- organize/filters/exif.py | 20 +++++++++++--------- organize/filters/extension.py | 20 ++++++++++++-------- organize/filters/filecontent.py | 15 ++++++++------- organize/filters/filter.py | 7 +++++-- organize/filters/hash.py | 7 +++++-- organize/filters/lastmodified.py | 12 +++++++----- organize/filters/mimetype.py | 14 +++++++------- organize/filters/name.py | 16 ++++++++-------- organize/filters/python.py | 8 ++++---- organize/filters/regex.py | 14 ++++++++------ organize/filters/size.py | 14 ++++++++------ testconf.yaml | 6 ++++-- 20 files changed, 124 insertions(+), 101 deletions(-) diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 7afd2bd4..cd34280f 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -6,7 +6,7 @@ from fs.path import basename, dirname, join from schema import Optional, Or -from organize.utils import Template, file_desc +from organize.utils import Template, resource_description from .action import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict @@ -99,7 +99,7 @@ def pipeline(self, args: dict, simulate: bool): if dst_fs.exists(dst_path): self.print( '%s already exists (conflict mode is "%s").' - % (file_desc(dst_fs, dst_path), self.conflict_mode) + % (resource_description(dst_fs, dst_path), self.conflict_mode) ) dst_fs, dst_path, skip = resolve_overwrite_conflict( dst_fs=dst_fs, @@ -112,7 +112,7 @@ def pipeline(self, args: dict, simulate: bool): if not skip: if not simulate: copy_action(src_fs, src_path, dst_fs, dst_path) - self.print("Copied to %s" % file_desc(dst_fs, dst_path)) + self.print("Copied to %s" % resource_description(dst_fs, dst_path)) # the next action should work with the newly created copy return { diff --git a/organize/actions/move.py b/organize/actions/move.py index c8c4c6bc..8fc9394d 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -6,7 +6,7 @@ from fs.path import basename, dirname, join from schema import Optional, Or -from organize.utils import Template, file_desc +from organize.utils import Template, resource_description from .action import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict @@ -146,7 +146,7 @@ def pipeline(self, args: dict, simulate: bool): if dst_fs.exists(dst_path): self.print( '%s already exists (conflict mode is "%s").' - % (file_desc(dst_fs, dst_path), self.conflict_mode) + % (resource_description(dst_fs, dst_path), self.conflict_mode) ) dst_fs, dst_path, skip = resolve_overwrite_conflict( dst_fs=dst_fs, @@ -159,7 +159,7 @@ def pipeline(self, args: dict, simulate: bool): if not skip: if not simulate: move_action(src_fs, src_path, dst_fs, dst_path) - self.print("Moved to %s" % file_desc(dst_fs, dst_path)) + self.print("Moved to %s" % resource_description(dst_fs, dst_path)) # the next action should work with the newly created copy return { diff --git a/organize/actions/rename.py b/organize/actions/rename.py index 691190af..96d37b9f 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -6,7 +6,7 @@ from fs.move import move_dir, move_file from schema import Optional, Or -from organize.utils import Template, file_desc +from organize.utils import Template, resource_description from .action import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict @@ -110,7 +110,7 @@ def pipeline(self, args: dict, simulate: bool): if fs.exists(dst_path): self.print( '%s already exists (conflict mode is "%s").' - % (file_desc(fs, dst_path), self.conflict_mode) + % (resource_description(fs, dst_path), self.conflict_mode) ) fs, dst_path, skip = resolve_overwrite_conflict( dst_fs=fs, @@ -123,7 +123,7 @@ def pipeline(self, args: dict, simulate: bool): if not skip: if not simulate: move_action(fs, src_path, fs, dst_path) - self.print("Renamed to %s" % file_desc(fs, dst_path)) + self.print("Renamed to %s" % resource_description(fs, dst_path)) # the next action should work with the newly created copy return { diff --git a/organize/actions/utils.py b/organize/actions/utils.py index cdd5221d..52f3c930 100644 --- a/organize/actions/utils.py +++ b/organize/actions/utils.py @@ -5,7 +5,7 @@ from fs.path import splitext from jinja2 import Template -from organize.utils import file_desc, next_free_name +from organize.utils import resource_description, next_free_name from .trash import Trash @@ -43,7 +43,7 @@ def resolve_overwrite_conflict( return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=True) elif conflict_mode == "overwrite": - print("Overwrite %s." % file_desc(dst_fs, dst_path)) + print("Overwrite %s." % resource_description(dst_fs, dst_path)) return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=False) elif conflict_mode == "rename_new": diff --git a/organize/core.py b/organize/core.py index 70e1d73e..c3e37f3c 100644 --- a/organize/core.py +++ b/organize/core.py @@ -117,13 +117,10 @@ def filter_pipeline(filters: Iterable[Filter], args: dict) -> bool: """ for filter_ in filters: try: - result = filter_.pipeline(args) - if isinstance(result, dict): - deep_merge_inplace(args, result) - elif not result: - # filters might return a simple True / False. - # Exit early if a filter does not match. + match, updates = filter_.pipeline(args) + if not match: return False + deep_merge_inplace(args, updates) except Exception as e: # pylint: disable=broad-except logger.exception(e) # console.print_exception() diff --git a/organize/filters/created.py b/organize/filters/created.py index d82b4156..64c14e06 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -1,8 +1,9 @@ -from schema import Optional, Or from datetime import datetime, timedelta -from typing import Dict, Optional as tyOptional -from .filter import Filter +from fs.base import FS +from schema import Optional, Or + +from .filter import Filter, FilterResult class Created(Filter): @@ -62,8 +63,8 @@ def __init__( seconds=seconds, ) - def pipeline(self, args: dict) -> tyOptional[Dict[str, datetime]]: - fs = args["fs"] + def pipeline(self, args: dict) -> FilterResult: + fs = args["fs"] # type: FS fs_path = args["fs_path"] file_created: datetime @@ -81,9 +82,11 @@ def pipeline(self, args: dict) -> tyOptional[Dict[str, datetime]]: match = self.should_be_older == is_past else: match = True - if match: - return {"created": file_created} - return None + + return FilterResult( + matches=match, + updates={self.get_name(): file_created}, + ) def __str__(self): return "[Created] All files %s than %s" % ( diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index c470b1db..8f4b2a31 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -14,8 +14,7 @@ from organize.output import console from organize.utils import is_same_resource -from itertools import chain -from .filter import Filter +from .filter import Filter, FilterResult HASH_ALGORITHM = "sha1" ORDER_BY = ("location", "created", "lastmodified", "name") @@ -138,10 +137,10 @@ def pipeline(self, args): fs_path = args["fs_path"] if fs.isdir(fs_path): raise EnvironmentError("Dirs are not supported") - try: - return self.matches(fs=fs, path=fs_path) - except Exception: - console.print_exception() + result = self.matches(fs=fs, path=fs_path) + if result is False: + return FilterResult(matches=False, updates={}) + return FilterResult(matches=True, updates={self.get_name(): result}) def __str__(self) -> str: return "Duplicate()" diff --git a/organize/filters/empty.py b/organize/filters/empty.py index d1d1f282..a2637d44 100644 --- a/organize/filters/empty.py +++ b/organize/filters/empty.py @@ -1,6 +1,6 @@ from fs.base import FS -from .filter import Filter +from .filter import Filter, FilterResult class Empty(Filter): @@ -13,13 +13,16 @@ class Empty(Filter): def get_schema(cls): return cls.name - def pipeline(self, args: dict): + def pipeline(self, args: dict) -> FilterResult: fs = args["fs"] # type: FS fs_path = args["fs_path"] # type: str if fs.isdir(fs_path): - return fs.isempty(fs_path) - return fs.getsize(fs_path) == 0 + result = fs.isempty(fs_path) + else: + result = fs.getsize(fs_path) == 0 + + return FilterResult(matches=result, updates={}) def __str__(self) -> str: return "Empty()" diff --git a/organize/filters/exif.py b/organize/filters/exif.py index 4e98d32b..f5b997c8 100644 --- a/organize/filters/exif.py +++ b/organize/filters/exif.py @@ -5,7 +5,7 @@ from pathlib import Path -from .filter import Filter +from .filter import Filter, FilterResult ExifDict = Mapping[str, Union[str, Mapping[str, str]]] @@ -103,11 +103,9 @@ def category_dict(self, tags: Mapping[str, str]) -> ExifDict: result[category][field] = value else: result[key] = value # type: ignore - return result + return dict(result) def matches(self, exiftags: dict) -> Union[bool, ExifDict]: - # NOTE: This should return Union[Literal[False], ExifDict] but Literal is only - # available in Python>=3.8. if not exiftags: return False tags = {k.lower(): v.printable for k, v in exiftags.items()} @@ -122,7 +120,7 @@ def matches(self, exiftags: dict) -> Union[bool, ExifDict]: key = normkey(key) if not (key in tags and tags[key].lower() == value.lower()): return False - return self.category_dict(tags) + return True def pipeline(self, args: Mapping[str, Any]) -> Optional[Dict[str, ExifDict]]: fs = args["fs"] @@ -130,10 +128,14 @@ def pipeline(self, args: Mapping[str, Any]) -> Optional[Dict[str, ExifDict]]: with fs.openbin(fs_path) as f: exiftags = exifread.process_file(f, details=False) - tags = self.matches(exiftags) - if isinstance(tags, dict): - return {"exif": tags} - return None + tags = {k.lower(): v.printable for k, v in exiftags.items()} + matches = self.matches(tags) + exif_result = self.category_dict(tags) + + return FilterResult( + matches=matches, + updates={self.get_name(): exif_result}, + ) def __str__(self) -> str: return "EXIF(%s)" % ", ".join("%s=%s" % (k, v) for k, v in self.kwargs.items()) diff --git a/organize/filters/extension.py b/organize/filters/extension.py index 5539d6f6..765e15f0 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -1,9 +1,10 @@ from typing import Dict, Optional, Union -from pathlib import Path +from fs.base import FS + from organize.utils import flatten -from .filter import Filter +from .filter import Filter, FilterResult class ExtensionResult: @@ -119,16 +120,19 @@ def matches(self, suffix: str) -> Union[bool, str]: return False return self.normalize_extension(suffix) in self.extensions - def pipeline(self, args: dict): - fs = args["fs"] + def pipeline(self, args: dict) -> FilterResult: + fs = args["fs"] # type: FS fs_path = args["fs_path"] if fs.isdir(fs_path): raise ValueError("Dirs not supported") + + # suffix is the extension with dot suffix = fs.getinfo(fs_path).suffix - if self.matches(suffix): - result = ExtensionResult(suffix) - return {"extension": result} - return None + ext = suffix[1:] + return FilterResult( + matches=self.matches(ext), + updates={self.get_name(): ext}, + ) def __str__(self): return "Extension(%s)" % ", ".join(self.extensions) diff --git a/organize/filters/filecontent.py b/organize/filters/filecontent.py index 6886f91d..63e74153 100644 --- a/organize/filters/filecontent.py +++ b/organize/filters/filecontent.py @@ -1,9 +1,10 @@ import re from typing import Any, Dict, Mapping, Optional +from fs.base import FS from fs.errors import NoSysPath -from .filter import Filter +from .filter import Filter, FilterResult SUPPORTED_EXTENSIONS = ( # not supported: .gif, .jpg, .mp3, .ogg, .png, .tiff, .wav @@ -78,8 +79,8 @@ def matches(self, path: str, extension: str) -> Any: except textract.exceptions.CommandLineError: pass - def pipeline(self, args: Mapping) -> Optional[Dict[str, Dict]]: - fs = args["fs"] + def pipeline(self, args: dict) -> FilterResult: + fs = args["fs"] # type: FS fs_path = args["fs_path"] if fs.isdir(fs_path): raise ValueError("Dirs not supported") @@ -91,7 +92,7 @@ def pipeline(self, args: Mapping) -> Optional[Dict[str, Dict]]: "filecontent only supports the local filesystem" ) from e match = self.matches(path=syspath, extension=extension) - if match: - result = match.groupdict() - return {"filecontent": result} - return None + return FilterResult( + matches=match, + updates={self.get_name(): match.groupdict()}, + ) diff --git a/organize/filters/filter.py b/organize/filters/filter.py index 74aa8ce3..d7eb3452 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -1,8 +1,11 @@ from schema import Schema, Optional, Or from textwrap import indent -from typing import Any, Dict, Union +from typing import Any, Dict, Union, NamedTuple -FilterResult = Union[Dict[str, Any], bool, None] + +class FilterResult(NamedTuple): + matches: bool + updates: dict class Filter: diff --git a/organize/filters/hash.py b/organize/filters/hash.py index b662c14a..9495e84d 100644 --- a/organize/filters/hash.py +++ b/organize/filters/hash.py @@ -4,7 +4,7 @@ from organize.utils import Template -from .filter import Filter +from .filter import Filter, FilterResult logger = logging.getLogger(__name__) @@ -48,7 +48,10 @@ def pipeline(self, args: dict): fs_path = args["fs_path"] # type: str algo = self.algorithm.render(**args) hash_ = fs.hash(fs_path, name=algo) - return {"hash": hash_} + return FilterResult( + matches=True, + updates={self.get_name(): hash_}, + ) def __str__(self) -> str: return "Hash(algorithm={})".format(self.algorithm) diff --git a/organize/filters/lastmodified.py b/organize/filters/lastmodified.py index 1cc3f39d..bf4c9012 100644 --- a/organize/filters/lastmodified.py +++ b/organize/filters/lastmodified.py @@ -2,7 +2,7 @@ from datetime import datetime, timedelta from typing import Dict, Optional as tyOptional -from .filter import Filter +from .filter import Filter, FilterResult class LastModified(Filter): @@ -123,7 +123,7 @@ def __init__( seconds=seconds, ) - def pipeline(self, args: dict) -> tyOptional[Dict[str, datetime]]: + def pipeline(self, args: dict) -> FilterResult: fs = args["fs"] fs_path = args["fs_path"] file_modified: datetime @@ -140,9 +140,11 @@ def pipeline(self, args: dict) -> tyOptional[Dict[str, datetime]]: match = self.should_be_older == is_past else: match = True - if match: - return {"lastmodified": file_modified} - return None + + return FilterResult( + matches=match, + updates={self.get_name(): file_modified}, + ) def __str__(self): return "[LastModified] All files last modified %s than %s" % ( diff --git a/organize/filters/mimetype.py b/organize/filters/mimetype.py index b646e530..24e6089f 100644 --- a/organize/filters/mimetype.py +++ b/organize/filters/mimetype.py @@ -2,7 +2,7 @@ from organize.utils import flatten -from .filter import Filter +from .filter import Filter, FilterResult class MimeType(Filter): @@ -32,23 +32,23 @@ def mimetype(path): type_, _ = mimetypes.guess_type(path, strict=False) return type_ - def matches(self, mimetype): + def matches(self, mimetype) -> bool: if mimetype is None: return False if not self.mimetypes: return True return any(mimetype.startswith(x) for x in self.mimetypes) - def pipeline(self, args: dict): + def pipeline(self, args: dict) -> FilterResult: fs = args["fs"] fs_path = args["fs_path"] if fs.isdir(fs_path): raise ValueError("Dirs not supported.") mimetype = self.mimetype(fs_path) - - if self.matches(mimetype): - return {"mimetype": mimetype} - return None + return FilterResult( + matches=self.matches(mimetype), + updates={self.get_name(): mimetype}, + ) def __str__(self): return "MimeType(%s)" % ", ".join(self.mimetypes) diff --git a/organize/filters/name.py b/organize/filters/name.py index 3965a7a2..e046034a 100644 --- a/organize/filters/name.py +++ b/organize/filters/name.py @@ -3,7 +3,7 @@ import simplematch # type: ignore from fs import path -from .filter import Filter +from .filter import Filter, FilterResult class Name(Filter): @@ -117,13 +117,13 @@ def pipeline(self, args: Dict) -> Optional[Dict[str, Any]]: else: name, _ = path.splitext(path.basename(fs_path)) result = self.matches(name) - - if result: - m = self.matcher.match(name) - if m == {}: - m = name - return {self.get_name(): m} - return None + m = self.matcher.match(name) + if m == {}: + m = name + return FilterResult( + matches=result, + updates={self.get_name(): m}, + ) @staticmethod def create_list(x: Union[int, str, List[Any]], case_sensitive: bool) -> List[str]: diff --git a/organize/filters/python.py b/organize/filters/python.py index 467c37fa..6cca879f 100644 --- a/organize/filters/python.py +++ b/organize/filters/python.py @@ -1,7 +1,7 @@ import textwrap from typing import Any, Dict, Optional, Sequence -from .filter import Filter +from .filter import Filter, FilterResult class Python(Filter): @@ -44,9 +44,9 @@ def create_method(self, name: str, argnames: Sequence[str], code: str) -> None: ) exec(funccode, globals_, locals_) # pylint: disable=exec-used - def pipeline(self, args) -> Optional[Dict[str, Any]]: + def pipeline(self, args) -> FilterResult: self.create_method(name="usercode", argnames=args.keys(), code=self.code) result = self.usercode(**args) # pylint: disable=assignment-from-no-return if result not in (False, None): - return {"python": result} - return None + return FilterResult(matches=True, updates={self.get_name(): result}) + return FilterResult(matches=False, updates=None) diff --git a/organize/filters/regex.py b/organize/filters/regex.py index 7816aef3..7471bf85 100644 --- a/organize/filters/regex.py +++ b/organize/filters/regex.py @@ -1,7 +1,7 @@ import re from typing import Any, Dict, Mapping, Optional -from .filter import Filter +from .filter import Filter, FilterResult class Regex(Filter): @@ -55,9 +55,11 @@ def __init__(self, expr) -> None: def matches(self, path: str) -> Any: return self.expr.search(path) - def pipeline(self, args: dict) -> Optional[Dict[str, Dict]]: + def pipeline(self, args: dict) -> FilterResult: match = self.matches(args["relative_path"]) - if match: - result = match.groupdict() - return {"regex": result} - return None + return FilterResult( + matches=bool(match), + updates={ + self.get_name(): match.groupdict(), + }, + ) diff --git a/organize/filters/size.py b/organize/filters/size.py index 3e3d8606..0aefcd05 100644 --- a/organize/filters/size.py +++ b/organize/filters/size.py @@ -9,7 +9,7 @@ from organize.utils import flattened_string_list, fullpath -from .filter import Filter +from .filter import Filter, FilterResult OPERATORS = { "<": operator.lt, @@ -134,16 +134,18 @@ def pipeline(self, args: dict) -> Opt[Dict[str, Dict[str, int]]]: ) else: size = fs.getsize(fs_path) - if self.matches(size): - return { - self.name: { + + return FilterResult( + matches=self.matches(size), + updates={ + self.get_name(): { "bytes": size, "traditional": traditional(size), "binary": binary(size), "decimal": decimal(size), }, - } - return None + }, + ) def __str__(self) -> str: return "FileSize({})".format(" ".join(self.conditions)) diff --git a/testconf.yaml b/testconf.yaml index f14df8c0..dfd655cf 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -12,9 +12,11 @@ rules: max_depth: null path: "/" filters: - - duplicate + - name: + startswith: Let + - extension: webloc actions: - - echo: "{fs_path}" + - echo: "{extension.upper()[1:]} {name}" # - name: Find some folders # targets: dirs From b3a6ae13c0e65305f668077a9230668bafb60e7f Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 28 Jan 2022 14:37:17 +0100 Subject: [PATCH 048/108] use docstring for json schema description --- organize/cli.py | 3 --- organize/config.py | 9 ++++++--- organize/core.py | 2 +- organize/filters/created.py | 3 +-- organize/filters/duplicate.py | 3 +-- organize/filters/exif.py | 3 +-- organize/filters/extension.py | 3 +-- organize/filters/filecontent.py | 3 +-- organize/filters/filter.py | 20 +++++++++++++------- organize/filters/hash.py | 3 +-- organize/filters/lastmodified.py | 3 +-- organize/filters/mimetype.py | 3 +-- organize/filters/name.py | 3 +-- organize/filters/python.py | 3 +-- organize/filters/regex.py | 3 +-- organize/filters/size.py | 3 +-- 16 files changed, 32 insertions(+), 38 deletions(-) diff --git a/organize/cli.py b/organize/cli.py index 69d0940d..bf5d8c19 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -36,9 +36,6 @@ from . import CONFIG_DIR, CONFIG_PATH, LOG_PATH from .__version__ import __version__ -from .config import Config -from .core import execute_rules -from .utils import flatten, fullpath logger = logging.getLogger("organize") diff --git a/organize/config.py b/organize/config.py index 3a63ec0b..acb82d84 100644 --- a/organize/config.py +++ b/organize/config.py @@ -2,7 +2,7 @@ import yaml from rich.console import Console -from schema import And, Optional, Or, Schema, SchemaError, Literal +from schema import And, Optional, Or, Schema from organize.actions import ACTIONS from organize.filters import FILTERS @@ -14,8 +14,11 @@ Optional("version"): int, "rules": [ { - Optional("name", description="The name of the rule"): str, - Optional("targets"): Or("dirs", "files"), + Optional("name", description="The name of the rule."): str, + Optional( + "targets", + description="Whether the rule should apply to directories or folders.", + ): Or("dirs", "files"), "locations": Or( str, [ diff --git a/organize/core.py b/organize/core.py index c3e37f3c..018c214c 100644 --- a/organize/core.py +++ b/organize/core.py @@ -194,7 +194,7 @@ def run(config, simulate: bool = True): conf = load_from_file("testconf.yaml") try: - # console.print(CONFIG_SCHEMA.json_schema(None)) + console.print(CONFIG_SCHEMA.json_schema(None)) CONFIG_SCHEMA.validate(conf) replace_with_instances(conf) run(conf, simulate=True) diff --git a/organize/filters/created.py b/organize/filters/created.py index 64c14e06..06c0cbc8 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -7,8 +7,7 @@ class Created(Filter): - """ - Matches files / folders by created date + """Matches files / folders by created date Args: years (int): specify number of years diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index 8f4b2a31..a0c35caf 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -47,8 +47,7 @@ def original_duplicate(a: File, b: File, ordering, reverse): class Duplicate(Filter): - """ - Finds duplicate files. + """Finds duplicate files. This filter compares files byte by byte and finds identical files with potentially different filenames. diff --git a/organize/filters/exif.py b/organize/filters/exif.py index f5b997c8..b4a63088 100644 --- a/organize/filters/exif.py +++ b/organize/filters/exif.py @@ -11,8 +11,7 @@ class Exif(Filter): - """ - Filter by image EXIF data + """Filter by image EXIF data The `exif` filter can be used as a filter as well as a way to get exif information into your actions. diff --git a/organize/filters/extension.py b/organize/filters/extension.py index 765e15f0..1f14f938 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -24,8 +24,7 @@ def __str__(self): class Extension(Filter): - """ - Filter by file extension + """Filter by file extension :param extensions: The file extensions to match (does not need to start with a colon). diff --git a/organize/filters/filecontent.py b/organize/filters/filecontent.py index 63e74153..8fe8f832 100644 --- a/organize/filters/filecontent.py +++ b/organize/filters/filecontent.py @@ -13,8 +13,7 @@ class FileContent(Filter): - r""" - Matches file content with the given regular expression + r"""Matches file content with the given regular expression :param str expr: The regular expression to be matched. diff --git a/organize/filters/filter.py b/organize/filters/filter.py index d7eb3452..b7bf7210 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -24,6 +24,11 @@ def get_name(cls): @classmethod def get_schema(cls): + name = Schema( + Or("not " + cls.get_name(), cls.get_name()), + description=cls.get_description(), + ) + if cls.arg_schema: arg_schema = cls.arg_schema else: @@ -32,17 +37,18 @@ def get_schema(cls): [str], Schema({}, ignore_extra_keys=True), ) + if cls.schema_support_instance_without_args: - return Or( - cls.get_name(), - { - cls.get_name(): arg_schema, - }, - ) + return Or(name, {name: arg_schema}) return { - cls.get_name(): arg_schema, + name: arg_schema, } + @classmethod + def get_description(cls): + """the first line of the class docstring""" + return cls.__doc__.splitlines()[0] + def run(self, **kwargs: Dict) -> FilterResult: return self.pipeline(dict(kwargs)) diff --git a/organize/filters/hash.py b/organize/filters/hash.py index 9495e84d..3efd8dd6 100644 --- a/organize/filters/hash.py +++ b/organize/filters/hash.py @@ -11,8 +11,7 @@ class Hash(Filter): - """ - Calculates the hash of a file. + """Calculates the hash of a file. Args: algorithm (str): Any hashing algorithm available to python's `hashlib`. diff --git a/organize/filters/lastmodified.py b/organize/filters/lastmodified.py index bf4c9012..7d2d4b65 100644 --- a/organize/filters/lastmodified.py +++ b/organize/filters/lastmodified.py @@ -7,8 +7,7 @@ class LastModified(Filter): - """ - Matches files by last modified date + """Matches files by last modified date :param int years: specify number of years diff --git a/organize/filters/mimetype.py b/organize/filters/mimetype.py index 24e6089f..ea26be4f 100644 --- a/organize/filters/mimetype.py +++ b/organize/filters/mimetype.py @@ -7,8 +7,7 @@ class MimeType(Filter): - """ - Filter by MIME type associated with the file extension. + """Filter by MIME type associated with the file extension. Supports a single string or list of MIME type strings as argument. The types don't need to be fully specified, for example "audio" matches everything diff --git a/organize/filters/name.py b/organize/filters/name.py index e046034a..4f2aea46 100644 --- a/organize/filters/name.py +++ b/organize/filters/name.py @@ -7,8 +7,7 @@ class Name(Filter): - """ - Match files by filename + """Match files by filename :param str match: A matching string in `simplematch`-syntax diff --git a/organize/filters/python.py b/organize/filters/python.py index 6cca879f..909f2d91 100644 --- a/organize/filters/python.py +++ b/organize/filters/python.py @@ -6,8 +6,7 @@ class Python(Filter): - r""" - Use python code to filter files. + r"""Use python code to filter files. :param str code: The python code to execute. The code must contain a ``return`` statement. diff --git a/organize/filters/regex.py b/organize/filters/regex.py index 7471bf85..5c0b327d 100644 --- a/organize/filters/regex.py +++ b/organize/filters/regex.py @@ -6,8 +6,7 @@ class Regex(Filter): - r""" - Matches filenames with the given regular expression + r"""Matches filenames with the given regular expression :param str expr: The regular expression to be matched. diff --git a/organize/filters/size.py b/organize/filters/size.py index 0aefcd05..7318fcbc 100644 --- a/organize/filters/size.py +++ b/organize/filters/size.py @@ -58,8 +58,7 @@ def satisfies_constraints(size, constraints): class Size(Filter): - """ - Matches files and folders by size + """Matches files and folders by size :param str conditions: From 16912c7a4a260bfb70ac6d948b8743b9c5b8a20e Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 28 Jan 2022 14:45:42 +0100 Subject: [PATCH 049/108] fix mypy warnings for filters --- organize/filters/created.py | 4 +- organize/filters/duplicate.py | 5 - organize/filters/exif.py | 4 +- organize/filters/extension.py | 20 +-- organize/filters/filter.py | 8 +- organize/filters/lastmodified.py | 2 +- organize/filters/name.py | 2 +- organize/filters/python.py | 2 +- organize/filters/size.py | 4 +- organize/migration/_old_config.py | 205 ------------------------------ organize/migration/_oldcore.py | 204 ----------------------------- 11 files changed, 15 insertions(+), 445 deletions(-) delete mode 100644 organize/migration/_old_config.py delete mode 100644 organize/migration/_oldcore.py diff --git a/organize/filters/created.py b/organize/filters/created.py index 06c0cbc8..06efa2c2 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from fs.base import FS -from schema import Optional, Or +from schema import Optional, Or # type: ignore from .filter import Filter, FilterResult @@ -66,7 +66,7 @@ def pipeline(self, args: dict) -> FilterResult: fs = args["fs"] # type: FS fs_path = args["fs_path"] - file_created: datetime + file_created: Optional[datetime] file_created = fs.getinfo(fs_path, namespaces=["details"]).created if file_created: file_created = file_created.astimezone() diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index a0c35caf..450b9bcf 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -63,13 +63,8 @@ def __init__(self, select_original_by="location"): self.select_original_by = select_original_by self.files_for_size = defaultdict(list) - self.files_for_size # type: DDict[int, List[File]] - self.files_for_chunk = defaultdict(list) - self.files_for_chunk # type: Dict[str, List[File]] - self.file_for_hash = dict() - self.file_for_hash # type: Dict[str, File] # we keep track of the files we already computed the hashes for so we only do # that once. diff --git a/organize/filters/exif.py b/organize/filters/exif.py index b4a63088..0a9b726a 100644 --- a/organize/filters/exif.py +++ b/organize/filters/exif.py @@ -104,7 +104,7 @@ def category_dict(self, tags: Mapping[str, str]) -> ExifDict: result[key] = value # type: ignore return dict(result) - def matches(self, exiftags: dict) -> Union[bool, ExifDict]: + def matches(self, exiftags: dict) -> bool: if not exiftags: return False tags = {k.lower(): v.printable for k, v in exiftags.items()} @@ -121,7 +121,7 @@ def matches(self, exiftags: dict) -> Union[bool, ExifDict]: return False return True - def pipeline(self, args: Mapping[str, Any]) -> Optional[Dict[str, ExifDict]]: + def pipeline(self, args: dict) -> FilterResult: fs = args["fs"] fs_path = args["fs_path"] with fs.openbin(fs_path) as f: diff --git a/organize/filters/extension.py b/organize/filters/extension.py index 1f14f938..0f795a7e 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, Union +from typing import Union from fs.base import FS @@ -7,22 +7,6 @@ from .filter import Filter, FilterResult -class ExtensionResult: - def __init__(self, ext): - self.ext = ext[1:] if ext.startswith(".") else ext - - @property - def lower(self): - return self.ext.lower() - - @property - def upper(self): - return self.ext.upper() - - def __str__(self): - return self.ext - - class Extension(Filter): """Filter by file extension @@ -129,7 +113,7 @@ def pipeline(self, args: dict) -> FilterResult: suffix = fs.getinfo(fs_path).suffix ext = suffix[1:] return FilterResult( - matches=self.matches(ext), + matches=bool(self.matches(ext)), updates={self.get_name(): ext}, ) diff --git a/organize/filters/filter.py b/organize/filters/filter.py index b7bf7210..08a748de 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -1,4 +1,4 @@ -from schema import Schema, Optional, Or +from schema import Schema, Optional, Or # type: ignore from textwrap import indent from typing import Any, Dict, Union, NamedTuple @@ -12,8 +12,8 @@ class Filter: print_hook = None print_error_hook = None - name = None - arg_schema = None + name: str + arg_schema: Schema schema_support_instance_without_args = False @classmethod @@ -66,7 +66,7 @@ def print_error(self, msg: str): def __str__(self) -> str: """Return filter name and properties""" - return self.name + return self.get_name() def __repr__(self) -> str: return "<%s>" % str(self) diff --git a/organize/filters/lastmodified.py b/organize/filters/lastmodified.py index 7d2d4b65..46ebae17 100644 --- a/organize/filters/lastmodified.py +++ b/organize/filters/lastmodified.py @@ -1,4 +1,4 @@ -from schema import Or, Optional +from schema import Or, Optional # type: ignore from datetime import datetime, timedelta from typing import Dict, Optional as tyOptional diff --git a/organize/filters/name.py b/organize/filters/name.py index 4f2aea46..4b078688 100644 --- a/organize/filters/name.py +++ b/organize/filters/name.py @@ -108,7 +108,7 @@ def matches(self, name: str) -> bool: ) return is_match - def pipeline(self, args: Dict) -> Optional[Dict[str, Any]]: + def pipeline(self, args: Dict) -> FilterResult: fs = args["fs"] fs_path = args["fs_path"] if fs.isdir(fs_path): diff --git a/organize/filters/python.py b/organize/filters/python.py index 909f2d91..000cbf02 100644 --- a/organize/filters/python.py +++ b/organize/filters/python.py @@ -48,4 +48,4 @@ def pipeline(self, args) -> FilterResult: result = self.usercode(**args) # pylint: disable=assignment-from-no-return if result not in (False, None): return FilterResult(matches=True, updates={self.get_name(): result}) - return FilterResult(matches=False, updates=None) + return FilterResult(matches=False, updates={}) diff --git a/organize/filters/size.py b/organize/filters/size.py index 7318fcbc..d9928cb1 100644 --- a/organize/filters/size.py +++ b/organize/filters/size.py @@ -5,7 +5,7 @@ from typing import Sequence, Set, Tuple from fs.filesize import binary, decimal, traditional -from schema import Optional, Or +from schema import Optional, Or # type: ignore from organize.utils import flattened_string_list, fullpath @@ -122,7 +122,7 @@ def matches(self, filesize: int) -> bool: return True return all(op(filesize, c_size) for op, c_size in self.constraints) - def pipeline(self, args: dict) -> Opt[Dict[str, Dict[str, int]]]: + def pipeline(self, args: dict) -> FilterResult: fs = args["fs"] fs_path = args["fs_path"] diff --git a/organize/migration/_old_config.py b/organize/migration/_old_config.py deleted file mode 100644 index 8c65044a..00000000 --- a/organize/migration/_old_config.py +++ /dev/null @@ -1,205 +0,0 @@ -import inspect -import logging -import textwrap -from typing import Generator, List, Mapping, NamedTuple, Sequence - -import yaml - -from . import actions, filters -from .actions.action import Action -from pathlib import Path -from .filters.filter import Filter -from .utils import first_key, flatten - -logger = logging.getLogger(__name__) -Rule = NamedTuple( - "Rule", - [ - ("filters", Sequence[Filter]), - ("actions", Sequence[Action]), - ("folders", Sequence[str]), - ("subfolders", bool), - ("system_files", bool), - ], -) - -# disable yaml constructors for strings starting with exclamation marks -# https://stackoverflow.com/a/13281292/300783 -def default_yaml_cnst(loader, tag_suffix, node): - return str(node.tag) - - -yaml.add_multi_constructor("", default_yaml_cnst, Loader=yaml.SafeLoader) - - -class Config: - def __init__(self, config: dict) -> None: - self.config = config - self.filter_by_name = { - name.lower(): getattr(filters, name) - for name, _ in inspect.getmembers(filters, inspect.isclass) - } - self.action_by_name = { - name.lower(): getattr(actions, name) - for name, _ in inspect.getmembers(actions, inspect.isclass) - } - - @classmethod - def from_string(cls, config: str) -> "Config": - dedented_config = textwrap.dedent(config) - try: - return cls(yaml.load(dedented_config, Loader=yaml.SafeLoader)) - except yaml.YAMLError as e: - raise cls.ParsingError(e) - - @classmethod - def from_file(cls, path: Path) -> "Config": - with path.open(encoding="utf-8") as f: - return cls.from_string(f.read()) - - def yaml(self) -> str: - if not (self.config and "rules" in self.config): - raise self.NoRulesFoundError() - data = {"rules": self.config["rules"]} - yaml.Dumper.ignore_aliases = lambda self, data: True # type: ignore - return yaml.dump( - data, allow_unicode=True, default_flow_style=False, default_style="'" - ) - - @staticmethod - def parse_folders(rule_item) -> Generator[str, None, None]: - # the folder list is flattened so we can use encapsulated list - # definitions in the config file. - yield from flatten(rule_item["folders"]) - - @staticmethod - def sanitize_key(key): - return key.lower().replace("_", "") - - def _get_filter_class_by_name(self, name): - try: - return self.filter_by_name[self.sanitize_key(name)] - except AttributeError as e: - raise self.Error("%s is no valid filter" % name) from e - - def _get_action_class_by_name(self, name): - try: - return self.action_by_name[self.sanitize_key(name)] - except AttributeError as e: - raise self.Error("%s is no valid action" % name) from e - - @staticmethod - def _class_instance_with_args(Cls, args): - if args is None: - return Cls() - elif isinstance(args, list): - return Cls(*args) - elif isinstance(args, dict): - return Cls(**args) - return Cls(args) - - def instantiate_filters(self, rule_item: Mapping) -> Generator[Filter, None, None]: - # filter list can be empty - try: - filter_list = rule_item["filters"] - except KeyError: - return - if not filter_list: - return - if not isinstance(filter_list, list): - raise self.FiltersNoListError() - - for filter_item in flatten(filter_list): - if filter_item is None: - # TODO: don't know what this should be - continue - # filter with arguments - elif isinstance(filter_item, dict): - name = first_key(filter_item) - args = filter_item[name] - filter_class = self._get_filter_class_by_name(name) - yield self._class_instance_with_args(filter_class, args) - # only given filter name without args - elif isinstance(filter_item, str): - name = filter_item - filter_class = self._get_filter_class_by_name(name) - yield filter_class() - else: - raise self.Error("Unknown filter: %s" % filter_item) - - def instantiate_actions(self, rule_item: Mapping) -> Generator[Action, None, None]: - action_list = rule_item["actions"] - if not isinstance(action_list, list): - raise self.ActionsNoListError() - - for action_item in flatten(action_list): - if isinstance(action_item, dict): - name = first_key(action_item) - args = action_item[name] - action_class = self._get_action_class_by_name(name) - yield self._class_instance_with_args(action_class, args) - elif isinstance(action_item, str): - name = action_item - action_class = self._get_action_class_by_name(name) - yield action_class() - else: - raise self.Error("Unknown action: %s" % action_item) - - @property - def rules(self) -> List[Rule]: - """:returns: A list of instantiated Rules""" - if not (self.config and "rules" in self.config): - raise self.NoRulesFoundError() - result = [] - for i, rule_item in enumerate(self.config["rules"]): - # skip disabled rules - if not rule_item.get("enabled", True): - continue - - rule_folders = list(self.parse_folders(rule_item)) - rule_filters = list(self.instantiate_filters(rule_item)) - rule_actions = list(self.instantiate_actions(rule_item)) - - if not rule_folders: - logger.warning("No folders given for rule %s!", i + 1) - if not rule_filters: - logger.warning("No filters given for rule %s!", i + 1) - if not rule_actions: - logger.warning("No actions given for rule %s!", i + 1) - - rule = Rule( - folders=rule_folders, - filters=rule_filters, - actions=rule_actions, - subfolders=rule_item.get("subfolders", False), - system_files=rule_item.get("system_files", False), - ) - result.append(rule) - return result - - class Error(Exception): - pass - - class NoRulesFoundError(Error): - def __str__(self): - return "No rules found in configuration file" - - class ParsingError(Error): - pass - - class NoFoldersFoundError(Error): - pass - - class NoFiltersFoundError(Error): - pass - - class NoActionsFoundError(Error): - pass - - class FiltersNoListError(Error): - def __str__(self): - return "Please specify your filters as a YAML list" - - class ActionsNoListError(Error): - def __str__(self): - return "Please specify your actions as a YAML list" diff --git a/organize/migration/_oldcore.py b/organize/migration/_oldcore.py deleted file mode 100644 index 1f36c5d6..00000000 --- a/organize/migration/_oldcore.py +++ /dev/null @@ -1,204 +0,0 @@ -import logging -import os -import shutil -from copy import deepcopy -from datetime import datetime -from textwrap import indent -from typing import Generator, Iterable, List, NamedTuple, Optional, Sequence, Set, Tuple - -from colorama import Fore, Style # type: ignore - -from .actions.action import Action -from pathlib import Path -from .config import Rule -from .filters.filter import Filter -from .utils import DotDict, splitglob - -logger = logging.getLogger(__name__) -SYSTEM_FILES = ("thumbs.db", "desktop.ini", ".DS_Store") - -Job = NamedTuple( - "Job", - [ - ("folderstr", str), - ("basedir", Path), - ("path", Path), - ("filters", Sequence[Filter]), - ("actions", Sequence[Action]), - ], -) -Job.__doc__ = """ - :param str folderstr: the original folder definition specified in the config - :param Path basedir: the job's base folder - :param Path path: the path of the file to handle - :param list filters: the filters that apply to the path - :param list actions: the actions which should be executed -""" - - -class OutputHelper: - """ - class to track the current folder / file and print only changes. - This is needed because we only want to output the current folder and file if the - filter or action prints something. - """ - - def __init__(self) -> None: - self.not_found = set() # type: Set[str] - self.curr_folder = None # type: Optional[Path] - self.curr_path = None # type: Optional[Path] - self.prev_folder = None # type: Optional[Path] - self.prev_path = None # type: Optional[Path] - - def set_location(self, folder: Path, path: Path) -> None: - self.curr_folder = folder - self.curr_path = path - - def pre_print(self) -> None: - """ - pre-print hook that is called everytime the moment before a filter or action is - about to print something to the cli - """ - if self.curr_folder != self.prev_folder: - if self.prev_folder is not None: - print() # ensure newline between folders - print("Folder %s%s:" % (Style.BRIGHT, self.curr_folder)) - self.prev_folder = self.curr_folder - - if self.curr_path != self.prev_path: - print(indent("File %s%s:" % (Style.BRIGHT, self.curr_path), " " * 2)) - self.prev_path = self.curr_path - - def print_path_not_found(self, folderstr: str) -> None: - if folderstr not in self.not_found: - self.not_found.add(folderstr) - msg = "Path not found: {}".format(folderstr) - print(Fore.YELLOW + Style.BRIGHT + msg) - logger.warning(msg) - - -output_helper = OutputHelper() - - -def execute_rules(rules: Iterable[Rule], simulate: bool) -> None: - cols, _ = shutil.get_terminal_size(fallback=(79, 20)) - simulation_msg = Fore.GREEN + Style.BRIGHT + " SIMULATION ".center(cols, "~") - - jobs = create_jobs(rules=rules) - - if simulate: - print(simulation_msg) - - failed, succeded = run_jobs(jobs=jobs, simulate=simulate) - if succeded == failed == 0: - msg = "Nothing to do." - logger.info(msg) - print(msg) - - if simulate: - print(simulation_msg) - - -def create_jobs(rules: Iterable[Rule]) -> Generator[Job, None, None]: - """ creates `Job` data structures for every path handled in each rule """ - for rule in rules: - for folderstr, basedir, path in all_files_for_rule(rule): - yield Job( - folderstr=folderstr, - basedir=basedir, - path=path, - filters=rule.filters, - actions=rule.actions, - ) - - -def all_files_for_rule(rule: Rule) -> Generator[Tuple[str, Path, Path], None, None]: - files = dict() - for folderstr in rule.folders: - folderstr = folderstr.strip() - - # check whether the file / folder is prefixed with `!` to be excluded - exclude_flag = folderstr.startswith("!") - - # assemble glob expression - basedir, globstr = splitglob(folderstr.lstrip("!").strip()) - if basedir.is_dir(): - if not globstr: - globstr = "**/*" if rule.subfolders else "*" - elif basedir.is_file(): - # this allows specifying single files - globstr = basedir.name - basedir = basedir.parent - else: - output_helper.print_path_not_found(str(basedir)) - continue - - # iterate files in basedir and add to / remove from result dict - for path in basedir.glob(globstr): - if path.is_file() and (rule.system_files or path.name not in SYSTEM_FILES): - if not exclude_flag: - files[path] = (folderstr, basedir) - elif path in files: - del files[path] - - for path, (folderstr, basedir) in files.items(): - yield (folderstr, basedir, path) - - -def run_jobs(jobs: Iterable[Job], simulate: bool) -> List[int]: - """ :returns: The number of successfully handled files """ - count = [0, 0] - Action.print_hook = output_helper.pipeline_message - Filter.print_hook = output_helper.pipeline_message - - for job in sorted(jobs, key=lambda x: (x.folderstr, x.basedir, x.path)): - args = DotDict( - path=job.path, - basedir=job.basedir, - simulate=simulate, - relative_path=job.path.relative_to(job.basedir), - env=os.environ, - now=datetime.now(), - ) - - output_helper.set_location(job.basedir, args.relative_path) - match = filter_pipeline(filters=job.filters, args=args) - if match: - success = action_pipeline(actions=job.actions, args=args) - count[success] += 1 - return count - - -def filter_pipeline(filters: Iterable[Filter], args: DotDict) -> bool: - """ - run the filter pipeline. - Returns True on a match, False otherwise and updates `args` in the process. - """ - for filter_ in filters: - try: - result = filter_.pipeline(deepcopy(args)) - if isinstance(result, dict): - args.update(result) - elif not result: - # filters might return a simple True / False. - # Exit early if a filter does not match. - return False - except Exception as e: # pylint: disable=broad-except - logger.exception(e) - filter_.print(Fore.RED + Style.BRIGHT + "ERROR! %s" % e) - return False - return True - - -def action_pipeline(actions: Iterable[Action], args: DotDict) -> bool: - for action in actions: - try: - updates = action.pipeline(deepcopy(args)) - # jobs may return a dict with updates that should be merged into args - if updates is not None: - args.update(updates) - except Exception as e: # pylint: disable=broad-except - logger.exception(e) - action.print(Fore.RED + Style.BRIGHT + "ERROR! %s" % e) - return False - return True From 17d6fa99bb37250e0c852513466597c8fd45aeb1 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 28 Jan 2022 14:56:30 +0100 Subject: [PATCH 050/108] update mypy --- organize/actions/action.py | 2 +- organize/filters/empty.py | 2 +- organize/filters/filter.py | 8 +++-- poetry.lock | 68 ++++++++++++++++++++++---------------- pyproject.toml | 8 ++++- 5 files changed, 55 insertions(+), 33 deletions(-) diff --git a/organize/actions/action.py b/organize/actions/action.py index 0f4205ab..2775c382 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -44,7 +44,7 @@ def get_schema(cls): def run(self, **kwargs) -> tyOptional[Dict[str, Any]]: return self.pipeline(kwargs) - def pipeline(self, args: dict) -> tyOptional[Dict[str, Any]]: + def pipeline(self, args: dict, simulate: bool) -> tyOptional[Dict[str, Any]]: raise NotImplementedError def print(self, msg, *args, **kwargs) -> None: diff --git a/organize/filters/empty.py b/organize/filters/empty.py index a2637d44..490f5659 100644 --- a/organize/filters/empty.py +++ b/organize/filters/empty.py @@ -11,7 +11,7 @@ class Empty(Filter): @classmethod def get_schema(cls): - return cls.name + return cls.get_name_schema() def pipeline(self, args: dict) -> FilterResult: fs = args["fs"] # type: FS diff --git a/organize/filters/filter.py b/organize/filters/filter.py index 08a748de..64f3b581 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -23,12 +23,16 @@ def get_name(cls): return cls.__name__.lower() @classmethod - def get_schema(cls): - name = Schema( + def get_name_schema(cls): + return Schema( Or("not " + cls.get_name(), cls.get_name()), description=cls.get_description(), ) + @classmethod + def get_schema(cls): + name = cls.get_name_schema() + if cls.arg_schema: arg_schema = cls.arg_schema else: diff --git a/poetry.lock b/poetry.lock index 08865b77..96d5a96f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -449,19 +449,21 @@ pytkdocs = ">=0.14.0" [[package]] name = "mypy" -version = "0.812" +version = "0.931" description = "Optional static typing for Python" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] -mypy-extensions = ">=0.4.3,<0.5.0" -typed-ast = ">=1.4.0,<1.5.0" -typing-extensions = ">=3.7.4" +mypy-extensions = ">=0.4.3" +tomli = ">=1.1.0" +typed-ast = {version = ">=1.4.0,<2", markers = "python_version < \"3.8\""} +typing-extensions = ">=3.10" [package.extras] dmypy = ["psutil (>=4.0)"] +python2 = ["typed-ast (>=1.4.0,<2)"] [[package]] name = "mypy-extensions" @@ -801,6 +803,14 @@ category = "dev" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +[[package]] +name = "tomli" +version = "1.2.3" +description = "A lil' TOML parser" +category = "dev" +optional = false +python-versions = ">=3.6" + [[package]] name = "typed-ast" version = "1.4.3" @@ -898,7 +908,7 @@ textract = ["textract"] [metadata] lock-version = "1.1" python-versions = "^3.6.2" -content-hash = "84bc287638a9d200ca09bf546aadc347560c351e6e7d4c1b8109143355a05db5" +content-hash = "abf2dc5d2c00840c4b690a145543b2fd5106a62851685ffbf2d500fe04cc0b8a" [metadata.files] appdirs = [ @@ -1241,28 +1251,26 @@ mkdocstrings = [ {file = "mkdocstrings-0.17.0.tar.gz", hash = "sha256:75b5cfa2039aeaf3a5f5cf0aa438507b0330ce76c8478da149d692daa7213a98"}, ] mypy = [ - {file = "mypy-0.812-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49"}, - {file = "mypy-0.812-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c"}, - {file = "mypy-0.812-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521"}, - {file = "mypy-0.812-cp35-cp35m-win_amd64.whl", hash = "sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb"}, - {file = "mypy-0.812-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a"}, - {file = "mypy-0.812-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c"}, - {file = "mypy-0.812-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6"}, - {file = "mypy-0.812-cp36-cp36m-win_amd64.whl", hash = "sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064"}, - {file = "mypy-0.812-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56"}, - {file = "mypy-0.812-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8"}, - {file = "mypy-0.812-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7"}, - {file = "mypy-0.812-cp37-cp37m-win_amd64.whl", hash = "sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564"}, - {file = "mypy-0.812-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506"}, - {file = "mypy-0.812-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5"}, - {file = "mypy-0.812-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66"}, - {file = "mypy-0.812-cp38-cp38-win_amd64.whl", hash = "sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e"}, - {file = "mypy-0.812-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a"}, - {file = "mypy-0.812-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a"}, - {file = "mypy-0.812-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97"}, - {file = "mypy-0.812-cp39-cp39-win_amd64.whl", hash = "sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df"}, - {file = "mypy-0.812-py3-none-any.whl", hash = "sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4"}, - {file = "mypy-0.812.tar.gz", hash = "sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119"}, + {file = "mypy-0.931-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3c5b42d0815e15518b1f0990cff7a705805961613e701db60387e6fb663fe78a"}, + {file = "mypy-0.931-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c89702cac5b302f0c5d33b172d2b55b5df2bede3344a2fbed99ff96bddb2cf00"}, + {file = "mypy-0.931-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:300717a07ad09525401a508ef5d105e6b56646f7942eb92715a1c8d610149714"}, + {file = "mypy-0.931-cp310-cp310-win_amd64.whl", hash = "sha256:7b3f6f557ba4afc7f2ce6d3215d5db279bcf120b3cfd0add20a5d4f4abdae5bc"}, + {file = "mypy-0.931-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1bf752559797c897cdd2c65f7b60c2b6969ffe458417b8d947b8340cc9cec08d"}, + {file = "mypy-0.931-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:4365c60266b95a3f216a3047f1d8e3f895da6c7402e9e1ddfab96393122cc58d"}, + {file = "mypy-0.931-cp36-cp36m-win_amd64.whl", hash = "sha256:1b65714dc296a7991000b6ee59a35b3f550e0073411ac9d3202f6516621ba66c"}, + {file = "mypy-0.931-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e839191b8da5b4e5d805f940537efcaa13ea5dd98418f06dc585d2891d228cf0"}, + {file = "mypy-0.931-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:50c7346a46dc76a4ed88f3277d4959de8a2bd0a0fa47fa87a4cde36fe247ac05"}, + {file = "mypy-0.931-cp37-cp37m-win_amd64.whl", hash = "sha256:d8f1ff62f7a879c9fe5917b3f9eb93a79b78aad47b533911b853a757223f72e7"}, + {file = "mypy-0.931-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f9fe20d0872b26c4bba1c1be02c5340de1019530302cf2dcc85c7f9fc3252ae0"}, + {file = "mypy-0.931-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1b06268df7eb53a8feea99cbfff77a6e2b205e70bf31743e786678ef87ee8069"}, + {file = "mypy-0.931-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8c11003aaeaf7cc2d0f1bc101c1cc9454ec4cc9cb825aef3cafff8a5fdf4c799"}, + {file = "mypy-0.931-cp38-cp38-win_amd64.whl", hash = "sha256:d9d2b84b2007cea426e327d2483238f040c49405a6bf4074f605f0156c91a47a"}, + {file = "mypy-0.931-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ff3bf387c14c805ab1388185dd22d6b210824e164d4bb324b195ff34e322d166"}, + {file = "mypy-0.931-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5b56154f8c09427bae082b32275a21f500b24d93c88d69a5e82f3978018a0266"}, + {file = "mypy-0.931-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8ca7f8c4b1584d63c9a0f827c37ba7a47226c19a23a753d52e5b5eddb201afcd"}, + {file = "mypy-0.931-cp39-cp39-win_amd64.whl", hash = "sha256:74f7eccbfd436abe9c352ad9fb65872cc0f1f0a868e9d9c44db0893440f0c697"}, + {file = "mypy-0.931-py3-none-any.whl", hash = "sha256:1171f2e0859cfff2d366da2c7092b06130f232c636a3f7301e3feb8b41f6377d"}, + {file = "mypy-0.931.tar.gz", hash = "sha256:0038b21890867793581e4cb0d810829f5fd4441aa75796b53033af3aa30430ce"}, ] mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, @@ -1474,6 +1482,10 @@ toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] +tomli = [ + {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, + {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, +] typed-ast = [ {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, diff --git a/pyproject.toml b/pyproject.toml index 3d28638e..e87a075b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,12 +50,18 @@ textract = ["textract"] [tool.poetry.dev-dependencies] pytest = "^6.2.5" -mypy = "^0.812" +mypy = "^0.931" mkdocs = "^1.2.3" mkdocstrings = "^0.17.0" mkdocs-include-markdown-plugin = "^3.2.3" mkdocs-autorefs = "^0.3.1" +[tool.mypy] + +[[tool.mypy.overrides]] +module = ["schema"] +ignore_missing_imports = true + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" From 92508b7f3427c8a9fc0fbaff2873806d6173dd8d Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 28 Jan 2022 15:00:03 +0100 Subject: [PATCH 051/108] configure mypy --- organize/__init__.py | 4 ++-- organize/actions/macos_tags.py | 4 ++-- organize/actions/trash.py | 2 +- organize/cli.py | 4 ++-- organize/filters/created.py | 2 +- organize/filters/exif.py | 4 ++-- organize/filters/filecontent.py | 2 +- organize/filters/filter.py | 2 +- organize/filters/lastmodified.py | 2 +- organize/filters/name.py | 2 +- organize/filters/size.py | 2 +- organize/utils.py | 10 +++++----- pyproject.toml | 2 +- 13 files changed, 21 insertions(+), 21 deletions(-) diff --git a/organize/__init__.py b/organize/__init__.py index 502da78e..8eb99ca9 100644 --- a/organize/__init__.py +++ b/organize/__init__.py @@ -2,8 +2,8 @@ import logging.config import os -import appdirs # type: ignore -import colorama # type: ignore +import appdirs +import colorama import yaml from pathlib import Path diff --git a/organize/actions/macos_tags.py b/organize/actions/macos_tags.py index 51e1aed0..5bed3a7f 100644 --- a/organize/actions/macos_tags.py +++ b/organize/actions/macos_tags.py @@ -1,7 +1,7 @@ import logging import sys -import simplematch as sm # type: ignore +import simplematch as sm from schema import Or from .action import Action @@ -44,7 +44,7 @@ def pipeline(self, args: dict, simulate: bool): self.print("The macos_tags action is only available on macOS") return - import macos_tags # type: ignore + import macos_tags COLORS = [c.name.lower() for c in macos_tags.Color] diff --git a/organize/actions/trash.py b/organize/actions/trash.py index 2fffc63d..4c8b68f2 100644 --- a/organize/actions/trash.py +++ b/organize/actions/trash.py @@ -16,7 +16,7 @@ def get_schema(cls): return cls.name def trash(self, path: str, simulate: bool): - from send2trash import send2trash # type: ignore + from send2trash import send2trash self.print('Trash "%s"' % path) if not simulate: diff --git a/organize/cli.py b/organize/cli.py index bf5d8c19..4be60747 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -30,8 +30,8 @@ from pathlib import Path from typing import Union -from colorama import Fore, Style # type: ignore -from docopt import docopt # type: ignore +from colorama import Fore, Style +from docopt import docopt from rich import print from . import CONFIG_DIR, CONFIG_PATH, LOG_PATH diff --git a/organize/filters/created.py b/organize/filters/created.py index 06efa2c2..5ca56a33 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from fs.base import FS -from schema import Optional, Or # type: ignore +from schema import Optional, Or from .filter import Filter, FilterResult diff --git a/organize/filters/exif.py b/organize/filters/exif.py index 0a9b726a..2d73af95 100644 --- a/organize/filters/exif.py +++ b/organize/filters/exif.py @@ -1,7 +1,7 @@ import collections from typing import Any, DefaultDict, Dict, Mapping, Optional, Union -import exifread # type: ignore +import exifread from pathlib import Path @@ -101,7 +101,7 @@ def category_dict(self, tags: Mapping[str, str]) -> ExifDict: category, field = key.split(" ", maxsplit=1) result[category][field] = value else: - result[key] = value # type: ignore + result[key] = value return dict(result) def matches(self, exiftags: dict) -> bool: diff --git a/organize/filters/filecontent.py b/organize/filters/filecontent.py index 8fe8f832..b3ef253a 100644 --- a/organize/filters/filecontent.py +++ b/organize/filters/filecontent.py @@ -62,7 +62,7 @@ def matches(self, path: str, extension: str) -> Any: if extension not in SUPPORTED_EXTENSIONS: return try: - import textract # type: ignore + import textract content = textract.process( str(path), diff --git a/organize/filters/filter.py b/organize/filters/filter.py index 64f3b581..be4de592 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -1,4 +1,4 @@ -from schema import Schema, Optional, Or # type: ignore +from schema import Schema, Optional, Or from textwrap import indent from typing import Any, Dict, Union, NamedTuple diff --git a/organize/filters/lastmodified.py b/organize/filters/lastmodified.py index 46ebae17..7d2d4b65 100644 --- a/organize/filters/lastmodified.py +++ b/organize/filters/lastmodified.py @@ -1,4 +1,4 @@ -from schema import Or, Optional # type: ignore +from schema import Or, Optional from datetime import datetime, timedelta from typing import Dict, Optional as tyOptional diff --git a/organize/filters/name.py b/organize/filters/name.py index 4b078688..3c384b12 100644 --- a/organize/filters/name.py +++ b/organize/filters/name.py @@ -1,6 +1,6 @@ from typing import Any, List, Union, Optional, Dict -import simplematch # type: ignore +import simplematch from fs import path from .filter import Filter, FilterResult diff --git a/organize/filters/size.py b/organize/filters/size.py index d9928cb1..3f54cd78 100644 --- a/organize/filters/size.py +++ b/organize/filters/size.py @@ -5,7 +5,7 @@ from typing import Sequence, Set, Tuple from fs.filesize import binary, decimal, traditional -from schema import Optional, Or # type: ignore +from schema import Optional, Or from organize.utils import flattened_string_list, fullpath diff --git a/organize/utils.py b/organize/utils.py index 824c156c..ff918c87 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -6,17 +6,17 @@ from fs.base import FS from fs.osfs import OSFS -from jinja2 import Environment -from jinja2.nativetypes import NativeEnvironment +import jinja2 +from jinja2 import nativetypes -Template = Environment( +Template = jinja2.Environment( variable_start_string="{", variable_end_string="}", finalize=lambda x: x() if callable(x) else x, autoescape=False, ) -NativeTemplate = NativeEnvironment( +NativeTemplate = nativetypes.NativeEnvironment( variable_start_string="{", variable_end_string="}", finalize=lambda x: x() if callable(x) else x, @@ -101,7 +101,7 @@ def deep_merge_inplace(base: dict, updates: dict) -> None: base[bk] = bv -def next_free_name(fs: FS, template: Template, name: str, extension: str) -> str: +def next_free_name(fs: FS, template: jinja2.Template, name: str, extension: str) -> str: """ Increments {counter} in the template until the given resource does not exist. diff --git a/pyproject.toml b/pyproject.toml index e87a075b..e2d8970b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ mkdocs-autorefs = "^0.3.1" [tool.mypy] [[tool.mypy.overrides]] -module = ["schema"] +module = ["schema", "simplematch", "appdirs", "send2trash", "exifread", "textract"] ignore_missing_imports = true [build-system] From 0f07f497509a30f504f0adec63d225654005d529 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 28 Jan 2022 15:12:54 +0100 Subject: [PATCH 052/108] replace mk with html --- README.md | 16 ++++++++++++---- mkdocs.yml | 2 ++ pyproject.toml | 1 + 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c40848e8..a2d2cd10 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,18 @@ diff --git a/mkdocs.yml b/mkdocs.yml index 87dbbde7..51358a97 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,4 +1,6 @@ site_name: organize +repo_url: https://github.com/tfeldmann/organize/ +site_author: "Thomas Feldmann" nav: - Home: index.md - Configuration: 01-config.md diff --git a/pyproject.toml b/pyproject.toml index e2d8970b..09e794e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ mkdocs-include-markdown-plugin = "^3.2.3" mkdocs-autorefs = "^0.3.1" [tool.mypy] +python_version = "3.6" [[tool.mypy.overrides]] module = ["schema", "simplematch", "appdirs", "send2trash", "exifread", "textract"] From 2d992f5e940d774468136dd59caf6159dcfe93ab Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 28 Jan 2022 17:11:59 +0100 Subject: [PATCH 053/108] update some tests --- docs/03-filters.md | 5 +- organize/actions/__init__.py | 2 +- organize/filters/filter.py | 4 +- organize/utils.py | 12 ++-- tests/conftest.py | 1 - tests/core/test_utils.py | 112 --------------------------------- tests/core/test_utils_merge.py | 77 +++++++++++++++++++++++ tests/docs/__init__.py | 0 tests/docs/test_docs.py | 42 +++++++++++++ tests/test_doc_examples.py | 25 -------- 10 files changed, 130 insertions(+), 150 deletions(-) create mode 100644 tests/core/test_utils_merge.py create mode 100644 tests/docs/__init__.py create mode 100644 tests/docs/test_docs.py delete mode 100644 tests/test_doc_examples.py diff --git a/docs/03-filters.md b/docs/03-filters.md index a7f6b5c9..9b54e195 100644 --- a/docs/03-filters.md +++ b/docs/03-filters.md @@ -53,7 +53,6 @@ rules: locations: - ~/Desktop - ~/Downloads - sublocations: true filters: - duplicate actions: @@ -183,8 +182,8 @@ rules: locations: ~/Students/ filters: - python: | - return int(path.stem.split('-')[1]) % 2 == 1 - actions: + return int(path.stem.split('-')[1]) % 2 == 1 + actions: - echo: "Odd student numbers: {path.name}" ``` diff --git a/organize/actions/__init__.py b/organize/actions/__init__.py index 20863509..5b589ecc 100644 --- a/organize/actions/__init__.py +++ b/organize/actions/__init__.py @@ -12,7 +12,7 @@ from .trash import Trash ACTIONS = { - Confirm.name: Confirm, + # Confirm.name: Confirm, Copy.name: Copy, Delete.name: Delete, Echo.name: Echo, diff --git a/organize/filters/filter.py b/organize/filters/filter.py index be4de592..080419b7 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -12,8 +12,8 @@ class Filter: print_hook = None print_error_hook = None - name: str - arg_schema: Schema + name = None # type: Union[str, None] + arg_schema = None # type: Union[Schema, None] schema_support_instance_without_args = False @classmethod diff --git a/organize/utils.py b/organize/utils.py index ff918c87..c9442d9e 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -29,11 +29,11 @@ def is_same_resource(fs1, path1, fs2, path2): from fs.tarfs import WriteTarFS, ReadTarFS from fs.errors import NoSysPath, NoURL + try: + return fs1.getsyspath(path1) == fs2.getsyspath(path2) + except NoSysPath: + pass if isinstance(fs1, fs2.__class__): - try: - return fs1.getsyspath(path1) == fs2.getsyspath(path2) - except NoSysPath: - pass try: return fs1.geturl(path1) == fs2.geturl(path2) except NoURL: @@ -84,7 +84,7 @@ def first_key(dic: Mapping) -> Hashable: def deep_merge(a: dict, b: dict) -> dict: result = deepcopy(a) for bk, bv in b.items(): - av = result.get("k") + av = result.get(bk) if isinstance(av, dict) and isinstance(bv, dict): result[bk] = deep_merge(av, bv) else: @@ -94,7 +94,7 @@ def deep_merge(a: dict, b: dict) -> dict: def deep_merge_inplace(base: dict, updates: dict) -> None: for bk, bv in updates.items(): - av = base.get("k") + av = base.get(bk) if isinstance(av, dict) and isinstance(bv, dict): deep_merge_inplace(av, bv) else: diff --git a/tests/conftest.py b/tests/conftest.py index 220b63a4..ce285cd4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,6 @@ import pytest from pathlib import Path -from organize.utils import DotDict TESTS_FOLDER = os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index cf6ca059..a3b70f68 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -1,40 +1,3 @@ -from organize.utils import ( - DotDict, - Path, - dict_merge, - find_unused_filename, - increment_filename_version, - splitglob, -) - - -def test_splitglob(): - assert splitglob("~/Downloads") == (Path.home() / "Downloads", "") - assert splitglob(r"/Test/\* tmp\*/*[!H]/**/*.*") == ( - Path(r"/Test/\* tmp\*"), - "*[!H]/**/*.*", - ) - assert splitglob("~/Downloads/Program 0.1*.exe") == ( - Path.home() / "Downloads", - "Program 0.1*.exe", - ) - assert splitglob("~/Downloads/Program[ms].exe") == ( - Path.home() / "Downloads", - "Program[ms].exe", - ) - assert splitglob("~/Downloads/Program.exe") == ( - Path.home() / "Downloads" / "Program.exe", - "", - ) - # https://github.com/tfeldmann/organize/issues/40 - assert splitglob("~/Ältere/Erträgnisaufstellung_*.pdf") == ( - Path.home() / "Ältere", - "Erträgnisaufstellung_*.pdf", - ) - # https://github.com/tfeldmann/organize/issues/39 - assert splitglob("~/Downloads/*.pdf") == (Path.home() / "Downloads", "*.pdf") - - def test_unused_filename_basic(mock_exists): mock_exists.return_value = False assert find_unused_filename(Path("somefile.jpg")) == Path("somefile 2.jpg") @@ -87,78 +50,3 @@ def test_increment_filename_version_no_separator(): assert increment_filename_version(Path("test 10.7z"), separator="") == Path( "test 102.7z" ) - - -def test_merges_dicts(): - a = {"a": 1, "b": {"b1": 2, "b2": 3}} - b = {"a": 1, "b": {"b1": 4}} - - assert dict_merge(a, b)["a"] == 1 - assert dict_merge(a, b)["b"]["b2"] == 3 - assert dict_merge(a, b)["b"]["b1"] == 4 - - -def test_returns_copy(): - a = {"regex": {"first": "A", "second": "B"}} - b = {"regex": {"third": "C"}} - - x = dict_merge(a, b) - a["regex"]["first"] = "X" - assert x["regex"]["first"] == "A" - assert x["regex"]["second"] == "B" - assert x["regex"]["third"] == "C" - - -def test_inserts_new_keys(): - """Will it insert new keys by default?""" - a = {"a": 1, "b": {"b1": 2, "b2": 3}} - b = {"a": 1, "b": {"b1": 4, "b3": 5}, "c": 6} - - assert dict_merge(a, b)["a"] == 1 - assert dict_merge(a, b)["b"]["b2"] == 3 - assert dict_merge(a, b)["b"]["b1"] == 4 - assert dict_merge(a, b)["b"]["b3"] == 5 - assert dict_merge(a, b)["c"] == 6 - - -def test_does_not_insert_new_keys(): - """Will it avoid inserting new keys when required?""" - a = {"a": 1, "b": {"b1": 2, "b2": 3}} - b = {"a": 1, "b": {"b1": 4, "b3": 5}, "c": 6} - - assert dict_merge(a, b, add_keys=False)["a"] == 1 - assert dict_merge(a, b, add_keys=False)["b"]["b2"] == 3 - assert dict_merge(a, b, add_keys=False)["b"]["b1"] == 4 - try: - assert dict_merge(a, b, add_keys=False)["b"]["b3"] == 5 - except KeyError: - pass - else: - raise Exception("New keys added when they should not be") - - try: - assert dict_merge(a, b, add_keys=False)["b"]["b3"] == 6 - except KeyError: - pass - else: - raise Exception("New keys added when they should not be") - - -def test_dotdict_merge(): - a = DotDict() - b = {1: {2: 2, 3: 3, 4: {5: "fin."}}} - a.update(b) - assert a == b - b[1][2] = 5 - assert a != b - - a.update({1: {4: {5: "new.", 6: "fin."}, 2: "x"}}) - assert a == {1: {2: "x", 3: 3, 4: {5: "new.", 6: "fin."}}} - - -def test_dotdict_keeptype(): - a = DotDict() - a.update({"nr": {"upper": 1}}) - assert a.nr.upper == 1 - - assert "{nr.upper}".format(**a) == "1" diff --git a/tests/core/test_utils_merge.py b/tests/core/test_utils_merge.py new file mode 100644 index 00000000..65e71b4f --- /dev/null +++ b/tests/core/test_utils_merge.py @@ -0,0 +1,77 @@ +from pytest import mark +from organize.utils import deep_merge, deep_merge_inplace + + +def test_merges_dicts(): + a = {"a": 1, "b": {"b1": 2, "b2": 3}} + b = {"a": 1, "b": {"b1": 4}} + + print(deep_merge(a, b)) + assert deep_merge(a, b)["a"] == 1 + assert deep_merge(a, b)["b"]["b2"] == 3 + assert deep_merge(a, b)["b"]["b1"] == 4 + + +def test_returns_copy(): + a = {"regex": {"first": "A", "second": "B"}} + b = {"regex": {"third": "C"}} + + x = deep_merge(a, b) + a["regex"]["first"] = "X" + assert x["regex"]["first"] == "A" + assert x["regex"]["second"] == "B" + assert x["regex"]["third"] == "C" + + +def test_inserts_new_keys(): + """Will it insert new keys by default?""" + a = {"a": 1, "b": {"b1": 2, "b2": 3}} + b = {"a": 1, "b": {"b1": 4, "b3": 5}, "c": 6} + + assert deep_merge(a, b)["a"] == 1 + assert deep_merge(a, b)["b"]["b2"] == 3 + assert deep_merge(a, b)["b"]["b1"] == 4 + assert deep_merge(a, b)["b"]["b3"] == 5 + assert deep_merge(a, b)["c"] == 6 + + +@mark.skip +def test_does_not_insert_new_keys(): + """Will it avoid inserting new keys when required?""" + a = {"a": 1, "b": {"b1": 2, "b2": 3}} + b = {"a": 1, "b": {"b1": 4, "b3": 5}, "c": 6} + + assert deep_merge(a, b, add_keys=False)["a"] == 1 + assert deep_merge(a, b, add_keys=False)["b"]["b2"] == 3 + assert deep_merge(a, b, add_keys=False)["b"]["b1"] == 4 + try: + assert deep_merge(a, b, add_keys=False)["b"]["b3"] == 5 + except KeyError: + pass + else: + raise Exception("New keys added when they should not be") + + try: + assert deep_merge(a, b, add_keys=False)["b"]["b3"] == 6 + except KeyError: + pass + else: + raise Exception("New keys added when they should not be") + + +def test_inplace_merge(): + a = {} + b = {1: {2: 2, 3: 3, 4: {5: "fin."}}} + a = deep_merge(a, b) + assert a == b + b[1][2] = 5 + assert a != b + + deep_merge_inplace(a, {1: {4: {5: "new.", 6: "fin."}, 2: "x"}}) + assert a == {1: {2: "x", 3: 3, 4: {5: "new.", 6: "fin."}}} + + +def test_inplace_keeptype(): + a = {} + deep_merge_inplace(a, {"nr": {"upper": 1}}) + assert a["nr"]["upper"] == 1 diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/docs/test_docs.py b/tests/docs/test_docs.py new file mode 100644 index 00000000..146ffbf7 --- /dev/null +++ b/tests/docs/test_docs.py @@ -0,0 +1,42 @@ +import re + +import fs +from schema import SchemaError + +from organize.filters import FILTERS +from organize.actions import ACTIONS +from organize.config import CONFIG_SCHEMA, load_from_string + +RE_CONFIG = re.compile(r"```yaml\n(?Prules:(?:.*?\n)+?)```", re.MULTILINE) + + +DOCS = { + "filters": "03-filters.md", + "actions": "04-actions.md", +} + + +def test_examples_are_valid(): + docdir = fs.open_fs("docs") + for f in DOCS.values(): + text = docdir.readtext(f) + for match in RE_CONFIG.findall(text): + try: + config = load_from_string(match) + CONFIG_SCHEMA.validate(config) + except SchemaError as e: + raise ValueError(e.autos[-1]) + + +def test_all_filters_documented(): + docdir = fs.open_fs("docs") + filter_docs = docdir.readtext(DOCS["filters"]) + for name in FILTERS.keys(): + assert "## {}".format(name) in filter_docs + + +def test_all_actions_documented(): + docdir = fs.open_fs("docs") + action_docs = docdir.readtext(DOCS["actions"]) + for name in ACTIONS.keys(): + assert "## {}".format(name) in action_docs diff --git a/tests/test_doc_examples.py b/tests/test_doc_examples.py deleted file mode 100644 index 30794c34..00000000 --- a/tests/test_doc_examples.py +++ /dev/null @@ -1,25 +0,0 @@ -import re - -import fs - -from organize.config import load_from_string, CONFIG_SCHEMA - -RE_CONFIG = re.compile(r"```yaml\n(?Prules:(?:.*?\n)+?)```", re.MULTILINE) - - -DOCS = ( - "03-filters.md", - "04-actions.md", -) - -docdir = fs.open_fs("docs") -for f in DOCS: - text = docdir.readtext(f) - for match in RE_CONFIG.findall(text): - try: - config = load_from_string(match) - CONFIG_SCHEMA.validate(config) - except Exception as e: - print("invalid config: ") - print(match) - print(str(e)) From 111e6bdce5535fc0e564075c8233f7beee6b2926 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 28 Jan 2022 17:47:03 +0100 Subject: [PATCH 054/108] update tests --- makedocs.sh | 2 - organize/filters/extension.py | 6 +- tests/filters/test_extension.py | 26 ++++--- tests/filters/test_filename.py | 119 ++++++++++++++++---------------- tests/filters/test_filesize.py | 38 +++++----- 5 files changed, 96 insertions(+), 95 deletions(-) delete mode 100755 makedocs.sh diff --git a/makedocs.sh b/makedocs.sh deleted file mode 100755 index a93166c4..00000000 --- a/makedocs.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!sh -poetry run sphinx-build docs/source docs/build diff --git a/organize/filters/extension.py b/organize/filters/extension.py index 0f795a7e..e7389514 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -96,12 +96,12 @@ def normalize_extension(ext: str) -> str: else: return ext.lower() - def matches(self, suffix: str) -> Union[bool, str]: + def matches(self, ext: str) -> Union[bool, str]: if not self.extensions: return True - if not suffix: + if not ext: return False - return self.normalize_extension(suffix) in self.extensions + return self.normalize_extension(ext) in self.extensions def pipeline(self, args: dict) -> FilterResult: fs = args["fs"] # type: FS diff --git a/tests/filters/test_extension.py b/tests/filters/test_extension.py index 516b57dc..ca7ed1eb 100644 --- a/tests/filters/test_extension.py +++ b/tests/filters/test_extension.py @@ -1,27 +1,33 @@ -from pathlib import Path +from fs import open_fs +from fs.path import dirname from organize.filters import Extension def test_extension(): extension = Extension("JPG", ".gif", "pdf") testpathes = [ - (Path("~/somefile.pdf"), True), - (Path("/home/test/somefile.pdf.jpeg"), False), - (Path("/home/test/gif.TXT"), False), - (Path("/home/test/txt.GIF"), True), - (Path("~/somefile.pdf"), True), + ("/somefile.pdf", True), + ("/home/test/somefile.pdf.jpeg", False), + ("/home/test/gif.TXT", False), + ("/home/test/txt.GIF", True), + ("/somefile.pdf", True), ] - for path, match in testpathes: - assert bool(extension.matches(path)) == match + with open_fs("mem://", writeable=True, create=True) as mem: + for f, match in testpathes: + mem.makedirs(dirname(f), recreate=True) + mem.touch(f) + assert extension.run(fs=mem, fs_path=f).matches == match def test_extension_empty(): + fs = open_fs("mem://") + fs.touch("test.txt") extension = Extension() - assert extension.matches(Path("~/test.txt")) + assert extension.run(fs=fs, fs_path="test.txt").matches def test_extension_result(): - path = Path("~/somefile.TxT") + path = "~/somefile.TxT" extension = Extension("txt") assert extension.matches(path) result = extension.run(path=path)["extension"] diff --git a/tests/filters/test_filename.py b/tests/filters/test_filename.py index 43efcc64..c7557e4e 100644 --- a/tests/filters/test_filename.py +++ b/tests/filters/test_filename.py @@ -1,102 +1,99 @@ -from pathlib import Path -from organize.filters import Filename +from organize.filters import Name def test_filename_startswith(): - filename = Filename(startswith="begin") - assert filename.matches(Path("~/here/beginhere.pdf")) - assert not filename.matches(Path("~/here/.beginhere.pdf")) - assert not filename.matches(Path("~/here/herebegin.begin")) + filename = Name(startswith="begin") + assert filename.matches("~/here/beginhere.pdf") + assert not filename.matches("~/here/.beginhere.pdf") + assert not filename.matches("~/here/herebegin.begin") def test_filename_contains(): - filename = Filename(contains="begin") - assert filename.matches(Path("~/here/beginhere.pdf")) - assert filename.matches(Path("~/here/.beginhere.pdf")) - assert filename.matches(Path("~/here/herebegin.begin")) - assert not filename.matches(Path("~/here/other.begin")) + filename = Name(contains="begin") + assert filename.matches("~/here/beginhere.pdf") + assert filename.matches("~/here/.beginhere.pdf") + assert filename.matches("~/here/herebegin.begin") + assert not filename.matches("~/here/other.begin") def test_filename_endswith(): - filename = Filename(endswith="end") - assert filename.matches(Path("~/here/hereend.pdf")) - assert not filename.matches(Path("~/here/end.tar.gz")) - assert not filename.matches(Path("~/here/theendishere.txt")) + filename = Name(endswith="end") + assert filename.matches("~/here/hereend.pdf") + assert not filename.matches("~/here/end.tar.gz") + assert not filename.matches("~/here/theendishere.txt") def test_filename_multiple(): - filename = Filename(startswith="begin", contains="con", endswith="end") - assert filename.matches(Path("~/here/begin_somethgin_con_end.pdf")) - assert not filename.matches(Path("~/here/beginend.pdf")) - assert not filename.matches(Path("~/here/begincon.begin")) - assert not filename.matches(Path("~/here/conend.begin")) - assert filename.matches(Path("~/here/beginconend.begin")) + filename = Name(startswith="begin", contains="con", endswith="end") + assert filename.matches("~/here/begin_somethgin_con_end.pdf") + assert not filename.matches("~/here/beginend.pdf") + assert not filename.matches("~/here/begincon.begin") + assert not filename.matches("~/here/conend.begin") + assert filename.matches("~/here/beginconend.begin") def test_filename_case(): - filename = Filename( + filename = Name( startswith="star", contains="con", endswith="end", case_sensitive=False ) - assert filename.matches(Path("~/STAR_conEnD.dpf")) - assert not filename.matches(Path("~/here/STAREND.pdf")) - assert not filename.matches(Path("~/here/STARCON.begin")) - assert not filename.matches(Path("~/here/CONEND.begin")) - assert filename.matches(Path("~/here/STARCONEND.begin")) + assert filename.matches("~/STAR_conEnD.dpf") + assert not filename.matches("~/here/STAREND.pdf") + assert not filename.matches("~/here/STARCON.begin") + assert not filename.matches("~/here/CONEND.begin") + assert filename.matches("~/here/STARCONEND.begin") def test_filename_list(): - filename = Filename( + filename = Name( startswith="_", contains=["1", "A", "3", "6"], endswith=["5", "6"], case_sensitive=False, ) - assert filename.matches(Path("~/_15.dpf")) - assert filename.matches(Path("~/_A5.dpf")) - assert filename.matches(Path("~/_A6.dpf")) - assert filename.matches(Path("~/_a6.dpf")) - assert filename.matches(Path("~/_35.dpf")) - assert filename.matches(Path("~/_36.dpf")) - assert filename.matches(Path("~/_somethinga56")) - assert filename.matches(Path("~/_6")) - assert not filename.matches(Path("~/")) - assert not filename.matches(Path("~/a_5")) + assert filename.matches("~/_15.dpf") + assert filename.matches("~/_A5.dpf") + assert filename.matches("~/_A6.dpf") + assert filename.matches("~/_a6.dpf") + assert filename.matches("~/_35.dpf") + assert filename.matches("~/_36.dpf") + assert filename.matches("~/_somethinga56") + assert filename.matches("~/_6") + assert not filename.matches("~/") + assert not filename.matches("~/a_5") def test_filename_list_case_sensitive(): - filename = Filename( + filename = Name( startswith="_", contains=["1", "A", "3", "7"], endswith=["5", "6"], case_sensitive=True, ) - assert filename.matches(Path("~/_15.dpf")) - assert filename.matches(Path("~/_A5.dpf")) - assert filename.matches(Path("~/_A6.dpf")) - assert not filename.matches(Path("~/_a6.dpf")) - assert filename.matches(Path("~/_35.dpf")) - assert filename.matches(Path("~/_36.dpf")) - assert filename.matches(Path("~/_somethingA56")) - assert not filename.matches(Path("~/_6")) - assert not filename.matches(Path("~/_a5.dpf")) - assert not filename.matches(Path("~/-A5.dpf")) - assert not filename.matches(Path("~/")) - assert not filename.matches(Path("~/_a5")) + assert filename.matches("~/_15.dpf") + assert filename.matches("~/_A5.dpf") + assert filename.matches("~/_A6.dpf") + assert not filename.matches("~/_a6.dpf") + assert filename.matches("~/_35.dpf") + assert filename.matches("~/_36.dpf") + assert filename.matches("~/_somethingA56") + assert not filename.matches("~/_6") + assert not filename.matches("~/_a5.dpf") + assert not filename.matches("~/-A5.dpf") + assert not filename.matches("~/") + assert not filename.matches("~/_a5") def test_filename_match(): - fn = Filename("Invoice_*_{year:int}_{month}_{day}") + fn = Name("Invoice_*_{year:int}_{month}_{day}") p = "~/Documents/Invoice_RE1001_2021_01_31.pdf" - assert fn.matches(Path(p)) - assert fn.run(path=Path(p)) == { - "filename": {"year": 2021, "month": "01", "day": "31"} - } + assert fn.matches(p) + assert fn.run(p) == {"filename": {"year": 2021, "month": "01", "day": "31"}} def test_filename_match_case_insensitive(): - case = Filename("upper_{m1}_{m2}", case_sensitive=True) - icase = Filename("upper_{m1}_{m2}", case_sensitive=False) + case = Name("upper_{m1}_{m2}", case_sensitive=True) + icase = Name("upper_{m1}_{m2}", case_sensitive=False) p = "~/Documents/UPPER_MiXed_lower.pdf" - assert icase.matches(Path(p)) - assert icase.run(path=Path(p)) == {"filename": {"m1": "MiXed", "m2": "lower"}} - assert not case.matches(Path(p)) + assert icase.matches(p) + assert icase.run(path=p) == {"filename": {"m1": "MiXed", "m2": "lower"}} + assert not case.matches(p) diff --git a/tests/filters/test_filesize.py b/tests/filters/test_filesize.py index fa03a895..1bbd41eb 100644 --- a/tests/filters/test_filesize.py +++ b/tests/filters/test_filesize.py @@ -1,28 +1,28 @@ -from organize.filters import FileSize +from organize.filters import Size def test_constrains_mope1(): - assert not FileSize("<1b,>2b").matches(1) - assert FileSize(">=1b,<2b").matches(1) - assert not FileSize(">1.000001b").matches(1) - assert FileSize("<1.000001B").matches(1) - assert FileSize("<1.000001").matches(1) - assert FileSize("<=1,>=0.001kb").matches(1) - assert FileSize("<1").matches(0) - assert not FileSize(">1").matches(0) - assert not FileSize("<1,>1b").matches(0) - assert FileSize(">99.99999GB").matches(100000000000) - assert FileSize("0").matches(0) + assert not Size("<1b,>2b").matches(1) + assert Size(">=1b,<2b").matches(1) + assert not Size(">1.000001b").matches(1) + assert Size("<1.000001B").matches(1) + assert Size("<1.000001").matches(1) + assert Size("<=1,>=0.001kb").matches(1) + assert Size("<1").matches(0) + assert not Size(">1").matches(0) + assert not Size("<1,>1b").matches(0) + assert Size(">99.99999GB").matches(100000000000) + assert Size("0").matches(0) def test_constrains_base(): - assert FileSize(">1kb,<1kib").matches(1010) - assert FileSize(">1k,<1ki").matches(1010) - assert FileSize("1k").matches(1000) - assert FileSize("1000").matches(1000) + assert Size(">1kb,<1kib").matches(1010) + assert Size(">1k,<1ki").matches(1010) + assert Size("1k").matches(1000) + assert Size("1000").matches(1000) def test_other(): - assert FileSize("<100 Mb").matches(20) - assert FileSize("<100 Mb, <10 mb, <1 mb, > 0").matches(20) - assert FileSize(["<100 Mb", ">= 0 Tb"]).matches(20) + assert Size("<100 Mb").matches(20) + assert Size("<100 Mb, <10 mb, <1 mb, > 0").matches(20) + assert Size(["<100 Mb", ">= 0 Tb"]).matches(20) From 696371e1091a1a491d1aaa4018fcc014c680201c Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sat, 29 Jan 2022 14:32:23 +0100 Subject: [PATCH 055/108] reworked cli --- CHANGELOG.md | 1 + organize/cli.py | 393 ++++++++++++++++++++++++++++++--------------- organize/config.py | 3 +- poetry.lock | 6 +- pyproject.toml | 1 + 5 files changed, 268 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01104356..3a833b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Please backup all your important stuff before running. - You can now target folders with your rules. Like copying a whole folder, renaming etc. - `max_depth` setting when recursing into subfolders - starts instantly (does not need to gather all the files before starting) +- filters can now be excluded - nice terminal output - rule names - cleaner config file validation and stricter format diff --git a/organize/cli.py b/organize/cli.py index 4be60747..7a7ae962 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -1,154 +1,283 @@ -""" -organize -- The file management automation tool. - -Usage: - organize sim [] - organize run [] - organize config [--open-folder | --path | --debug | --schema] - organize list - organize --help - organize --version - -Arguments: - sim Simulate a run. Does not touch your files. - run Organizes your files according to your rules. - config Open the configuration file in $EDITOR. - list List available filters and actions. - --version Show program version and exit. - -h, --help Show this screen and exit. - -Options: - -o, --open-folder Open the folder containing the configuration files. - -p, --path Show the path to the configuration file. - -d, --debug Debug your configuration file. - -Full documentation: https://organize.readthedocs.io -""" -import logging +import click +from . import CONFIG_DIR, CONFIG_PATH, LOG_PATH +from .__version__ import __version__ +from .config import CONFIG_SCHEMA +from .output import console + import os -import sys +import appdirs from pathlib import Path -from typing import Union -from colorama import Fore, Style -from docopt import docopt -from rich import print +# prepare config and log folders +APP_DIRS = appdirs.AppDirs("organize") -from . import CONFIG_DIR, CONFIG_PATH, LOG_PATH -from .__version__ import __version__ +# setting the $ORGANIZE_CONFIG env variable overrides the default config path +if os.getenv("ORGANIZE_CONFIG"): + CONFIG_PATH = Path(os.getenv("ORGANIZE_CONFIG", "")).resolve() + CONFIG_DIR = CONFIG_PATH.parent +else: + CONFIG_DIR = Path(APP_DIRS.user_config_dir) + CONFIG_PATH = CONFIG_DIR / "config.yaml" -logger = logging.getLogger("organize") +class NaturalOrderGroup(click.Group): + def list_commands(self, ctx): + return self.commands.keys() -def main(argv=None): - """entry point for the command line interface""" - args = docopt(__doc__, argv=argv, version=__version__, help=True) - # override default config file path - if args["--config-file"]: - expanded_path = os.path.expandvars(args["--config-file"]) - config_path = Path(expanded_path).expanduser().resolve() - config_dir = config_path.parent - else: - config_dir = CONFIG_DIR - config_path = CONFIG_PATH - - # > organize config - if args["config"]: - if args["--open-folder"]: - open_in_filemanager(config_dir) - elif args["--path"]: - print(str(config_path)) - elif args["--debug"]: - config_debug(config_path) - else: - config_edit(config_path) - - # > organize list - elif args["list"]: - list_actions_and_filters() - - # > organize sim / run - else: - try: - config = Config.from_file(config_path) - execute_rules(config.rules, simulate=args["sim"]) - except Config.Error as e: - logger.exception(e) - print_error(e) - print("Try 'organize config --debug' for easier debugging.") - print("Full traceback at: %s" % LOG_PATH) - sys.exit(1) - except Exception as e: # pylint: disable=broad-except - logger.exception(e) - print_error(e) - print("Full traceback at: %s" % LOG_PATH) - sys.exit(1) - - -def config_edit(config_path: Path) -> None: - """open the config file in $EDITOR or default text editor""" - # attention: the env variable might contain command line arguments. - # https://github.com/tfeldmann/organize/issues/24 - editor = os.getenv("EDITOR") - if editor: - os.system('%s "%s"' % (editor, config_path)) - else: - open_in_filemanager(config_path) +CLI_RULES_FILE = click.argument( + "rule_file", + required=False, + envvar="ORGANIZE_RULE_FILE", + type=click.Path(exists=True), +) +CLI_WORKING_DIR_OPTION = click.option( + "--working-dir", + default=".", + type=click.Path(exists=True), + help="The working directory", +) +# for CLI backwards compatibility with organize v1.x +CLI_CONFIG_FILE_OPTION = click.option( + "--config-file", + envvar="ORGANIZE_CONFIG", + default=None, + hidden=True, + type=click.Path(exists=True), +) + + +def warn(msg): + console.print("Warning: %s" % msg, style="yellow") + + +@click.group( + cls=NaturalOrderGroup, + context_settings=dict(help_option_names=["-h", "--help"]), +) +@click.version_option(__version__) +def cli(): + """ + organize + + The file management automation tool. + """ + pass + + +@cli.command() +@CLI_RULES_FILE +@CLI_WORKING_DIR_OPTION +@CLI_CONFIG_FILE_OPTION +def run(rule_file, working_dir, config_file): + """Organizes your files according to your rules.""" + print(rule_file, working_dir, config_file) + +@cli.command() +@CLI_RULES_FILE +@CLI_WORKING_DIR_OPTION +@CLI_CONFIG_FILE_OPTION +def sim(rule_file, working_dir, config_file): + """Simulates a run (does not touch your files).""" + print(rule_file, working_dir, config_file) -def open_in_filemanager(path: Path) -> None: - """opens the given path in file manager, using the default application""" - import webbrowser # pylint: disable=import-outside-toplevel - webbrowser.open(path.as_uri()) +@cli.group() +def rules(): + """Manage your rules""" + pass -def config_debug(config_path: Path) -> None: - """prints the config with resolved yaml aliases, checks rules syntax and checks - whether the given folders exist +@rules.command() +@click.argument( + "rule_file", + required=False, + default=CONFIG_PATH, + envvar="ORGANIZE_RULE_FILE", + type=click.Path(), +) +@click.option( + "--editor", + envvar="EDITOR", + help="The editor to use. (Default: $EDITOR)", +) +def edit(rule_file, editor): + """Edit a rule file. + + If called without further arguments, it will open the default rule file in $EDITOR. """ - print(str(config_path)) - haserr = False - # check config syntax - try: - print(Style.BRIGHT + "Your configuration as seen by the parser:") - config = Config.from_file(config_path) - if not config.config: - print_error("Config file is empty") - return - print(config.yaml()) - rules = config.rules - print("Config file syntax seems fine!") - except Config.Error as e: - haserr = True - print_error(e) + click.edit(filename=rule_file, editor=editor) + + +@rules.command() +@CLI_RULES_FILE +def check(rule_file): + """Checks whether the given rule file is valid""" + print(rule_file) + + +@rules.command() +def schema(): + """Checks whether the given rule file is valid""" + import json + + js = json.dumps( + CONFIG_SCHEMA.json_schema( + schema_id="https://tfeldmann.de/organize.schema.json", + ) + ) + console.print_json(js) + + +@rules.command() +def path(): + """Prints the path of the default rule file""" + click.echo(CONFIG_PATH) + + +@rules.command() +def reveal(): + """Reveals the default rule file""" + click.launch(str(CONFIG_PATH), locate=True) + + +@cli.command(hidden=True) +@click.option("--path", is_flag=True, help="Print the default config file path") +@click.option("--debug", is_flag=True, help="Debug the default config file") +@click.option("--open-folder", is_flag=True) # backwards compatibility +@click.pass_context +def config(ctx, path, debug, open_folder): + """Edit the default configuration file.""" + warn("`organize config` is deprecated. Please try the new `organize rules`.") + if open_folder: + ctx.invoke(reveal) + elif path: + ctx.invoke(path) + elif debug: + ctx.invoke(check) else: - # check whether all folders exists: - allfolders = set(flatten([rule.folders for rule in rules])) - for f in allfolders: - if not fullpath(f).exists(): - haserr = True - print(Fore.YELLOW + 'Warning: "%s" does not exist!' % f) + ctx.invoke(edit) + + +@cli.command(help="Open the documentation") +def docs(): + click.launch("https://organize.readthedocs.io") + + +if __name__ == "__main__": + cli() + + +# def main(argv=None): +# """entry point for the command line interface""" +# args = docopt(__doc__, argv=argv, version=__version__, help=True) + +# # override default config file path +# if args["--config-file"]: +# expanded_path = os.path.expandvars(args["--config-file"]) +# config_path = Path(expanded_path).expanduser().resolve() +# config_dir = config_path.parent +# else: +# config_dir = CONFIG_DIR +# config_path = CONFIG_PATH + +# # > organize config +# if args["config"]: +# if args["--open-folder"]: +# open_in_filemanager(config_dir) +# elif args["--path"]: +# print(str(config_path)) +# elif args["--debug"]: +# config_debug(config_path) +# else: +# config_edit(config_path) + +# # > organize list +# elif args["list"]: +# list_actions_and_filters() + +# # > organize sim / run +# else: +# try: +# config = Config.from_file(config_path) +# execute_rules(config.rules, simulate=args["sim"]) +# except Config.Error as e: +# logger.exception(e) +# print_error(e) +# print("Try 'organize config --debug' for easier debugging.") +# print("Full traceback at: %s" % LOG_PATH) +# sys.exit(1) +# except Exception as e: # pylint: disable=broad-except +# logger.exception(e) +# print_error(e) +# print("Full traceback at: %s" % LOG_PATH) +# sys.exit(1) + + +# def config_edit(config_path: Path) -> None: +# """open the config file in $EDITOR or default text editor""" +# # attention: the env variable might contain command line arguments. +# # https://github.com/tfeldmann/organize/issues/24 +# editor = os.getenv("EDITOR") +# if editor: +# os.system('%s "%s"' % (editor, config_path)) +# else: +# open_in_filemanager(config_path) + + +# def open_in_filemanager(path: Path) -> None: +# """opens the given path in file manager, using the default application""" +# import webbrowser # pylint: disable=import-outside-toplevel + +# webbrowser.open(path.as_uri()) + + +# def config_debug(config_path: Path) -> None: +# """prints the config with resolved yaml aliases, checks rules syntax and checks +# whether the given folders exist +# """ +# print(str(config_path)) +# haserr = False +# # check config syntax +# try: +# print(Style.BRIGHT + "Your configuration as seen by the parser:") +# config = Config.from_file(config_path) +# if not config.config: +# print_error("Config file is empty") +# return +# print(config.yaml()) +# rules = config.rules +# print("Config file syntax seems fine!") +# except Config.Error as e: +# haserr = True +# print_error(e) +# else: +# # check whether all folders exists: +# allfolders = set(flatten([rule.folders for rule in rules])) +# for f in allfolders: +# if not fullpath(f).exists(): +# haserr = True +# print(Fore.YELLOW + 'Warning: "%s" does not exist!' % f) - if not haserr: - print(Fore.GREEN + Style.BRIGHT + "No config problems found.") +# if not haserr: +# print(Fore.GREEN + Style.BRIGHT + "No config problems found.") -def list_actions_and_filters() -> None: - """Prints a list of available actions and filters""" - import inspect # pylint: disable=import-outside-toplevel +# def list_actions_and_filters() -> None: +# """Prints a list of available actions and filters""" +# import inspect # pylint: disable=import-outside-toplevel - from organize import actions, filters # pylint: disable=import-outside-toplevel +# from organize import actions, filters # pylint: disable=import-outside-toplevel - print(Style.BRIGHT + "Filters:") - for name, _ in inspect.getmembers(filters, inspect.isclass): - print(" " + name) - print() - print(Style.BRIGHT + "Actions:") - for name, _ in inspect.getmembers(actions, inspect.isclass): - print(" " + name) +# print(Style.BRIGHT + "Filters:") +# for name, _ in inspect.getmembers(filters, inspect.isclass): +# print(" " + name) +# print() +# print(Style.BRIGHT + "Actions:") +# for name, _ in inspect.getmembers(actions, inspect.isclass): +# print(" " + name) -def print_error(e: Union[Exception, str]) -> None: - print(Style.BRIGHT + Fore.RED + "ERROR:" + Style.RESET_ALL + " %s" % e) +# def print_error(e: Union[Exception, str]) -> None: +# print(Style.BRIGHT + Fore.RED + "ERROR:" + Style.RESET_ALL + " %s" % e) diff --git a/organize/config.py b/organize/config.py index acb82d84..ec1b0f7a 100644 --- a/organize/config.py +++ b/organize/config.py @@ -48,7 +48,8 @@ ], } ], - } + }, + name="organize rule configuration", ) diff --git a/poetry.lock b/poetry.lock index 96d5a96f..f6a54dbe 100644 --- a/poetry.lock +++ b/poetry.lock @@ -110,7 +110,7 @@ python-versions = "*" name = "click" version = "8.0.3" description = "Composable command line interface toolkit" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -269,7 +269,7 @@ test = ["mock (>=1.3.0)"] name = "importlib-metadata" version = "4.8.3" description = "Read metadata from Python packages" -category = "dev" +category = "main" optional = false python-versions = ">=3.6" @@ -908,7 +908,7 @@ textract = ["textract"] [metadata] lock-version = "1.1" python-versions = "^3.6.2" -content-hash = "abf2dc5d2c00840c4b690a145543b2fd5106a62851685ffbf2d500fe04cc0b8a" +content-hash = "fe994f61fffe6e1da4b387381566905468e866e59cc448a6ef3659fb5bd7abd0" [metadata.files] appdirs = [ diff --git a/pyproject.toml b/pyproject.toml index 09e794e1..7de2cac9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ simplematch = "^1.3" macos-tags = { version = "^1.5.1", markers = "sys_platform == 'darwin'" } schema = "^0.7.5" Jinja2 = "^3.0.3" +click = "^8.0.3" [tool.poetry.extras] textract = ["textract"] From 6ac1be263bf087b78910b10f6072bdaca8fd299e Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sat, 29 Jan 2022 14:47:39 +0100 Subject: [PATCH 056/108] update cli --- organize/__init__.py | 94 +++++++++++++++----------------------------- organize/__main__.py | 4 +- organize/cli.py | 36 +++++++++++------ 3 files changed, 59 insertions(+), 75 deletions(-) diff --git a/organize/__init__.py b/organize/__init__.py index 8eb99ca9..adeefca2 100644 --- a/organize/__init__.py +++ b/organize/__init__.py @@ -1,62 +1,32 @@ -import logging -import logging.config -import os - -import appdirs -import colorama -import yaml - -from pathlib import Path - -colorama.init(autoreset=True) - -# prepare config and log folders -APP_DIRS = appdirs.AppDirs("organize") - -# setting the $ORGANIZE_CONFIG env variable overrides the default config path -if os.getenv("ORGANIZE_CONFIG"): - CONFIG_PATH = Path(os.getenv("ORGANIZE_CONFIG", "")).resolve() - CONFIG_DIR = CONFIG_PATH.parent -else: - CONFIG_DIR = Path(APP_DIRS.user_config_dir) - CONFIG_PATH = CONFIG_DIR / "config.yaml" - -LOG_DIR = Path(APP_DIRS.user_log_dir) -LOG_PATH = LOG_DIR / "organize.log" - -for folder in (CONFIG_DIR, LOG_DIR): - folder.mkdir(parents=True, exist_ok=True) - -# create empty config file if it does not exist -if not CONFIG_PATH.exists(): - CONFIG_PATH.touch() - -# configure logging -LOGGING_CONFIG = """ -version: 1 -disable_existing_loggers: false -formatters: - simple: - format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' -handlers: - console: - class: logging.StreamHandler - level: DEBUG - formatter: simple - stream: ext://sys.stdout - file: - class: logging.handlers.TimedRotatingFileHandler - level: DEBUG - filename: {filename} - formatter: simple - when: midnight - backupCount: 30 -root: - level: DEBUG - handlers: [file] -exifread: - level: INFO -""".format( - filename=str(LOG_PATH) -) -logging.config.dictConfig(yaml.safe_load(LOGGING_CONFIG)) +# import logging +# import logging.config +# +# # configure logging +# LOGGING_CONFIG = """ +# version: 1 +# disable_existing_loggers: false +# formatters: +# simple: +# format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' +# handlers: +# console: +# class: logging.StreamHandler +# level: DEBUG +# formatter: simple +# stream: ext://sys.stdout +# file: +# class: logging.handlers.TimedRotatingFileHandler +# level: DEBUG +# filename: {filename} +# formatter: simple +# when: midnight +# backupCount: 30 +# root: +# level: DEBUG +# handlers: [file] +# exifread: +# level: INFO +# """.format( +# filename=str(LOG_PATH) +# ) +# logging.config.dictConfig(yaml.safe_load(LOGGING_CONFIG)) diff --git a/organize/__main__.py b/organize/__main__.py index 710a2d1f..42a01aa0 100644 --- a/organize/__main__.py +++ b/organize/__main__.py @@ -1,4 +1,4 @@ if __name__ == "__main__": - from .cli import main + from .cli import cli - main() + cli() diff --git a/organize/cli.py b/organize/cli.py index 7a7ae962..cd1a932a 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -1,12 +1,18 @@ +""" +organize + +The file management automation tool. +""" +import os +from pathlib import Path + +import appdirs import click -from . import CONFIG_DIR, CONFIG_PATH, LOG_PATH + from .__version__ import __version__ -from .config import CONFIG_SCHEMA from .output import console -import os -import appdirs -from pathlib import Path +DOCS_URL = "https://organize.readthedocs.io" # prepare config and log folders APP_DIRS = appdirs.AppDirs("organize") @@ -19,6 +25,16 @@ CONFIG_DIR = Path(APP_DIRS.user_config_dir) CONFIG_PATH = CONFIG_DIR / "config.yaml" +LOG_DIR = Path(APP_DIRS.user_log_dir) +LOG_PATH = LOG_DIR / "organize.log" + +for folder in (CONFIG_DIR, LOG_DIR): + folder.mkdir(parents=True, exist_ok=True) + +# create empty config file if it does not exist +if not CONFIG_PATH.exists(): + CONFIG_PATH.touch() + class NaturalOrderGroup(click.Group): def list_commands(self, ctx): @@ -52,16 +68,12 @@ def warn(msg): @click.group( + help=__doc__, cls=NaturalOrderGroup, context_settings=dict(help_option_names=["-h", "--help"]), ) @click.version_option(__version__) def cli(): - """ - organize - - The file management automation tool. - """ pass @@ -122,6 +134,8 @@ def schema(): """Checks whether the given rule file is valid""" import json + from .config import CONFIG_SCHEMA + js = json.dumps( CONFIG_SCHEMA.json_schema( schema_id="https://tfeldmann.de/organize.schema.json", @@ -162,7 +176,7 @@ def config(ctx, path, debug, open_folder): @cli.command(help="Open the documentation") def docs(): - click.launch("https://organize.readthedocs.io") + click.launch(DOCS_URL) if __name__ == "__main__": From 557c36dcfde7570e4636a6b971ac7613dd3a3995 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sat, 29 Jan 2022 14:51:58 +0100 Subject: [PATCH 057/108] update cli --- organize/cli.py | 24 +----------------------- organize/output.py | 4 ++++ 2 files changed, 5 insertions(+), 23 deletions(-) diff --git a/organize/cli.py b/organize/cli.py index cd1a932a..44c1b3fe 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -10,7 +10,7 @@ import click from .__version__ import __version__ -from .output import console +from .output import console, warn DOCS_URL = "https://organize.readthedocs.io" @@ -63,10 +63,6 @@ def list_commands(self, ctx): ) -def warn(msg): - console.print("Warning: %s" % msg, style="yellow") - - @click.group( help=__doc__, cls=NaturalOrderGroup, @@ -229,24 +225,6 @@ def docs(): # sys.exit(1) -# def config_edit(config_path: Path) -> None: -# """open the config file in $EDITOR or default text editor""" -# # attention: the env variable might contain command line arguments. -# # https://github.com/tfeldmann/organize/issues/24 -# editor = os.getenv("EDITOR") -# if editor: -# os.system('%s "%s"' % (editor, config_path)) -# else: -# open_in_filemanager(config_path) - - -# def open_in_filemanager(path: Path) -> None: -# """opens the given path in file manager, using the default application""" -# import webbrowser # pylint: disable=import-outside-toplevel - -# webbrowser.open(path.as_uri()) - - # def config_debug(config_path: Path) -> None: # """prints the config with resolved yaml aliases, checks rules syntax and checks # whether the given folders exist diff --git a/organize/output.py b/organize/output.py index f293d37a..a8c3341b 100644 --- a/organize/output.py +++ b/organize/output.py @@ -9,6 +9,10 @@ console = Console() +def warn(msg): + console.print("Warning: %s" % msg, style="yellow") + + class Output: """ class to track the current folder / file and print only changes. From 87e21351b67e2eee3a862fce8ca9ffa72ec1c8c8 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sat, 29 Jan 2022 16:04:50 +0100 Subject: [PATCH 058/108] finalized cli --- organize/cli.py | 66 ++++++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/organize/cli.py b/organize/cli.py index 44c1b3fe..ff00281b 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -91,13 +91,7 @@ def sim(rule_file, working_dir, config_file): print(rule_file, working_dir, config_file) -@cli.group() -def rules(): - """Manage your rules""" - pass - - -@rules.command() +@cli.command() @click.argument( "rule_file", required=False, @@ -111,23 +105,41 @@ def rules(): help="The editor to use. (Default: $EDITOR)", ) def edit(rule_file, editor): - """Edit a rule file. + """Edit the rules. - If called without further arguments, it will open the default rule file in $EDITOR. + If called without arguments, it will open the default rule file in $EDITOR. """ click.edit(filename=rule_file, editor=editor) -@rules.command() +@cli.command() @CLI_RULES_FILE def check(rule_file): - """Checks whether the given rule file is valid""" + """Checks whether a given rule file is valid. + + If called without arguments, it will check the default rule file + """ print(rule_file) -@rules.command() +@cli.command() +def path(): + """Prints the path of the default rule file.""" + + +@cli.command() +@click.option("--path", is_flag=True, help="Print the path") +def reveal(path): + """Reveals the default rule file.""" + if path: + click.echo(CONFIG_PATH) + else: + click.launch(str(CONFIG_PATH), locate=True) + + +@cli.command() def schema(): - """Checks whether the given rule file is valid""" + """Prints the json schema for rule files.""" import json from .config import CONFIG_SCHEMA @@ -140,39 +152,31 @@ def schema(): console.print_json(js) -@rules.command() -def path(): - """Prints the path of the default rule file""" - click.echo(CONFIG_PATH) - - -@rules.command() -def reveal(): - """Reveals the default rule file""" - click.launch(str(CONFIG_PATH), locate=True) +@cli.command() +def docs(): + """Opens the documentation.""" + click.launch(DOCS_URL) +# deprecated - only here for backwards compatibility @cli.command(hidden=True) @click.option("--path", is_flag=True, help="Print the default config file path") @click.option("--debug", is_flag=True, help="Debug the default config file") -@click.option("--open-folder", is_flag=True) # backwards compatibility +@click.option("--open-folder", is_flag=True) @click.pass_context def config(ctx, path, debug, open_folder): """Edit the default configuration file.""" - warn("`organize config` is deprecated. Please try the new `organize rules`.") if open_folder: ctx.invoke(reveal) elif path: - ctx.invoke(path) + ctx.invoke(reveal, path=True) + return elif debug: ctx.invoke(check) else: ctx.invoke(edit) - - -@cli.command(help="Open the documentation") -def docs(): - click.launch(DOCS_URL) + warn("`organize config` is deprecated.") + warn("Please see `organize --help` for all available commands.") if __name__ == "__main__": From e0e727ca3bac65a6738d1147944562a4121bfeee Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sat, 29 Jan 2022 16:47:53 +0100 Subject: [PATCH 059/108] ignore errors setting --- organize/cli.py | 24 ++++++++++-------- organize/config.py | 6 ++--- organize/core.py | 63 +++++++++++++++++++++++++++++----------------- organize/output.py | 19 +++++++++++++- testconf.yaml | 2 ++ 5 files changed, 76 insertions(+), 38 deletions(-) diff --git a/organize/cli.py b/organize/cli.py index ff00281b..fe6a0f95 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -12,8 +12,6 @@ from .__version__ import __version__ from .output import console, warn -DOCS_URL = "https://organize.readthedocs.io" - # prepare config and log folders APP_DIRS = appdirs.AppDirs("organize") @@ -45,6 +43,7 @@ def list_commands(self, ctx): "rule_file", required=False, envvar="ORGANIZE_RULE_FILE", + default=CONFIG_PATH, type=click.Path(exists=True), ) CLI_WORKING_DIR_OPTION = click.option( @@ -79,7 +78,11 @@ def cli(): @CLI_CONFIG_FILE_OPTION def run(rule_file, working_dir, config_file): """Organizes your files according to your rules.""" - print(rule_file, working_dir, config_file) + from .core import run_file + + if config_file and not rule_file: + rule_file = config_file + run_file(rule_file=rule_file, working_dir=working_dir, simulate=False) @cli.command() @@ -88,7 +91,11 @@ def run(rule_file, working_dir, config_file): @CLI_CONFIG_FILE_OPTION def sim(rule_file, working_dir, config_file): """Simulates a run (does not touch your files).""" - print(rule_file, working_dir, config_file) + from .core import run_file + + if config_file and not rule_file: + rule_file = config_file + run_file(rule_file=rule_file, working_dir=working_dir, simulate=False) @cli.command() @@ -123,12 +130,7 @@ def check(rule_file): @cli.command() -def path(): - """Prints the path of the default rule file.""" - - -@cli.command() -@click.option("--path", is_flag=True, help="Print the path") +@click.option("--path", is_flag=True, help="Print the path instead of revealing it.") def reveal(path): """Reveals the default rule file.""" if path: @@ -155,7 +157,7 @@ def schema(): @cli.command() def docs(): """Opens the documentation.""" - click.launch(DOCS_URL) + click.launch("https://organize.readthedocs.io") # deprecated - only here for backwards compatibility diff --git a/organize/config.py b/organize/config.py index ec1b0f7a..93513399 100644 --- a/organize/config.py +++ b/organize/config.py @@ -2,7 +2,7 @@ import yaml from rich.console import Console -from schema import And, Optional, Or, Schema +from schema import And, Optional, Or, Schema, Literal, Const from organize.actions import ACTIONS from organize.filters import FILTERS @@ -11,8 +11,7 @@ CONFIG_SCHEMA = Schema( { - Optional("version"): int, - "rules": [ + Literal("rules", description="All rules are defined here."): [ { Optional("name", description="The name of the rule."): str, Optional( @@ -48,6 +47,7 @@ ], } ], + Optional("version"): int, }, name="organize rule configuration", ) diff --git a/organize/core.py b/organize/core.py index 018c214c..f745cc8f 100644 --- a/organize/core.py +++ b/organize/core.py @@ -10,10 +10,11 @@ from .actions import ACTIONS from .actions.action import Action +from .config import CONFIG_SCHEMA, load_from_file from .filters import FILTERS from .filters.filter import Filter -from .output import RichOutput, console -from .utils import deep_merge_inplace, Template, ensure_list +from .output import ColoredOutput, console, warn +from .utils import Template, deep_merge_inplace, ensure_list logger = logging.getLogger(__name__) @@ -24,7 +25,7 @@ class Location(NamedTuple): path: str -output_helper = RichOutput() +output = ColoredOutput() DEFAULT_SYSTEM_EXCLUDE_FILES = [ "thumbs.db", @@ -59,7 +60,7 @@ def walker_args_from_location_options(options): } -def instantiate_location(loc): +def instantiate_location(loc) -> Location: if isinstance(loc, str): loc = {"path": loc} @@ -96,10 +97,23 @@ def instantiate_by_name(d, classes): def replace_with_instances(config): + warnings = [] + for rule in config["rules"]: - rule["locations"] = [ - instantiate_location(loc) for loc in ensure_list(rule["locations"]) - ] + locations = [] + + for loc in ensure_list(rule["locations"]): + try: + instance = instantiate_location(loc) + locations.append(instance) + except Exception as e: + if loc.get("ignore_errors", False): + warnings.append(str(e)) + else: + raise e + + rule["locations"] = locations + # filters are optional rule["filters"] = [ instantiate_by_name(x, FILTERS) @@ -109,6 +123,8 @@ def replace_with_instances(config): instantiate_by_name(x, ACTIONS) for x in ensure_list(rule["actions"]) ] + return warnings + def filter_pipeline(filters: Iterable[Filter], args: dict) -> bool: """ @@ -145,17 +161,17 @@ def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bo def run(config, simulate: bool = True): count = [0, 0] - Action.print_hook = output_helper.pipeline_message - Action.print_error_hook = output_helper.pipeline_error - Filter.print_hook = output_helper.pipeline_message - Filter.print_error_hook = output_helper.pipeline_error + Action.print_hook = output.pipeline_message + Action.print_error_hook = output.pipeline_error + Filter.print_hook = output.pipeline_message + Filter.print_error_hook = output.pipeline_error if simulate: - output_helper.print_simulation_banner() + output.print_simulation_banner() for rule in config["rules"]: target = rule.get("targets", "files") - output_helper.print_rule(rule["name"]) + output.print_rule(rule["name"]) status_verb = "simulating" if simulate else "organizing" with console.status("[bold green]%s..." % status_verb) as status: @@ -163,7 +179,7 @@ def run(config, simulate: bool = True): walk = walker.files if target == "files" else walker.dirs for path in walk(fs=base_fs, path=base_path): relative_path = fs.path.relativefrom(base_path, path) - output_helper.set_location(base_fs, relative_path) + output.set_location(base_fs, relative_path) args = { "fs": base_fs, "fs_path": path, @@ -186,18 +202,19 @@ def run(config, simulate: bool = True): count[success] += 1 if simulate: - output_helper.print_simulation_banner() - + output.print_simulation_banner() -if __name__ == "__main__": - from .config import CONFIG_SCHEMA, load_from_file - conf = load_from_file("testconf.yaml") +def run_file(rule_file: str, working_dir: str, simulate: bool): try: - console.print(CONFIG_SCHEMA.json_schema(None)) - CONFIG_SCHEMA.validate(conf) - replace_with_instances(conf) - run(conf, simulate=True) + output.print_info(rule_file, working_dir, simulate) + rules = load_from_file(rule_file) + CONFIG_SCHEMA.validate(rules) + warnings = replace_with_instances(rules) + for msg in warnings: + output.print_warning(msg) + os.chdir(working_dir) + run(rules, simulate=simulate) except SchemaError as e: console.print("Invalid config file") console.print(e.autos[-1]) diff --git a/organize/output.py b/organize/output.py index a8c3341b..5896ff12 100644 --- a/organize/output.py +++ b/organize/output.py @@ -4,6 +4,7 @@ from rich.rule import Rule from rich.console import Console from rich.panel import Panel +from organize.__version__ import __version__ logger = logging.getLogger(__name__) console = Console() @@ -78,12 +79,28 @@ def print_pipeline_message(self, name, msg): def print_pipeline_error(self, name, msg): raise NotImplementedError + def print_info(self): + raise NotImplementedError + + def print_warning(self): + raise NotImplementedError + + +class ColoredOutput(Output): + def print_info(self, rule_file, working_dir, simulate): + console.print("organize {}".format(__version__)) + console.print("Rule file: \"{}\"".format(rule_file)) + if working_dir != ".": + console.print("Working dir: {}".format(working_dir)) + + def print_warning(self, msg): + console.print("[yellow][bold]Warning:[/bold] {}[/yellow]".format(msg)) -class RichOutput(Output): def print_simulation_banner(self): console.print(Panel("[bold green]SIMULATION", style="green")) def print_rule(self, rule): + console.print() console.print(Rule(rule, align="left", style="gray"), style="bold") def print_location(self, folder): diff --git a/testconf.yaml b/testconf.yaml index dfd655cf..8319e11b 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -8,8 +8,10 @@ rules: max_depth: null - path: "~/Desktop/Testfolder 2" max_depth: null + ignore_errors: true - filesystem: zip:///Users/thomasfeldmann/Desktop/Testfolder.zip max_depth: null + ignore_errors: true path: "/" filters: - name: From fbddf50cee2a9f8c75d251485855e3dcf90e87b9 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sat, 29 Jan 2022 17:18:29 +0100 Subject: [PATCH 060/108] add icons --- organize/cli.py | 2 +- organize/core.py | 2 +- organize/output.py | 31 +++++++++++++++++++++---------- testconf.yaml | 35 +++++++++++++++++------------------ 4 files changed, 40 insertions(+), 30 deletions(-) diff --git a/organize/cli.py b/organize/cli.py index fe6a0f95..5702df65 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -95,7 +95,7 @@ def sim(rule_file, working_dir, config_file): if config_file and not rule_file: rule_file = config_file - run_file(rule_file=rule_file, working_dir=working_dir, simulate=False) + run_file(rule_file=rule_file, working_dir=working_dir, simulate=True) @cli.command() diff --git a/organize/core.py b/organize/core.py index f745cc8f..9021bf83 100644 --- a/organize/core.py +++ b/organize/core.py @@ -179,7 +179,7 @@ def run(config, simulate: bool = True): walk = walker.files if target == "files" else walker.dirs for path in walk(fs=base_fs, path=base_path): relative_path = fs.path.relativefrom(base_path, path) - output.set_location(base_fs, relative_path) + output.set_location(base_fs, relative_path, targets=target) args = { "fs": base_fs, "fs_path": path, diff --git a/organize/output.py b/organize/output.py index 5896ff12..1da459ba 100644 --- a/organize/output.py +++ b/organize/output.py @@ -27,10 +27,15 @@ def __init__(self) -> None: self.curr_path = None self.prev_folder = None self.prev_path = None + self._location_target = "" - def set_location(self, folder, path) -> None: + def set_location(self, folder, path, targets="files") -> None: self.curr_folder = folder self.curr_path = path + if targets == "dirs": + self._location_target = "🗀" # ":file_folder:" + else: + self._location_target = "🗅" # ":page_facing_up:" def print_location_update(self): if self.curr_folder != self.prev_folder: @@ -89,7 +94,7 @@ def print_warning(self): class ColoredOutput(Output): def print_info(self, rule_file, working_dir, simulate): console.print("organize {}".format(__version__)) - console.print("Rule file: \"{}\"".format(rule_file)) + console.print('Rule file: "{}"'.format(rule_file)) if working_dir != ".": console.print("Working dir: {}".format(working_dir)) @@ -100,29 +105,35 @@ def print_simulation_banner(self): console.print(Panel("[bold green]SIMULATION", style="green")) def print_rule(self, rule): - console.print() - console.print(Rule(rule, align="left", style="gray"), style="bold") + console.print( + Rule( + "[bold yellow]:gear: %s[/bold yellow]" % rule, + align="left", + style="yellow", + ) + ) def print_location(self, folder): if isinstance(folder, OSFS): - console.print(folder.root_path) + console.print(folder.root_path, style="purple bold") else: - console.print(str(folder), style="bold") + console.print(str(folder), style="purple bold") def print_location_spacer(self): console.print() def print_path(self, path): - # file "page_facing_up": "📄", - # dirs "file_folder": "📁", - console.print(indent(":file_folder: %s" % path, " " * 2), style="purple bold") + console.print( + indent("%s %s" % (self._location_target, path), " " * 2), + style="italic purple", + ) def print_not_found(self, path): msg = "Path not found: {}".format(path) console.print(msg, style="bold yellow") def print_pipeline_message(self, name, msg, *args, **kwargs): - console.print(indent("- (%s) %s" % (name, msg), " " * 4)) + console.print(indent("- (%s) %s" % (name, msg), " " * 4), style="green") def print_pipeline_error(self, name, msg): console.print( diff --git a/testconf.yaml b/testconf.yaml index 8319e11b..b6c833bb 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -1,24 +1,25 @@ rules: - - name: "Find the last modification date of some png files" + - name: "Files starting with L" targets: files locations: - - path: ~/Desktop/Testfolder - max_depth: null - - path: ~/Desktop/Testfolder - max_depth: null - - path: "~/Desktop/Testfolder 2" - max_depth: null - ignore_errors: true - - filesystem: zip:///Users/thomasfeldmann/Desktop/Testfolder.zip - max_depth: null - ignore_errors: true - path: "/" + - path: ~/Desktop + max_depth: 3 filters: - name: - startswith: Let - - extension: webloc + startswith: L actions: - - echo: "{extension.upper()[1:]} {name}" + - echo: "{name}" + + - name: "Folders" + targets: dirs + locations: + - path: ~/Desktop + max_depth: 3 + filters: + - name: + startswith: I + actions: + - echo: "{name}" # - name: Find some folders # targets: dirs @@ -26,7 +27,5 @@ rules: # - ~/Desktop # - path: ~/Desktop/Inbox # max_depth: null - # filters: - # - extension: pdf # actions: - # - copy: ~/Dir + # - echo: ~/Dir From 409d0233894016455e20281c9eddbb0c96568cd5 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 30 Jan 2022 14:40:00 +0100 Subject: [PATCH 061/108] super nice console output --- organize/actions/action.py | 19 ++- organize/cli.py | 110 +++++---------- organize/config.py | 5 +- organize/core.py | 42 +++--- organize/filters/filter.py | 10 +- organize/output.py | 275 +++++++++++++++++++------------------ organize/utils.py | 2 +- testconf.yaml | 14 +- 8 files changed, 225 insertions(+), 252 deletions(-) diff --git a/organize/actions/action.py b/organize/actions/action.py index 2775c382..60b49063 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -1,5 +1,9 @@ -from schema import Or, Schema, Optional -from typing import Any, Dict, Optional as tyOptional, Callable +from typing import Any, Dict +from typing import Optional as tyOptional + +from schema import Optional, Or, Schema + +from organize.output import pipeline_error, pipeline_message class Error(Exception): @@ -7,9 +11,6 @@ class Error(Exception): class Action: - print_hook = None # type: Optional[Callable] - print_error_hook = None # type: Optional[Callable] - name = None arg_schema = None schema_support_instance_without_args = False @@ -47,14 +48,12 @@ def run(self, **kwargs) -> tyOptional[Dict[str, Any]]: def pipeline(self, args: dict, simulate: bool) -> tyOptional[Dict[str, Any]]: raise NotImplementedError - def print(self, msg, *args, **kwargs) -> None: + def print(self, msg) -> None: """print a message for the user""" - if callable(self.print_hook): - self.print_hook(name=self.name, msg=msg, *args, **kwargs) + pipeline_message(source=self.get_name(), msg=msg) def print_error(self, msg: str): - if callable(self.print_error_hook): - self.print_error_hook(name=self.name, msg=msg) + pipeline_error(source=self.get_name(), msg=msg) def __str__(self) -> str: return self.__class__.__name__ diff --git a/organize/cli.py b/organize/cli.py index 5702df65..d166d615 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -9,15 +9,16 @@ import appdirs import click +from . import output +from .output import console from .__version__ import __version__ -from .output import console, warn # prepare config and log folders APP_DIRS = appdirs.AppDirs("organize") # setting the $ORGANIZE_CONFIG env variable overrides the default config path if os.getenv("ORGANIZE_CONFIG"): - CONFIG_PATH = Path(os.getenv("ORGANIZE_CONFIG", "")).resolve() + CONFIG_PATH = Path(os.getenv("ORGANIZE_CONFIG")).resolve() CONFIG_DIR = CONFIG_PATH.parent else: CONFIG_DIR = Path(APP_DIRS.user_config_dir) @@ -39,10 +40,10 @@ def list_commands(self, ctx): return self.commands.keys() -CLI_RULES_FILE = click.argument( - "rule_file", +CLI_CONFIG = click.argument( + "config", required=False, - envvar="ORGANIZE_RULE_FILE", + envvar="ORGANIZE_CONFIG", default=CONFIG_PATH, type=click.Path(exists=True), ) @@ -55,7 +56,6 @@ def list_commands(self, ctx): # for CLI backwards compatibility with organize v1.x CLI_CONFIG_FILE_OPTION = click.option( "--config-file", - envvar="ORGANIZE_CONFIG", default=None, hidden=True, type=click.Path(exists=True), @@ -73,37 +73,43 @@ def cli(): @cli.command() -@CLI_RULES_FILE +@CLI_CONFIG @CLI_WORKING_DIR_OPTION @CLI_CONFIG_FILE_OPTION -def run(rule_file, working_dir, config_file): +def run(config, working_dir, config_file): """Organizes your files according to your rules.""" from .core import run_file - if config_file and not rule_file: - rule_file = config_file - run_file(rule_file=rule_file, working_dir=working_dir, simulate=False) + if config_file: + config = config_file + output.deprecated( + "The --config-file option can now be omitted. See organize --help." + ) + run_file(config_file=config, working_dir=working_dir, simulate=False) @cli.command() -@CLI_RULES_FILE +@CLI_CONFIG @CLI_WORKING_DIR_OPTION @CLI_CONFIG_FILE_OPTION -def sim(rule_file, working_dir, config_file): +def sim(config, working_dir, config_file): """Simulates a run (does not touch your files).""" from .core import run_file - if config_file and not rule_file: - rule_file = config_file - run_file(rule_file=rule_file, working_dir=working_dir, simulate=True) + if config_file: + config = config_file + output.deprecated( + "The --config-file option can now be omitted. See organize --help." + ) + run_file(config_file=config, working_dir=working_dir, simulate=True) @cli.command() @click.argument( - "rule_file", + "config", required=False, default=CONFIG_PATH, - envvar="ORGANIZE_RULE_FILE", + envvar="ORGANIZE_CONFIG", type=click.Path(), ) @click.option( @@ -111,22 +117,22 @@ def sim(rule_file, working_dir, config_file): envvar="EDITOR", help="The editor to use. (Default: $EDITOR)", ) -def edit(rule_file, editor): +def edit(config, editor): """Edit the rules. - If called without arguments, it will open the default rule file in $EDITOR. + If called without arguments it will open the default rule file in $EDITOR. """ - click.edit(filename=rule_file, editor=editor) + click.edit(filename=config, editor=editor) @cli.command() -@CLI_RULES_FILE -def check(rule_file): +@CLI_CONFIG +def check(config): """Checks whether a given rule file is valid. - If called without arguments, it will check the default rule file + If called without arguments it will check the default rule file. """ - print(rule_file) + print(config) @cli.command() @@ -177,60 +183,14 @@ def config(ctx, path, debug, open_folder): ctx.invoke(check) else: ctx.invoke(edit) - warn("`organize config` is deprecated.") - warn("Please see `organize --help` for all available commands.") + output.deprecated("`organize config` is deprecated.") + output.deprecated("Please see `organize --help` for all available commands.") if __name__ == "__main__": cli() -# def main(argv=None): -# """entry point for the command line interface""" -# args = docopt(__doc__, argv=argv, version=__version__, help=True) - -# # override default config file path -# if args["--config-file"]: -# expanded_path = os.path.expandvars(args["--config-file"]) -# config_path = Path(expanded_path).expanduser().resolve() -# config_dir = config_path.parent -# else: -# config_dir = CONFIG_DIR -# config_path = CONFIG_PATH - -# # > organize config -# if args["config"]: -# if args["--open-folder"]: -# open_in_filemanager(config_dir) -# elif args["--path"]: -# print(str(config_path)) -# elif args["--debug"]: -# config_debug(config_path) -# else: -# config_edit(config_path) - -# # > organize list -# elif args["list"]: -# list_actions_and_filters() - -# # > organize sim / run -# else: -# try: -# config = Config.from_file(config_path) -# execute_rules(config.rules, simulate=args["sim"]) -# except Config.Error as e: -# logger.exception(e) -# print_error(e) -# print("Try 'organize config --debug' for easier debugging.") -# print("Full traceback at: %s" % LOG_PATH) -# sys.exit(1) -# except Exception as e: # pylint: disable=broad-except -# logger.exception(e) -# print_error(e) -# print("Full traceback at: %s" % LOG_PATH) -# sys.exit(1) - - # def config_debug(config_path: Path) -> None: # """prints the config with resolved yaml aliases, checks rules syntax and checks # whether the given folders exist @@ -275,7 +235,3 @@ def config(ctx, path, debug, open_folder): # print(Style.BRIGHT + "Actions:") # for name, _ in inspect.getmembers(actions, inspect.isclass): # print(" " + name) - - -# def print_error(e: Union[Exception, str]) -> None: -# print(Style.BRIGHT + Fore.RED + "ERROR:" + Style.RESET_ALL + " %s" % e) diff --git a/organize/config.py b/organize/config.py index 93513399..e2f423bf 100644 --- a/organize/config.py +++ b/organize/config.py @@ -1,14 +1,11 @@ import textwrap import yaml -from rich.console import Console -from schema import And, Optional, Or, Schema, Literal, Const +from schema import And, Optional, Or, Schema, Literal from organize.actions import ACTIONS from organize.filters import FILTERS -console = Console() - CONFIG_SCHEMA = Schema( { Literal("rules", description="All rules are defined here."): [ diff --git a/organize/core.py b/organize/core.py index 9021bf83..0e86bbe7 100644 --- a/organize/core.py +++ b/organize/core.py @@ -1,5 +1,6 @@ import logging import os +from collections import Counter from datetime import datetime from typing import Iterable, NamedTuple @@ -8,12 +9,13 @@ from fs.walk import Walker from schema import SchemaError +from . import output from .actions import ACTIONS from .actions.action import Action from .config import CONFIG_SCHEMA, load_from_file from .filters import FILTERS from .filters.filter import Filter -from .output import ColoredOutput, console, warn +from .output import console from .utils import Template, deep_merge_inplace, ensure_list logger = logging.getLogger(__name__) @@ -25,8 +27,6 @@ class Location(NamedTuple): path: str -output = ColoredOutput() - DEFAULT_SYSTEM_EXCLUDE_FILES = [ "thumbs.db", "desktop.ini", @@ -160,26 +160,22 @@ def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bo def run(config, simulate: bool = True): - count = [0, 0] - Action.print_hook = output.pipeline_message - Action.print_error_hook = output.pipeline_error - Filter.print_hook = output.pipeline_message - Filter.print_error_hook = output.pipeline_error + count = Counter(done=0, fail=0) if simulate: - output.print_simulation_banner() + output.simulation_banner() for rule in config["rules"]: target = rule.get("targets", "files") - output.print_rule(rule["name"]) + output.rule(rule["name"]) - status_verb = "simulating" if simulate else "organizing" - with console.status("[bold green]%s..." % status_verb) as status: + with output.spinner(simulate=simulate): for walker, base_fs, base_path in rule["locations"]: + output.location(base_fs, base_path) walk = walker.files if target == "files" else walker.dirs for path in walk(fs=base_fs, path=base_path): + output.path(base_fs, path) relative_path = fs.path.relativefrom(base_path, path) - output.set_location(base_fs, relative_path, targets=target) args = { "fs": base_fs, "fs_path": path, @@ -194,27 +190,33 @@ def run(config, simulate: bool = True): args=args, ) if match: - success = action_pipeline( + is_success = action_pipeline( actions=rule["actions"], args=args, simulate=simulate, ) - count[success] += 1 + if is_success: + count["done"] += 1 + else: + count["fail"] += 1 if simulate: - output.print_simulation_banner() + output.simulation_banner() + + return count -def run_file(rule_file: str, working_dir: str, simulate: bool): +def run_file(config_file: str, working_dir: str, simulate: bool): + output.info(config_file, working_dir) try: - output.print_info(rule_file, working_dir, simulate) - rules = load_from_file(rule_file) + rules = load_from_file(config_file) CONFIG_SCHEMA.validate(rules) warnings = replace_with_instances(rules) for msg in warnings: output.print_warning(msg) os.chdir(working_dir) - run(rules, simulate=simulate) + count = run(rules, simulate=simulate) + output.summary(count) except SchemaError as e: console.print("Invalid config file") console.print(e.autos[-1]) diff --git a/organize/filters/filter.py b/organize/filters/filter.py index 080419b7..eb4a0f71 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -1,6 +1,7 @@ from schema import Schema, Optional, Or from textwrap import indent from typing import Any, Dict, Union, NamedTuple +from organize.output import pipeline_message, pipeline_error class FilterResult(NamedTuple): @@ -9,9 +10,6 @@ class FilterResult(NamedTuple): class Filter: - print_hook = None - print_error_hook = None - name = None # type: Union[str, None] arg_schema = None # type: Union[Schema, None] schema_support_instance_without_args = False @@ -61,12 +59,10 @@ def pipeline(self, args: dict) -> FilterResult: def print(self, msg: str) -> None: """print a message for the user""" - if callable(self.print_hook): - self.print_hook(name=self.name, msg=msg) + pipeline_message(self.get_name(), msg) def print_error(self, msg: str): - if callable(self.print_error_hook): - self.print_error_hook(name=self.name, msg=msg) + pipeline_error(self.get_name(), msg) def __str__(self) -> str: """Return filter name and properties""" diff --git a/organize/output.py b/organize/output.py index 1da459ba..b912ae81 100644 --- a/organize/output.py +++ b/organize/output.py @@ -1,141 +1,156 @@ -import logging -from textwrap import indent -from fs.osfs import OSFS -from rich.rule import Rule +from collections import Counter +from fs.path import basename, dirname, forcedir from rich.console import Console from rich.panel import Panel +from rich.text import Text +from rich.theme import Theme + from organize.__version__ import __version__ -logger = logging.getLogger(__name__) -console = Console() - - -def warn(msg): - console.print("Warning: %s" % msg, style="yellow") - - -class Output: - """ - class to track the current folder / file and print only changes. - This is needed because we only want to output the current folder and file if the - filter or action prints something. - """ - - def __init__(self) -> None: - self.not_found = set() - self.curr_folder = None - self.curr_path = None - self.prev_folder = None - self.prev_path = None - self._location_target = "" - - def set_location(self, folder, path, targets="files") -> None: - self.curr_folder = folder - self.curr_path = path - if targets == "dirs": - self._location_target = "🗀" # ":file_folder:" - else: - self._location_target = "🗅" # ":page_facing_up:" - - def print_location_update(self): - if self.curr_folder != self.prev_folder: - if self.prev_folder is not None: - self.print_location_spacer() - self.print_location(self.curr_folder) - self.prev_folder = self.curr_folder - - if self.curr_path != self.prev_path: - self.print_path(self.curr_path) - self.prev_path = self.curr_path - - def pipeline_message(self, name, msg, *args, **kwargs) -> None: - """ - pre-print hook that is called everytime the moment before a filter or action is - about to print something to the cli - """ - self.print_location_update() - self.print_pipeline_message(name, msg, *args, **kwargs) - - def pipeline_error(self, name, msg): - self.print_location_update() - self.print_pipeline_error(name, msg) - - def path_not_found(self, folderstr: str) -> None: - if folderstr not in self.not_found: - self.not_found.add(folderstr) - self.print_not_found(folderstr) - logger.warning("Path not found: %s", folderstr) - - def print_location_spacer(self): - raise NotImplementedError - - def print_location(self, folder): - raise NotImplementedError - - def print_path(self, path): - raise NotImplementedError - - def print_not_found(self, path): - raise NotImplementedError - - def print_pipeline_message(self, name, msg): - raise NotImplementedError - - def print_pipeline_error(self, name, msg): - raise NotImplementedError - - def print_info(self): - raise NotImplementedError - - def print_warning(self): - raise NotImplementedError - - -class ColoredOutput(Output): - def print_info(self, rule_file, working_dir, simulate): - console.print("organize {}".format(__version__)) - console.print('Rule file: "{}"'.format(rule_file)) - if working_dir != ".": - console.print("Working dir: {}".format(working_dir)) - - def print_warning(self, msg): - console.print("[yellow][bold]Warning:[/bold] {}[/yellow]".format(msg)) - - def print_simulation_banner(self): - console.print(Panel("[bold green]SIMULATION", style="green")) - - def print_rule(self, rule): - console.print( - Rule( - "[bold yellow]:gear: %s[/bold yellow]" % rule, - align="left", - style="yellow", - ) - ) +from .utils import resource_description - def print_location(self, folder): - if isinstance(folder, OSFS): - console.print(folder.root_path, style="purple bold") - else: - console.print(str(folder), style="purple bold") +ICON_DIR = "🗁" +ICON_FILE = "" +INDENT = " " * 2 - def print_location_spacer(self): - console.print() +theme = Theme( + { + "info": "dim cyan", + "warning": "yellow", + "error": "bold red", + "simulation": "bold green", + "status": "bold green", + "rule": "bold cyan", + "location.fs": "yellow", + "location.base": "green", + "location.main": "bold green", + "path.base": "dim white", + "path.main": "white", + "path.icon": "white", + "pipeline.source": "cyan", + "pipeline.msg": "white", + "pipeline.error": "bold red", + "summary.done": "bold green", + "summary.fail": "red", + } +) +console = Console(theme=theme, highlight=False) + + +class Prefixer: + def __init__(self): + self.reset() + + def reset(self): + self._args = None + self._kwargs = None + + def set_prefix(self, *args, **kwargs): + self._args = args + self._kwargs = kwargs + + def print(self, *args, **kwargs): + if self._args is not None: + console.print(*self._args, **self._kwargs) + self.reset() + console.print(*args, **kwargs) + + +with_path = Prefixer() +with_newline = Prefixer() + + +def _highlight_path(path, base_style, main_style): + return Text.assemble( + (forcedir(dirname(path)), base_style), + (basename(path), main_style), + ) - def print_path(self, path): - console.print( - indent("%s %s" % (self._location_target, path), " " * 2), - style="italic purple", - ) - def print_not_found(self, path): - msg = "Path not found: {}".format(path) - console.print(msg, style="bold yellow") +def info(rule_file, working_dir): + console.print("organize {}".format(__version__)) + console.print('Config file: "{}"'.format(rule_file)) + if working_dir != ".": + console.print("Working dir: {}".format(working_dir)) - def print_pipeline_message(self, name, msg, *args, **kwargs): - console.print(indent("- (%s) %s" % (name, msg), " " * 4), style="green") - def print_pipeline_error(self, name, msg): - console.print( - indent("- ([bold red]%s[/]) [bold red]ERROR! %s[/]" % (name, msg), " " * 4) +def warn(msg, title="Warning"): + console.print("[warning][b]{}:[/b] {}[/warning]".format(title, msg)) + + +def deprecated(msg): + warn(msg, title="Deprecated") + + +def simulation_banner(): + console.print() + console.print(Panel("SIMULATION", style="simulation")) + + +def spinner(simulate: bool): + status_verb = "simulating" if simulate else "organizing" + return console.status("[status]%s..." % status_verb) + + +def rule(rule): + console.print() + console.rule("[rule]:gear: %s" % rule, align="left", style="rule") + with_newline.reset() + + +def location(fs, path): + result = Text() + if fs.hassyspath(path): + syspath = fs.getsyspath(path) + result = _highlight_path(syspath.rstrip("/"), "location.base", "location.main") + else: + result = Text.assemble( + (str(fs), "location.fs"), + " ", + _highlight_path(path.rstrip("/"), "location.base", "location.main"), + ) + with_newline.print(result) + + +def path(fs, path): + icon = ICON_DIR if fs.isdir(path) else ICON_FILE + msg = Text.assemble( + INDENT, + _highlight_path(path, "path.base", "path.main"), + " ", + (icon, "path.icon"), + ) + with_path.set_prefix(msg) + + +def pipeline_message(source: str, msg: str) -> None: + line = Text.assemble( + INDENT * 2, + ("- ({})".format(source), "pipeline.source"), + (msg, "pipeline.msg"), + ) + with_path.print(line) + with_newline.set_prefix("") + + +def pipeline_error(source: str, msg: str): + line = Text.assemble( + INDENT * 2, + ("- ({})".format(source), "pipeline.source"), + ("ERROR! {}".format(msg), "pipeline.error"), + ) + with_path.print(line) + with_newline.set_prefix("") + + +def summary(count: Counter): + console.print() + if not sum(count.values()): + console.print("Nothing to do.") + else: + result = Text.assemble( + ("success {done}".format(**count), "summary.done"), + " / ", + ("fail {fail}".format(**count), "summary.fail"), ) + console.print(result) diff --git a/organize/utils.py b/organize/utils.py index c9442d9e..46441c71 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -2,7 +2,7 @@ from collections.abc import Mapping from copy import deepcopy from pathlib import Path -from typing import Any, Hashable, List, NamedTuple, Sequence, Union +from typing import Any, Hashable, List, Sequence, Union from fs.base import FS from fs.osfs import OSFS diff --git a/testconf.yaml b/testconf.yaml index b6c833bb..2e6f5ea0 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -2,11 +2,19 @@ rules: - name: "Files starting with L" targets: files locations: - - path: ~/Desktop + - path: ~/Downloads + max_depth: 3 + - path: ~/Documents + max_depth: 3 + - path: ~/Pictures max_depth: 3 + ignore_errors: true + - filesystem: zip:///Users/thomasfeldmann/Downloads/105133-0001_stp.zip + path: "/Test" + ignore_errors: true filters: - name: - startswith: L + startswith: Liasd actions: - echo: "{name}" @@ -17,7 +25,7 @@ rules: max_depth: 3 filters: - name: - startswith: I + startswith: Iasdawf actions: - echo: "{name}" From 28d4561970a7a703153ef4c17be1c99adeba5dae Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 30 Jan 2022 15:28:37 +0100 Subject: [PATCH 062/108] fix mypy warnings --- CHANGELOG.md | 33 +++-- README.md | 2 +- main.py | 4 +- organize/actions/__init__.py | 3 +- organize/actions/action.py | 8 +- organize/actions/copy.py | 2 + organize/actions/macos_tags.py | 8 +- organize/actions/move.py | 2 + organize/actions/python.py | 2 +- organize/actions/rename.py | 7 +- organize/actions/shell.py | 2 +- organize/actions/utils.py | 2 + organize/cli.py | 2 +- organize/config.py | 6 +- organize/core.py | 8 +- organize/filters/__init__.py | 4 +- organize/filters/exif.py | 2 +- organize/filters/size.py | 2 +- organize/utils.py | 5 - poetry.lock | 227 ++++++++++++++++++++++----------- pyproject.toml | 14 +- 21 files changed, 218 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a833b80..a502b06a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,34 +2,33 @@ ## v2 - In Progress -This is a huge update with a large refactoring. -Please backup all your important stuff before running. +This is a huge update with lots of improvements. +Please backup all your important stuff before running and use the simulate option! ### what's new -- Completely rewritten core! - Respects your rule order - safer, less magic, less surprises. (v1 tried to be clever. v2 now works your config file from top to bottom) -- Now you can organize (S)FTP, S3 Buckets, Zip archives and many more. - - Most of the actions like `move` and `copy` even work across file systems! +- You can now target directories with your rules (copying, renaming, etc a whole folder) +- Organize inside or between (S)FTP, S3 Buckets, Zip archives and many more. - [Available filesystems](https://www.pyfilesystem.org/page/index-of-filesystems/) -- You can now target folders with your rules. Like copying a whole folder, renaming etc. - `max_depth` setting when recursing into subfolders -- starts instantly (does not need to gather all the files before starting) -- filters can now be excluded -- nice terminal output -- rule names -- cleaner config file validation and stricter format -- option to run `python` actions in simulation -- added `empty` filter. +- Instant start. (does not need to gather all the files before starting) +- Filters can now be excluded. +- Nice terminal output. +- Rule names. - new conflict resolution settings in `move`, `copy` and `rename` action: - `skip`, `overwrite`, `trash`, `rename_new`, `rename_existing` as well as a - `rename_template` parameter. -- the `shell` action now returns stdout and errorcode. -- Added `symlink` action + - Options are `skip`, `overwrite`, `trash`, `rename_new` or `rename_existing` + - You can now define a custom `rename_template`. +- The `python` action can now be run in simulation. +- The `shell` action now returns stdout and errorcode. +- Added filter `empty`. +- Added filter `hash`. +- Added action `symlink`. ### changed +- cleaner config file validation and stricter format - The config file format got a long due overhaul. Please see the [migration documentation](docs/06-updating-from-v1.md) for what is new. - The `timezone` keyword for `lastmodified` and `created` was removed. The timezone is diff --git a/README.md b/README.md index a2d2cd10..57feffe5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- organize logo + organize logo

diff --git a/main.py b/main.py index 3a85f7ec..ad67468d 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,4 @@ -from organize.cli import main +from organize.cli import cli if __name__ == "__main__": - main() + cli() diff --git a/organize/actions/__init__.py b/organize/actions/__init__.py index 5b589ecc..83d897ba 100644 --- a/organize/actions/__init__.py +++ b/organize/actions/__init__.py @@ -1,3 +1,4 @@ +from typing import Dict, Type from .action import Action from .confirm import Confirm from .copy import Copy @@ -23,4 +24,4 @@ Shell.name: Shell, Symlink.name: Symlink, Trash.name: Trash, -} +} # type: Dict[str, Type[Action]] diff --git a/organize/actions/action.py b/organize/actions/action.py index 60b49063..f97e1227 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Dict, Union from typing import Optional as tyOptional from schema import Optional, Or, Schema @@ -11,7 +11,7 @@ class Error(Exception): class Action: - name = None + name = None # type: Union[str, None] arg_schema = None schema_support_instance_without_args = False @@ -42,8 +42,8 @@ def get_schema(cls): cls.get_name(): arg_schema, } - def run(self, **kwargs) -> tyOptional[Dict[str, Any]]: - return self.pipeline(kwargs) + def run(self, simulate: bool, **kwargs) -> tyOptional[Dict[str, Any]]: + return self.pipeline(kwargs, simulate=simulate) def pipeline(self, args: dict, simulate: bool) -> tyOptional[Dict[str, Any]]: raise NotImplementedError diff --git a/organize/actions/copy.py b/organize/actions/copy.py index cd34280f..723dcd4c 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -1,4 +1,5 @@ import logging +from typing import Callable from fs import open_fs from fs.base import FS @@ -90,6 +91,7 @@ def pipeline(self, args: dict, simulate: bool): dst_fs = open_fs(dirname(dst_path), writeable=True, create=True) dst_path = basename(dst_path) + copy_action: Callable[[FS, str, FS, str],None] if src_fs.isdir(src_path): copy_action = copy_dir elif src_fs.isfile(src_path): diff --git a/organize/actions/macos_tags.py b/organize/actions/macos_tags.py index 5bed3a7f..61707725 100644 --- a/organize/actions/macos_tags.py +++ b/organize/actions/macos_tags.py @@ -4,6 +4,8 @@ import simplematch as sm from schema import Or +from organize.utils import Template + from .action import Action logger = logging.getLogger(__name__) @@ -33,7 +35,7 @@ def get_schema(cls): return {cls.name: Or(str, [str])} def __init__(self, *tags): - self.tags = tags + self.tags = [Template(tag) for tag in tags] def pipeline(self, args: dict, simulate: bool): fs = args["fs"] @@ -49,7 +51,7 @@ def pipeline(self, args: dict, simulate: bool): COLORS = [c.name.lower() for c in macos_tags.Color] for template in self.tags: - tag = self.fill_template_tags(template, args) + tag = template.render(**args) name, color = self._parse_tag(tag) if color not in COLORS: @@ -62,7 +64,7 @@ def pipeline(self, args: dict, simulate: bool): _tag = macos_tags.Tag( name=name, color=macos_tags.Color[color.upper()], - ) + ) # type: ignore macos_tags.add(_tag, file=str(path)) def _parse_tag(self, s): diff --git a/organize/actions/move.py b/organize/actions/move.py index 8fc9394d..48320701 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -1,4 +1,5 @@ import logging +from typing import Callable from fs import open_fs from fs.base import FS @@ -137,6 +138,7 @@ def pipeline(self, args: dict, simulate: bool): dst_fs = open_fs(dirname(dst_path), writeable=True, create=True) dst_path = basename(dst_path) + move_action: Callable[[FS, str, FS, str], None] if src_fs.isdir(src_path): move_action = move_dir elif src_fs.isfile(src_path): diff --git a/organize/actions/python.py b/organize/actions/python.py index e28d7d16..6ae4a952 100644 --- a/organize/actions/python.py +++ b/organize/actions/python.py @@ -93,7 +93,7 @@ def create_method(self, name: str, argnames: Iterable[str], code: str) -> None: def pipeline(self, args: dict, simulate: bool) -> tyOptional[Dict[str, Any]]: if simulate and not self.run_in_simulation: - self.print("Code not run in simulation.", style="yellow") + self.print("[yellow]Code not run in simulation.[/]") return None logger.info('Executing python:\n"""\n%s\n""", args=%s', self.code, args) diff --git a/organize/actions/rename.py b/organize/actions/rename.py index 96d37b9f..150b81ea 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -1,5 +1,6 @@ import logging import os +from typing import Callable from fs import path from fs.base import FS @@ -101,6 +102,7 @@ def pipeline(self, args: dict, simulate: bool): if dst_path == src_path: self.print("Name did not change") else: + move_action: Callable[[FS, str, FS, str], None] if fs.isdir(src_path): move_action = move_dir elif fs.isfile(src_path): @@ -132,4 +134,7 @@ def pipeline(self, args: dict, simulate: bool): } def __str__(self) -> str: - return "Move(dest=%s, conflict_mode=%s)" % (self.dest, self.conflict_mode) + return "Rename(new_name=%s, conflict_mode=%s)" % ( + self.new_name, + self.conflict_mode, + ) diff --git a/organize/actions/shell.py b/organize/actions/shell.py index 2207ac73..19c20151 100644 --- a/organize/actions/shell.py +++ b/organize/actions/shell.py @@ -45,7 +45,7 @@ def __init__(self, cmd: str, run_in_simulation=False, ignore_errors=False): self.run_in_simulation = run_in_simulation self.ignore_errors = ignore_errors - def pipeline(self, args: dict, simulate: bool) -> None: + def pipeline(self, args: dict, simulate: bool): full_cmd = self.cmd.render(**args) self.print("$ %s" % full_cmd) if not simulate or self.run_in_simulation: diff --git a/organize/actions/utils.py b/organize/actions/utils.py index 52f3c930..052fd14c 100644 --- a/organize/actions/utils.py +++ b/organize/actions/utils.py @@ -72,3 +72,5 @@ def resolve_overwrite_conflict( move_file(dst_fs, dst_path, dst_fs, name) return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=False) + + raise ValueError("Unknown conflict_mode %s" % conflict_mode) diff --git a/organize/cli.py b/organize/cli.py index d166d615..5fe9e2f2 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -18,7 +18,7 @@ # setting the $ORGANIZE_CONFIG env variable overrides the default config path if os.getenv("ORGANIZE_CONFIG"): - CONFIG_PATH = Path(os.getenv("ORGANIZE_CONFIG")).resolve() + CONFIG_PATH = Path(os.getenv("ORGANIZE_CONFIG", "")).resolve() CONFIG_DIR = CONFIG_PATH.parent else: CONFIG_DIR = Path(APP_DIRS.user_config_dir) diff --git a/organize/config.py b/organize/config.py index e2f423bf..c97638e7 100644 --- a/organize/config.py +++ b/organize/config.py @@ -37,11 +37,9 @@ ], ), Optional("filters"): [ - Optional(FILTER.get_schema()) for FILTER in FILTERS.values() - ], - "actions": [ - Optional(ACTION.get_schema()) for ACTION in ACTIONS.values() + Optional(x.get_schema()) for x in FILTERS.values() ], + "actions": [Optional(x.get_schema()) for x in ACTIONS.values()], } ], Optional("version"): int, diff --git a/organize/core.py b/organize/core.py index 0e86bbe7..4494864b 100644 --- a/organize/core.py +++ b/organize/core.py @@ -140,7 +140,7 @@ def filter_pipeline(filters: Iterable[Filter], args: dict) -> bool: except Exception as e: # pylint: disable=broad-except logger.exception(e) # console.print_exception() - filter_.print_error(e) + filter_.print_error(str(e)) return False return True @@ -154,13 +154,13 @@ def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bo deep_merge_inplace(args, updates) except Exception as e: # pylint: disable=broad-except logger.exception(e) - action.print_error(e) + action.print_error(str(e)) return False return True def run(config, simulate: bool = True): - count = Counter(done=0, fail=0) + count = Counter(done=0, fail=0) # type: Counter if simulate: output.simulation_banner() @@ -213,7 +213,7 @@ def run_file(config_file: str, working_dir: str, simulate: bool): CONFIG_SCHEMA.validate(rules) warnings = replace_with_instances(rules) for msg in warnings: - output.print_warning(msg) + output.warn(msg) os.chdir(working_dir) count = run(rules, simulate=simulate) output.summary(count) diff --git a/organize/filters/__init__.py b/organize/filters/__init__.py index cb2c9d45..9aafe5f0 100644 --- a/organize/filters/__init__.py +++ b/organize/filters/__init__.py @@ -1,3 +1,5 @@ +from typing import Dict, Type + from .created import Created from .duplicate import Duplicate from .empty import Empty @@ -27,4 +29,4 @@ MimeType.name: MimeType, Python.name: Python, Regex.name: Regex, -} +} # type: Dict[str, Type[Filter]] diff --git a/organize/filters/exif.py b/organize/filters/exif.py index 2d73af95..52101a0a 100644 --- a/organize/filters/exif.py +++ b/organize/filters/exif.py @@ -95,7 +95,7 @@ def __init__(self, *required_tags: str, **tag_filters: str) -> None: self.kwargs = tag_filters # exif keys with expected values def category_dict(self, tags: Mapping[str, str]) -> ExifDict: - result = collections.defaultdict(dict) # type: DefaultDict[str, Dict[str, str]] + result = collections.defaultdict(dict) # type: DefaultDict for key, value in tags.items(): if " " in key: category, field = key.split(" ", maxsplit=1) diff --git a/organize/filters/size.py b/organize/filters/size.py index 3f54cd78..cac9bba6 100644 --- a/organize/filters/size.py +++ b/organize/filters/size.py @@ -7,7 +7,7 @@ from fs.filesize import binary, decimal, traditional from schema import Optional, Or -from organize.utils import flattened_string_list, fullpath +from organize.utils import flattened_string_list from .filter import Filter, FilterResult diff --git a/organize/utils.py b/organize/utils.py index 46441c71..23c3c83d 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -51,11 +51,6 @@ def resource_description(fs, path): return "{} on {}".format(path, fs) -def fullpath(path: Union[str, Path]) -> Path: - """Expand '~' and resolve the given path. Path can be a string or a Path obj.""" - return Path(os.path.expandvars(str(path))).expanduser().resolve(strict=False) - - def ensure_list(inp): if not isinstance(inp, list): return [inp] diff --git a/poetry.lock b/poetry.lock index f6a54dbe..5202a816 100644 --- a/poetry.lock +++ b/poetry.lock @@ -87,6 +87,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "certifi" +version = "2021.10.8" +description = "Python package for providing Mozilla's CA Bundle." +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "cffi" version = "1.15.0" @@ -106,6 +114,17 @@ category = "main" optional = true python-versions = "*" +[[package]] +name = "charset-normalizer" +version = "2.0.10" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "dev" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + [[package]] name = "click" version = "8.0.3" @@ -161,14 +180,6 @@ category = "main" optional = false python-versions = ">=3.6, <3.7" -[[package]] -name = "docopt" -version = "0.6.2" -description = "Pythonic argument parser, that will make you smile" -category = "main" -optional = false -python-versions = "*" - [[package]] name = "docx2txt" version = "0.8" @@ -250,6 +261,14 @@ python-dateutil = ">=2.8.1" [package.extras] dev = ["twine", "markdown", "flake8", "wheel"] +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "dev" +optional = false +python-versions = ">=3.5" + [[package]] name = "imapclient" version = "2.1.0" @@ -551,7 +570,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pycryptodome" -version = "3.13.0" +version = "3.14.0" description = "Cryptographic library for Python" category = "main" optional = true @@ -689,9 +708,27 @@ python-versions = ">=3.6" [package.dependencies] pyyaml = "*" +[[package]] +name = "requests" +version = "2.27.1" +description = "Python HTTP for Humans." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + [[package]] name = "rich" -version = "11.0.0" +version = "11.1.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" category = "main" optional = false @@ -813,10 +850,18 @@ python-versions = ">=3.6" [[package]] name = "typed-ast" -version = "1.4.3" +version = "1.5.2" description = "a fork of Python 2 and 3 ast modules with type comment support" category = "dev" optional = false +python-versions = ">=3.6" + +[[package]] +name = "types-pyyaml" +version = "6.0.3" +description = "Typing stubs for PyYAML" +category = "dev" +optional = false python-versions = "*" [[package]] @@ -852,6 +897,19 @@ tzdata = {version = "*", markers = "platform_system == \"Windows\""} devenv = ["black", "pyroma", "pytest-cov", "zest.releaser"] test = ["pytest-mock (>=3.3)", "pytest (>=4.3)"] +[[package]] +name = "urllib3" +version = "1.26.8" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + [[package]] name = "watchdog" version = "2.1.6" @@ -908,7 +966,7 @@ textract = ["textract"] [metadata] lock-version = "1.1" python-versions = "^3.6.2" -content-hash = "fe994f61fffe6e1da4b387381566905468e866e59cc448a6ef3659fb5bd7abd0" +content-hash = "f8bf1c60f540c1372b02cf4f7af43dee5ece0bdadfba5d751ca45a862bff88f1" [metadata.files] appdirs = [ @@ -958,6 +1016,10 @@ cached-property = [ {file = "cached-property-1.5.2.tar.gz", hash = "sha256:9fa5755838eecbb2d234c3aa390bd80fbd3ac6b6869109bfc1b499f7bd89a130"}, {file = "cached_property-1.5.2-py2.py3-none-any.whl", hash = "sha256:df4f613cf7ad9a588cc381aaf4a512d26265ecebd5eb9e1ba12f1319eb85a6a0"}, ] +certifi = [ + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, +] cffi = [ {file = "cffi-1.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:c2502a1a03b6312837279c8c1bd3ebedf6c12c4228ddbad40912d671ccc8a962"}, {file = "cffi-1.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:23cfe892bd5dd8941608f93348c0737e369e51c100d03718f108bf1add7bd6d0"}, @@ -1014,6 +1076,10 @@ chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, ] +charset-normalizer = [ + {file = "charset-normalizer-2.0.10.tar.gz", hash = "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd"}, + {file = "charset_normalizer-2.0.10-py3-none-any.whl", hash = "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"}, +] click = [ {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, {file = "click-8.0.3.tar.gz", hash = "sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"}, @@ -1037,9 +1103,6 @@ dataclasses = [ {file = "dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf"}, {file = "dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97"}, ] -docopt = [ - {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, -] docx2txt = [ {file = "docx2txt-0.8.tar.gz", hash = "sha256:2c06d98d7cfe2d3947e5760a57d924e3ff07745b379c8737723922e7009236e5"}, ] @@ -1065,6 +1128,10 @@ ghp-import = [ {file = "ghp-import-2.0.2.tar.gz", hash = "sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071"}, {file = "ghp_import-2.0.2-py3-none-any.whl", hash = "sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46"}, ] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] imapclient = [ {file = "IMAPClient-2.1.0-py2.py3-none-any.whl", hash = "sha256:3eeb97b9aa8faab0caa5024d74bfde59408fbd542781246f6960873c7bf0dd01"}, {file = "IMAPClient-2.1.0.zip", hash = "sha256:60ba79758cc9f13ec910d7a3df9acaaf2bb6c458720d9a02ec33a41352fd1b99"}, @@ -1343,36 +1410,36 @@ pycparser = [ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] pycryptodome = [ - {file = "pycryptodome-3.13.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e468724173df02f9d83f3fea830bf0d04aa291b5add22b4a78e01c97aab04873"}, - {file = "pycryptodome-3.13.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1fb7a6f222072412f320b9e48d3ce981920efbfce37b06d028ec9bd94093b37f"}, - {file = "pycryptodome-3.13.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4f1b594d0cf35bd12ec4244df1155a7f565bf6e6245976ac36174c1564688c90"}, - {file = "pycryptodome-3.13.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:9ea70f6c3f6566159e3798e4593a4a8016994a0080ac29a45200615b45091a1b"}, - {file = "pycryptodome-3.13.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:f7aad304575d075faf2806977b726b67da7ba294adc97d878f92a062e357a56a"}, - {file = "pycryptodome-3.13.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:702446a012fd9337b9327d168bb0c7dc714eb93ad361f6f61af9ca8305a301f1"}, - {file = "pycryptodome-3.13.0-cp27-cp27m-win32.whl", hash = "sha256:681ac47c538c64305d710eaed2bb49532f62b3f4c93aa7c423c520df981392e5"}, - {file = "pycryptodome-3.13.0-cp27-cp27m-win_amd64.whl", hash = "sha256:7b3478a187d897f003b2aa1793bcc59463e8d57a42e2aafbcbbe9cd47ec46863"}, - {file = "pycryptodome-3.13.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:eec02d9199af4b1ccfe1f9c587691a07a1fa39d949d2c1dc69d079ab9af8212f"}, - {file = "pycryptodome-3.13.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:9c8e0e6c5e982699801b20fa74f43c19aa080d2b53a39f3c132d35958e153bd4"}, - {file = "pycryptodome-3.13.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:f5457e44d3f26d9946091e92b28f3e970a56538b96c87b4b155a84e32a40b7b5"}, - {file = "pycryptodome-3.13.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:88d6d54e83cf9bbd665ce1e7b9079983ee2d97a05f42e0569ff00a70f1dd8b1e"}, - {file = "pycryptodome-3.13.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:72de8c4d71e6b11d54528bb924447fa4fdabcbb3d76cc0e7f61d3b6075def6b3"}, - {file = "pycryptodome-3.13.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:008ef2c631f112cd5a58736e0b29f4a28b4bb853e68878689f8b476fd56e0691"}, - {file = "pycryptodome-3.13.0-cp35-abi3-manylinux1_i686.whl", hash = "sha256:51ebe9624ad0a0b4da1aaaa2d43aabadf8537737fd494cee0ffa37cd6326de02"}, - {file = "pycryptodome-3.13.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:deede160bdf87ddb71f0a1314ad5a267b1a960be314ea7dc6b7ad86da6da89a3"}, - {file = "pycryptodome-3.13.0-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:857c16bffd938254e3a834cd6b2a755ed24e1a953b1a86e33da136d3e4c16a6f"}, - {file = "pycryptodome-3.13.0-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:ca6db61335d07220de0b665bfee7b8e9615b2dfc67a54016db4826dac34c2dd2"}, - {file = "pycryptodome-3.13.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:073dedf0f9c490ae22ca081b86357646ac9b76f3e2bd89119d137fc697a9e3b6"}, - {file = "pycryptodome-3.13.0-cp35-abi3-win32.whl", hash = "sha256:e3affa03c49cce7b0a9501cc7f608d4f8e61fb2522b276d599ac049b5955576d"}, - {file = "pycryptodome-3.13.0-cp35-abi3-win_amd64.whl", hash = "sha256:e5d72be02b17e6bd7919555811264403468d1d052fa67c946e402257c3c29a27"}, - {file = "pycryptodome-3.13.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:0896d5d15ffe584d46cb9b69a75cf14a2bc8f6daf635b7bf16c1b041342a44b1"}, - {file = "pycryptodome-3.13.0-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:e420cdfca73f80fe15f79bb34756959945231a052440813e5fce531e6e96331a"}, - {file = "pycryptodome-3.13.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:720fafdf3e5c5de93039d8308f765cc60b8e9e7e852ad7135aa65dd89238191f"}, - {file = "pycryptodome-3.13.0-pp27-pypy_73-win32.whl", hash = "sha256:7a8b0e526ff239b4f4c61dd6898e2474d609843ffc437267f3a27ddff626e6f6"}, - {file = "pycryptodome-3.13.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:d92a5eddffb0ad39f582f07c1de26e9daf6880e3e782a94bb7ebaf939567f8bf"}, - {file = "pycryptodome-3.13.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:cb9453c981554984c6f5c5ce7682d7286e65e2173d7416114c3593a977a01bf5"}, - {file = "pycryptodome-3.13.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:765b8b16bc1fd699e183dde642c7f2653b8f3c9c1a50051139908e9683f97732"}, - {file = "pycryptodome-3.13.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:b3af53dddf848afb38b3ac2bae7159ddad1feb9bac14aa3acec6ef1797b82f8d"}, - {file = "pycryptodome-3.13.0.tar.gz", hash = "sha256:95bacf9ff7d1b90bba537d3f5f6c834efe6bfbb1a0195cb3573f29e6716ef08d"}, + {file = "pycryptodome-3.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bd800856e6dea6924504795ae4ec0d822e912e0a9a215e73b77b585c4d15a0f7"}, + {file = "pycryptodome-3.14.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:625f78ad69aa3c45e19b85b9e9cae3a30aa4a1de6b908981a63426b88e860489"}, + {file = "pycryptodome-3.14.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:a1c116dd7a00aac631f67920912fd8ef7a5ad3402cd4d497c6f5cc6b8115747b"}, + {file = "pycryptodome-3.14.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:0d0b6cca6b707b2c7cd4177c2d3cd950efa959ed8f01c30e676f102c68156f00"}, + {file = "pycryptodome-3.14.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9d939a257117cc8c6840ad69f149b3ca5e07268cfe0429bd9feec0f91da2343d"}, + {file = "pycryptodome-3.14.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:41dbb8c2129d43f34ed555cbd365d5e8f023ef0f9238fd9cd0302086b15a38b3"}, + {file = "pycryptodome-3.14.0-cp27-cp27m-win32.whl", hash = "sha256:9b454af09914807cef1222d100a8c523737a160347cb8d699facc4bdfb9fe725"}, + {file = "pycryptodome-3.14.0-cp27-cp27m-win_amd64.whl", hash = "sha256:95bac6e55411650933f3b615e57bf0966bf08f3ce07c01f07482ced95f18cbec"}, + {file = "pycryptodome-3.14.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:0ffbca43c1788243421a8583d85acb59f4cd0b82b001c485fdc3fbfd8fd0804f"}, + {file = "pycryptodome-3.14.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:69b85d78f7db628370d2cc87f1c41a449f6460896ba95f412173618f75027c2c"}, + {file = "pycryptodome-3.14.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:bba348d2823315ab8ebe44f0b2fc2ff8dfac8de881713a08def3dadcfc8e92bb"}, + {file = "pycryptodome-3.14.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:7d667daa851b1f9a20f2b5cad3cff13fba5204bc2f857d12f27c25b178d8629b"}, + {file = "pycryptodome-3.14.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:74918d5de06b12fef2255135bede41307a5f7b929b145ad867111525aea075dc"}, + {file = "pycryptodome-3.14.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c2b6faabd09d2876f9050f8af5d78046d81fe856f99e801c2ddab85b59602007"}, + {file = "pycryptodome-3.14.0-cp35-abi3-manylinux1_i686.whl", hash = "sha256:22a8629315c76d2bec57bc4fd67eb7e01664c3e3b9579df40f530ee5821db1de"}, + {file = "pycryptodome-3.14.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:7e3851e4fbbab72d9b30f98a504f450cc61e497e8e4b0be8205dc198703eee4d"}, + {file = "pycryptodome-3.14.0-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:9006f17944efaacc3be364c01c2253c00a00f0b5fa5a1a85a1191efd861e764d"}, + {file = "pycryptodome-3.14.0-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:8f0da308fca149b4c4da78e1388f82d8dd167e0ce12992a44f81b506cede3109"}, + {file = "pycryptodome-3.14.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:d186e34747985fbd94df7ed4d621f8377165053a06872314c2a594af34741655"}, + {file = "pycryptodome-3.14.0-cp35-abi3-win32.whl", hash = "sha256:2ed4da8f8afe44895c1f49ae1141a55b15d81dc745b5baa7b7a7265d7b40b81e"}, + {file = "pycryptodome-3.14.0-cp35-abi3-win_amd64.whl", hash = "sha256:11167a1f892283e5320feb5e81589fd041a1822b94c047820f00bc03eb98a9f7"}, + {file = "pycryptodome-3.14.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:1714ea5f83bcff25e8ae4640e22359d7a0815157a29d9f4eebc2b9e975a3cda0"}, + {file = "pycryptodome-3.14.0-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:3a011b9fe674bd21056613e88a3e660c56f1b47263138ebf420aa3ee4b8b0107"}, + {file = "pycryptodome-3.14.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:3fd50e3682ac3a684ace5b90ba1aef8090a78eeadf38c1ec385aad3a599cfd68"}, + {file = "pycryptodome-3.14.0-pp27-pypy_73-win32.whl", hash = "sha256:08be50d4195edd595df580077bbeec5599d0e5aa0cc468083178ae870e0b29f4"}, + {file = "pycryptodome-3.14.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:16c171dd969c9046b7b304c6ba0c643624dcf18093a66bd30b8b091703f177a2"}, + {file = "pycryptodome-3.14.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:89bb56cfd1fb74663842710bc41a6be26dafceb60eb8d432536891aea08a3740"}, + {file = "pycryptodome-3.14.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c30a98c8718ae93d44680a7038adb484a520319860747ba43b6cd0a20f6b5984"}, + {file = "pycryptodome-3.14.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:e972f566ef7b821c8b958dab64174afa072f8271b779e32444ad7c127b0a84b2"}, + {file = "pycryptodome-3.14.0.tar.gz", hash = "sha256:ceea92a4b8ba6c50d8d70f2efbb4ea14b002dac4160ce4dda33f1b7442f8158a"}, ] pygments = [ {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, @@ -1444,9 +1511,13 @@ pyyaml-env-tag = [ {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, ] +requests = [ + {file = "requests-2.27.1-py2.py3-none-any.whl", hash = "sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"}, + {file = "requests-2.27.1.tar.gz", hash = "sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61"}, +] rich = [ - {file = "rich-11.0.0-py3-none-any.whl", hash = "sha256:d7a8086aa1fa7e817e3bba544eee4fd82047ef59036313147759c11475f0dafd"}, - {file = "rich-11.0.0.tar.gz", hash = "sha256:c32a8340b21c75931f157466fefe81ae10b92c36a5ea34524dff3767238774a4"}, + {file = "rich-11.1.0-py3-none-any.whl", hash = "sha256:365ebcdbfb3aa8d4b0ed2490e0fbf7b886a39d14eb7ea5fb7aece950835e1eed"}, + {file = "rich-11.1.0.tar.gz", hash = "sha256:43e03d8eec12e21beaecc22c828a41c4247356414a12d5879834863d4ad53816"}, ] schema = [ {file = "schema-0.7.5-py2.py3-none-any.whl", hash = "sha256:f3ffdeeada09ec34bf40d7d79996d9f7175db93b7a5065de0faa7f41083c1e6c"}, @@ -1487,36 +1558,34 @@ tomli = [ {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, ] typed-ast = [ - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, - {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, - {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, - {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, - {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, - {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, - {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, - {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, - {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, - {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, - {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, - {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, - {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, - {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, - {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, - {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, + {file = "typed_ast-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:183b183b7771a508395d2cbffd6db67d6ad52958a5fdc99f450d954003900266"}, + {file = "typed_ast-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:676d051b1da67a852c0447621fdd11c4e104827417bf216092ec3e286f7da596"}, + {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc2542e83ac8399752bc16e0b35e038bdb659ba237f4222616b4e83fb9654985"}, + {file = "typed_ast-1.5.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:74cac86cc586db8dfda0ce65d8bcd2bf17b58668dfcc3652762f3ef0e6677e76"}, + {file = "typed_ast-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:18fe320f354d6f9ad3147859b6e16649a0781425268c4dde596093177660e71a"}, + {file = "typed_ast-1.5.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:31d8c6b2df19a777bc8826770b872a45a1f30cfefcfd729491baa5237faae837"}, + {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:963a0ccc9a4188524e6e6d39b12c9ca24cc2d45a71cfdd04a26d883c922b4b78"}, + {file = "typed_ast-1.5.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0eb77764ea470f14fcbb89d51bc6bbf5e7623446ac4ed06cbd9ca9495b62e36e"}, + {file = "typed_ast-1.5.2-cp36-cp36m-win_amd64.whl", hash = "sha256:294a6903a4d087db805a7656989f613371915fc45c8cc0ddc5c5a0a8ad9bea4d"}, + {file = "typed_ast-1.5.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:26a432dc219c6b6f38be20a958cbe1abffcc5492821d7e27f08606ef99e0dffd"}, + {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7407cfcad702f0b6c0e0f3e7ab876cd1d2c13b14ce770e412c0c4b9728a0f88"}, + {file = "typed_ast-1.5.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f30ddd110634c2d7534b2d4e0e22967e88366b0d356b24de87419cc4410c41b7"}, + {file = "typed_ast-1.5.2-cp37-cp37m-win_amd64.whl", hash = "sha256:8c08d6625bb258179b6e512f55ad20f9dfef019bbfbe3095247401e053a3ea30"}, + {file = "typed_ast-1.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90904d889ab8e81a956f2c0935a523cc4e077c7847a836abee832f868d5c26a4"}, + {file = "typed_ast-1.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:bbebc31bf11762b63bf61aaae232becb41c5bf6b3461b80a4df7e791fabb3aca"}, + {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c29dd9a3a9d259c9fa19d19738d021632d673f6ed9b35a739f48e5f807f264fb"}, + {file = "typed_ast-1.5.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:58ae097a325e9bb7a684572d20eb3e1809802c5c9ec7108e85da1eb6c1a3331b"}, + {file = "typed_ast-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:da0a98d458010bf4fe535f2d1e367a2e2060e105978873c04c04212fb20543f7"}, + {file = "typed_ast-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:33b4a19ddc9fc551ebabca9765d54d04600c4a50eda13893dadf67ed81d9a098"}, + {file = "typed_ast-1.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1098df9a0592dd4c8c0ccfc2e98931278a6c6c53cb3a3e2cf7e9ee3b06153344"}, + {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42c47c3b43fe3a39ddf8de1d40dbbfca60ac8530a36c9b198ea5b9efac75c09e"}, + {file = "typed_ast-1.5.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f290617f74a610849bd8f5514e34ae3d09eafd521dceaa6cf68b3f4414266d4e"}, + {file = "typed_ast-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:df05aa5b241e2e8045f5f4367a9f6187b09c4cdf8578bb219861c4e27c443db5"}, + {file = "typed_ast-1.5.2.tar.gz", hash = "sha256:525a2d4088e70a9f75b08b3f87a51acc9cde640e19cc523c7e41aa355564ae27"}, +] +types-pyyaml = [ + {file = "types-PyYAML-6.0.3.tar.gz", hash = "sha256:6ea4eefa8579e0ce022f785a62de2bcd647fad4a81df5cf946fd67e4b059920b"}, + {file = "types_PyYAML-6.0.3-py3-none-any.whl", hash = "sha256:8b50294b55a9db89498cdc5a65b1b4545112b6cd1cf4465bd693d828b0282a17"}, ] typing-extensions = [ {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, @@ -1530,6 +1599,10 @@ tzlocal = [ {file = "tzlocal-4.1-py3-none-any.whl", hash = "sha256:28ba8d9fcb6c9a782d6e0078b4f6627af1ea26aeaa32b4eab5324abc7df4149f"}, {file = "tzlocal-4.1.tar.gz", hash = "sha256:0f28015ac68a5c067210400a9197fc5d36ba9bc3f8eaf1da3cbd59acdfed9e09"}, ] +urllib3 = [ + {file = "urllib3-1.26.8-py2.py3-none-any.whl", hash = "sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed"}, + {file = "urllib3-1.26.8.tar.gz", hash = "sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"}, +] watchdog = [ {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9693f35162dc6208d10b10ddf0458cc09ad70c30ba689d9206e02cd836ce28a3"}, {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aba5c812f8ee8a3ff3be51887ca2d55fb8e268439ed44110d3846e4229eb0e8b"}, diff --git a/pyproject.toml b/pyproject.toml index 7de2cac9..4e0f19fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,6 @@ organize = "organize.cli:main" python = "^3.6.2" fs = "^2.4.14" rich = "^11.0.0" -docopt = "^0.6.2" PyYAML = "^5.4.1" Send2Trash = "^1.8.0" ExifRead = "^2.3.2" @@ -45,6 +44,7 @@ macos-tags = { version = "^1.5.1", markers = "sys_platform == 'darwin'" } schema = "^0.7.5" Jinja2 = "^3.0.3" click = "^8.0.3" +appdirs = "^1.4.4" [tool.poetry.extras] textract = ["textract"] @@ -56,12 +56,22 @@ mkdocs = "^1.2.3" mkdocstrings = "^0.17.0" mkdocs-include-markdown-plugin = "^3.2.3" mkdocs-autorefs = "^0.3.1" +requests = "^2.27.1" +types-PyYAML = "^6.0.3" [tool.mypy] python_version = "3.6" [[tool.mypy.overrides]] -module = ["schema", "simplematch", "appdirs", "send2trash", "exifread", "textract"] +module = [ + "schema", + "simplematch", + "appdirs", + "send2trash", + "exifread", + "textract", + "requests", +] ignore_missing_imports = true [build-system] From 983b2a8887558624bb4d714bba75690d142cf136 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 30 Jan 2022 16:22:55 +0100 Subject: [PATCH 063/108] update docs --- README.md | 38 ++-- docs/03-filters.md | 302 +++++++++++++++++++++++++++++-- docs/04-actions.md | 136 +++++++++++++- organize/actions/move.py | 93 +++------- organize/actions/python.py | 54 +----- organize/actions/rename.py | 54 ++---- organize/actions/shell.py | 21 +-- organize/cli.py | 10 +- organize/filters/duplicate.py | 6 +- organize/filters/exif.py | 60 ------ organize/filters/extension.py | 73 +------- organize/filters/filecontent.py | 42 +---- organize/filters/hash.py | 5 +- organize/filters/lastmodified.py | 90 ++------- organize/filters/mimetype.py | 11 +- organize/filters/name.py | 75 ++------ organize/filters/python.py | 20 +- organize/filters/regex.py | 38 +--- organize/filters/size.py | 54 ++---- 19 files changed, 577 insertions(+), 605 deletions(-) diff --git a/README.md b/README.md index 57feffe5..f164c729 100644 --- a/README.md +++ b/README.md @@ -214,28 +214,22 @@ happen: ## Command line interface ``` -The file management automation tool. - -Usage: - organize sim [--config-file=] - organize run [--config-file=] - organize config [--open-folder | --path | --debug] [--config-file=] - organize list - organize --help - organize --version - -Arguments: - sim Simulate a run. Does not touch your files. - run Organizes your files according to your rules. - config Open the configuration file in $EDITOR. - list List available filters and actions. - --version Show program version and exit. - -h, --help Show this screen and exit. +Usage: organize [OPTIONS] COMMAND [ARGS]... -Options: - -o, --open-folder Open the folder containing the configuration files. - -p, --path Show the path to the configuration file. - -d, --debug Debug your configuration file. + organize + + The file management automation tool. -Full documentation: https://organize.readthedocs.io +Options: + --version Show the version and exit. + -h, --help Show this message and exit. + +Commands: + run Organizes your files according to your rules. + sim Simulates a run (does not touch your files). + edit Edit the rules. + check Checks whether a given config file is valid. + reveal Reveals the default config file. + schema Prints the json schema for config files. + docs Opens the documentation. ``` diff --git a/docs/03-filters.md b/docs/03-filters.md index 9b54e195..cb74657d 100644 --- a/docs/03-filters.md +++ b/docs/03-filters.md @@ -10,14 +10,14 @@ look at the [Config](01-config.md) section. **Examples** ```yaml -- : rules: - - name: Show all files on your desktop created at least 10 days ago - locations: "~/Desktop" - filters: - - created: - days: 10 - actions: - - echo: "Was created at least 10 days ago" +rules: + - name: Show all files on your desktop created at least 10 days ago + locations: "~/Desktop" + filters: + - created: + days: 10 + actions: + - echo: "Was created at least 10 days ago" ``` ```yaml @@ -80,29 +80,189 @@ rules: ::: organize.filters.Exif +```yaml +rules: + - name: "Show available EXIF data of your pictures" + folders: ~/Pictures + subfolders: true + filters: + - exif + actions: + - echo: "{exif}" +``` + +Copy all images which contain GPS information while keeping subfolder structure: + +```yaml +rules: + - name: "GPS demo" + locations: ~/Pictures + subfolders: true + filters: + - exif: gps.gpsdate + actions: + - copy: ~/Pictures/with_gps/{relative_path}/ +``` + +```yaml +rules: + - name: "Filter by camera manufacturer" + folders: ~/Pictures + subfolders: true + filters: + - exif: + image.model: Nikon D3200 + actions: + - move: "~/Pictures/My old Nikon/" +``` + +Sort images by camera manufacturer. This will create folders for each camera model +(for example "Nikon D3200", "iPhone 6s", "iPhone 5s", "DMC-GX80") and move the pictures +accordingly: + +```yaml +rules: + - name: "camera sort" + locations: ~/Pictures + subfolders: true + filters: + - extension: jpg + - exif: image.model + actions: + - move: "~/Pictures/{exif.image.model}/" +``` + ## extension ::: organize.filters.Extension +**Examples** + +```yaml +rules: + - name: "Match a single file extension" + locations: "~/Desktop" + filters: + - extension: png + actions: + - echo: "Found PNG file: {path}" +``` + +```yaml +rules: + - name: "Match multiple file extensions" + locations: "~/Desktop" + filters: + - extension: + - .jpg + - jpeg + actions: + - echo: "Found JPG file: {path}" +``` + +```yaml +rules: + - name: "Make all file extensions lowercase" + locations: "~/Desktop" + filters: + - extension + actions: + - rename: "{path.stem}.{extension.lower}" +``` + +```yaml +img_ext: &img + - png + - jpg + - tiff + +audio_ext: &audio + - mp3 + - wav + - ogg + +rules: + - name: "Using extension lists" + locations: "~/Desktop" + filters: + - extension: + - *img + - *audio + actions: + - echo: "Found media file: {path}" +``` + ## filecontent ::: organize.filters.FileContent +**Examples** + +```yaml +rules: + - name: "Show the content of all your PDF files" + folders: ~/Documents + filters: + - extension: pdf + - filecontent: "(?P.*)" + actions: + - echo: "{filecontent.all}" +``` + +```yaml +rules: + - name: "Match an invoice with a regular expression and sort by customer" + locations: "~/Desktop" + filters: + - filecontent: 'Invoice.*Customer (?P\w+)' + actions: + - move: "~/Documents/Invoices/{filecontent.customer}/" +``` + ## hash ::: organize.filters.Hash -## name +## lastmodified -::: organize.filters.Name +::: organize.filters.LastModified -## size +**Examples**: -::: organize.filters.Size +```yaml +rules: + - name: "Show all files on your desktop last modified at least 10 days ago" + locations: "~/Desktop" + filters: + - lastmodified: + days: 10 + actions: + - echo: "Was modified at least 10 days ago" +``` -## lastmodified +Show all files on your desktop which were modified within the last 5 hours: -::: organize.filters.LastModified +```yaml +rules: + - locations: "~/Desktop" + filters: + - lastmodified: + hours: 5 + mode: newer + actions: + - echo: "Was modified within the last 5 hours" +``` + +```yaml +rules: + - name: "Sort pdfs by year of last modification" + locations: "~/Documents" + filters: + - extension: pdf + - lastmodified + actions: + - move: "~/Documents/PDF/{lastmodified.year}/" +``` ## mimetype @@ -152,6 +312,59 @@ rules: - echo: "Found Midi or PDF." ``` +## name + +::: organize.filters.Name + +**Examples** + +Match all files starting with 'Invoice': + +```yaml +rules: + - locations: "~/Desktop" + filters: + - filename: + startswith: Invoice + actions: + - echo: "This is an invoice" +``` + +Match all files starting with 'A' end containing the string 'hole' +(case insensitive): + +```yaml +rules: + - locations: "~/Desktop" + filters: + - filename: + startswith: A + contains: hole + case_sensitive: false + actions: + - echo: "Found a match." +``` + +Match all files starting with 'A' or 'B' containing '5' or '6' and ending with +'\_end': + +```yaml +rules: + - locations: "~/Desktop" + filters: + - filename: + startswith: + - A + - B + contains: + - 5 + - 6 + endswith: _end + case_sensitive: false + actions: + - echo: "Found a match." +``` + ## python ::: organize.filters.Python @@ -222,3 +435,64 @@ Result: ## regex ::: organize.filters.Regex + +**Examples** + +Match an invoice with a regular expression: + +```yaml +rules: + - folders: "~/Desktop" + filters: + - regex: '^RG(\d{12})-sig\.pdf$' + actions: + - move: "~/Documents/Invoices/1und1/" +``` + +Match and extract data from filenames with regex named groups: +This is just like the previous example but we rename the invoice using +the invoice number extracted via the regular expression and the named +group `the_number`. + +```yaml +rules: + - folders: ~/Desktop + filters: + - regex: '^RG(?P\d{12})-sig\.pdf$' + actions: + - move: ~/Documents/Invoices/1und1/{regex.the_number}.pdf +``` + +## size + +::: organize.filters.Size + +**Examples:** + +Trash big downloads: + +```yaml +rules: + - locations: "~/Downloads" + targets: files + filters: + - filesize: "> 0.5 GB" + actions: + - trash +``` + +Move all JPEGS bigger > 1MB and <10 MB. Search all subfolders and keep the +original relative path. + +```yaml +rules: + - folders: "~/Pictures" + subfolders: true + filters: + - extension: + - jpg + - jpeg + - filesize: ">1mb, <10mb" + actions: + - move: "~/Pictures/sorted/{relative_path}/" +``` diff --git a/docs/04-actions.md b/docs/04-actions.md index 633addf6..54587a2e 100644 --- a/docs/04-actions.md +++ b/docs/04-actions.md @@ -88,7 +88,7 @@ rules: ::: organize.actions.Echo -
Examples +**Examples** ```yaml rules: @@ -134,22 +134,20 @@ Show the `{basedir}` and `{path}` of all files in '~/Downloads', '~/Desktop' and rules: - locations: - - ~/Desktop - - ~/Downloads - subfolders: true + - path: ~/Desktop + max_depth: null + - path: ~/Downloads + max_depth: null actions: - echo: "Basedir: {basedir}" - echo: "Path: {path}" ``` -
- ## macos_tags ::: organize.actions.MacOSTags -
-Examples +**Examples** ```yaml rules: @@ -205,24 +203,142 @@ rules: - Year-{created.year} (red) ``` -
- ## move ::: organize.actions.Move +**Examples** + +Move all pdfs and jpgs from the desktop into the folder "~/Desktop/media/". Filenames are not changed. + +```yaml +rules: + - locations: ~/Desktop + filters: + - extension: + - pdf + - jpg + actions: + - move: "~/Desktop/media/" +``` + +Use a placeholder to move all .pdf files into a "PDF" folder and all .jpg files into a +"JPG" folder. Existing files will be overwritten. + +```yaml +rules: + - locations: ~/Desktop + filters: + - extension: + - pdf + - jpg + actions: + - move: + dest: "~/Desktop/{extension.upper}/" + overwrite: true +``` + +Move pdfs into the folder `Invoices`. Keep the filename but do not overwrite existing files. To prevent overwriting files, an index is added to the filename, so `somefile.jpg` becomes `somefile 2.jpg`. + +```yaml +rules: + - locations: ~/Desktop/Invoices + filters: + - extension: + - pdf + actions: + - move: + dest: "~/Documents/Invoices/" + overwrite: false + counter_separator: "_" +``` + ## python ::: organize.actions.Python +A basic example that shows how to get the current file path and do some printing in a +for loop. The `|` is yaml syntax for defining a string literal spanning multiple lines. + +```yaml +rules: + - folders: "~/Desktop" + actions: + - python: | + print('The path of the current file is %s' % path) + for _ in range(5): + print('Heyho, its me from the loop') +``` + +```yaml +rules: + - name: "You can access filter data" + - folders: ~/Desktop + filters: + - regex: '^(?P.*)\.(?P.*)$' + actions: + - python: | + print('Name: %s' % regex.name) + print('Extension: %s' % regex.extension) +``` + +You have access to all the python magic -- do a google search for each +filename starting with an underscore: + +```yaml +rules: + - folders: ~/Desktop + filters: + - filename: + startswith: "_" + actions: + - python: | + import webbrowser + webbrowser.open('https://www.google.com/search?q=%s' % path.stem) +``` + ## rename ::: organize.actions.Rename +**Examples** + +```yaml +rules: + - name: "Convert all .PDF file extensions to lowercase (.pdf)" + locations: "~/Desktop" + filters: + - extension: PDF + actions: + - rename: "{path.stem}.pdf" +``` + +```yaml +rules: + - name: "Convert **all** file extensions to lowercase" + folders: "~/Desktop" + filters: + - Extension + actions: + - rename: "{path.stem}.{extension.lower}" +``` + ## shell ::: organize.actions.Shell +**Examples** + +```yaml +rules: + - name: "On macOS: Open all pdfs on your desktop" + folders: "~/Desktop" + filters: + - extension: pdf + actions: + - shell: 'open "{path}"' +``` + ## symlink ::: organize.actions.Symlink diff --git a/organize/actions/move.py b/organize/actions/move.py index 48320701..493066a0 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -17,77 +17,34 @@ class Move(Action): - """ - Move a file to a new location. The file can also be renamed. + """Move a file to a new location. + + The file can also be renamed. If the specified path does not exist it will be created. If you only want to rename the file and keep the folder, it is - easier to use the Rename-Action. - - :param str dest: - The destination folder or path. - If `dest` ends with a slash / backslash, the file will be moved into - this folder and not renamed. - - :param bool overwrite: - specifies whether existing files should be overwritten. - Otherwise it will start enumerating files (append a counter to the - filename) to resolve naming conflicts. [Default: False] - - :param str counter_separator: - specifies the separator between filename and the appended counter. - Only relevant if **overwrite** is disabled. [Default: ``\' \'``] - - Examples: - - Move all pdfs and jpgs from the desktop into the folder "~/Desktop/media/". - Filenames are not changed. - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Desktop - filters: - - extension: - - pdf - - jpg - actions: - - move: '~/Desktop/media/' - - - Use a placeholder to move all .pdf files into a "PDF" folder and all - .jpg files into a "JPG" folder. Existing files will be overwritten. - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Desktop - filters: - - extension: - - pdf - - jpg - actions: - - move: - dest: '~/Desktop/{extension.upper}/' - overwrite: true - - - Move pdfs into the folder `Invoices`. Keep the filename but do not - overwrite existing files. To prevent overwriting files, an index is - added to the filename, so ``somefile.jpg`` becomes ``somefile 2.jpg``. - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Desktop/Invoices - filters: - - extension: - - pdf - actions: - - move: - dest: '~/Documents/Invoices/' - overwrite: false - counter_separator: '_' + easier to use the `rename` action. + + Args: + dest (str): + The destination where the file / dir should be moved to. + If `dest` ends with a slash, it is assumed to be a target directory + and the file / dir will be moved into `dest` and keep its name. + + on_conflict (str): + What should happen in case **dest** already exists. + One of `skip`, `overwrite`, `trash`, `rename_new` and `rename_existing`. + Defaults to `rename_new`. + + rename_template (str): + A template for renaming the file / dir in case of a conflict. + Defaults to `{name} {counter}{extension}`. + + dest_filesystem (str): + (Optional) A pyfilesystem opener url of the filesystem you want to copy to. + If this is not given, the local filesystem is used. + + The next action will work with the moved file / dir. """ name = "move" diff --git a/organize/actions/python.py b/organize/actions/python.py index 6ae4a952..741b6943 100644 --- a/organize/actions/python.py +++ b/organize/actions/python.py @@ -12,56 +12,12 @@ class Python(Action): - r""" - Execute python code in your config file. + """Execute python code. - :param str code: The python code to execute - - Examples: - - A basic example that shows how to get the current file path and do some - printing in a for loop. The ``|`` is yaml syntax for defining a string - literal spanning multiple lines. - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - actions: - - python: | - print('The path of the current file is %s' % path) - for _ in range(5): - print('Heyho, its me from the loop') - - - You can access filter data: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Desktop - filters: - - regex: '^(?P.*)\.(?P.*)$' - actions: - - python: | - print('Name: %s' % regex.name) - print('Extension: %s' % regex.extension) - - - You have access to all the python magic -- do a google search for each - filename starting with an underscore: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Desktop - filters: - - filename: - startswith: '_' - actions: - - python: | - import webbrowser - webbrowser.open('https://www.google.com/search?q=%s' % path.stem) + Args: + code (str): The python code to execute. + run_in_simulation (bool): + Whether to execute this code in simulation mode (Default false). """ name = "python" diff --git a/organize/actions/rename.py b/organize/actions/rename.py index 150b81ea..166ac523 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -17,46 +17,26 @@ class Rename(Action): - """ - Renames a file. - - :param str name: - The new filename. - Can be a format string which uses file attributes from a filter. - - :param bool overwrite: - specifies whether existing files should be overwritten. - Otherwise it will start enumerating files (append a counter to the - filename) to resolve naming conflicts. [Default: False] - - :param str counter_separator: - specifies the separator between filename and the appended counter. - Only relevant if **overwrite** is disabled. [Default: ``\' \'``] - - Examples: - - Convert all .PDF file extensions to lowercase (.pdf): + """Renames a file. - .. code-block:: yaml - :caption: config.yaml + Args: + name (str): + The new name for the file / dir. - rules: - - folders: '~/Desktop' - filters: - - extension: PDF - actions: - - rename: "{path.stem}.pdf" + on_conflict (str): + What should happen in case **dest** already exists. + One of `skip`, `overwrite`, `trash`, `rename_new` and `rename_existing`. + Defaults to `rename_new`. - - Convert **all** file extensions to lowercase: + rename_template (str): + A template for renaming the file / dir in case of a conflict. + Defaults to `{name} {counter}{extension}`. - .. code-block:: yaml - :caption: config.yaml + dest_filesystem (str): + (Optional) A pyfilesystem opener url of the filesystem you want to copy to. + If this is not given, the local filesystem is used. - rules: - - folders: '~/Desktop' - filters: - - Extension - actions: - - rename: "{path.stem}.{extension.lower}" + The next action will work with the renamed file / dir. """ name = "rename" @@ -71,7 +51,7 @@ class Rename(Action): def __init__( self, - new_name: str, + name: str, on_conflict="rename_new", rename_template="{name} {counter}{extension}", ) -> None: @@ -80,7 +60,7 @@ def __init__( "on_conflict must be one of %s" % ", ".join(CONFLICT_OPTIONS) ) - self.new_name = Template.from_string(new_name) + self.new_name = Template.from_string(name) self.conflict_mode = on_conflict self.rename_template = Template.from_string(rename_template) diff --git a/organize/actions/shell.py b/organize/actions/shell.py index 19c20151..b221cad5 100644 --- a/organize/actions/shell.py +++ b/organize/actions/shell.py @@ -14,20 +14,17 @@ class Shell(Action): """ Executes a shell command - :param str cmd: The command to execute. + Args: + cmd (str): The command to execute. + run_in_simulation (bool): + Whether to execute in simulation mode (default = false) + ignore_errors (bool): + Whether to continue on returncodes != 0. - Example: - - (macOS) Open all pdfs on your desktop: + Returns - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - extension: pdf - actions: - - shell: 'open "{path}"' + - `{shell.output}` (`str`): The stdout of the executed process. + - `{shell.returncode}` (`int`): The returncode of the executed process. """ name = "shell" diff --git a/organize/cli.py b/organize/cli.py index 5fe9e2f2..912882ed 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -120,7 +120,7 @@ def sim(config, working_dir, config_file): def edit(config, editor): """Edit the rules. - If called without arguments it will open the default rule file in $EDITOR. + If called without arguments it will open the default config file in $EDITOR. """ click.edit(filename=config, editor=editor) @@ -128,9 +128,9 @@ def edit(config, editor): @cli.command() @CLI_CONFIG def check(config): - """Checks whether a given rule file is valid. + """Checks whether a given config file is valid. - If called without arguments it will check the default rule file. + If called without arguments it will check the default config file. """ print(config) @@ -138,7 +138,7 @@ def check(config): @cli.command() @click.option("--path", is_flag=True, help="Print the path instead of revealing it.") def reveal(path): - """Reveals the default rule file.""" + """Reveals the default config file.""" if path: click.echo(CONFIG_PATH) else: @@ -147,7 +147,7 @@ def reveal(path): @cli.command() def schema(): - """Prints the json schema for rule files.""" + """Prints the json schema for config files.""" import json from .config import CONFIG_SCHEMA diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index 450b9bcf..5fae723a 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -11,7 +11,6 @@ from collections import defaultdict from fs.base import FS from typing import Dict, Set, Union, NamedTuple -from organize.output import console from organize.utils import is_same_resource from .filter import Filter, FilterResult @@ -52,8 +51,9 @@ class Duplicate(Filter): This filter compares files byte by byte and finds identical files with potentially different filenames. - :returns: - - ``{duplicate}`` -- path to the duplicate source + **Returns:** + + - `{duplicate}` -- full path of the duplicate source """ name = "duplicate" diff --git a/organize/filters/exif.py b/organize/filters/exif.py index 52101a0a..903b1168 100644 --- a/organize/filters/exif.py +++ b/organize/filters/exif.py @@ -24,66 +24,6 @@ class Exif(Filter): - ``{exif.exif}`` -- Exif information - ``{exif.gps}`` -- GPS information - ``{exif.interoperability}`` -- Interoperability information - - Examples: - - Show available EXIF data of your pictures: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Pictures - subfolders: true - filters: - - exif - actions: - - echo: "{exif}" - - - Copy all images which contain GPS information while keeping subfolder - structure: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Pictures - subfolders: true - filters: - - exif: - gps.gpsdate - actions: - - copy: ~/Pictures/with_gps/{relative_path}/ - - - Filter by camera manufacturer: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Pictures - subfolders: true - filters: - - exif: - image.model: Nikon D3200 - actions: - - move: '~/Pictures/My old Nikon/' - - - Sort images by camera manufacturer. This will create folders for each camera - model (for example "Nikon D3200", "iPhone 6s", "iPhone 5s", "DMC-GX80") and - move the pictures accordingly: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: ~/Pictures - subfolders: true - filters: - - extension: jpg - - exif: - image.model - actions: - - move: '~/Pictures/{exif.image.model}/' """ name = "exif" diff --git a/organize/filters/extension.py b/organize/filters/extension.py index e7389514..a937c077 100644 --- a/organize/filters/extension.py +++ b/organize/filters/extension.py @@ -10,76 +10,13 @@ class Extension(Filter): """Filter by file extension - :param extensions: - The file extensions to match (does not need to start with a colon). + Args: + *extensions (list(str) or str): + The file extensions to match (does not need to start with a colon). - :returns: - - ``{extension}`` -- the original file extension (without colon) - - ``{extension.lower}`` -- the file extension in lowercase - - ``{extension.upper}`` -- the file extension in UPPERCASE + **Returns:** - Examples: - - Match a single file extension: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - extension: png - actions: - - echo: 'Found PNG file: {path}' - - - Match multiple file extensions: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - extension: - - .jpg - - jpeg - actions: - - echo: 'Found JPG file: {path}' - - - Make all file extensions lowercase: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - extension - actions: - - rename: '{path.stem}.{extension.lower}' - - - Using extension lists: - - .. code-block:: yaml - :caption: config.yaml - - img_ext: &img - - png - - jpg - - tiff - - audio_ext: &audio - - mp3 - - wav - - ogg - - rules: - - folders: '~/Desktop' - filters: - - extension: - - *img - - *audio - actions: - - echo: 'Found media file: {path}' + - `{extension}`: the original file extension (without colon) """ name = "extension" diff --git a/organize/filters/filecontent.py b/organize/filters/filecontent.py index b3ef253a..ad88b648 100644 --- a/organize/filters/filecontent.py +++ b/organize/filters/filecontent.py @@ -13,44 +13,18 @@ class FileContent(Filter): - r"""Matches file content with the given regular expression + """Matches file content with the given regular expression - :param str expr: - The regular expression to be matched. + Args: + expr (str): The regular expression to be matched. - Any named groups in your regular expression will be returned like this: + Any named groups (`(?P.*)`) in your regular expression will + be returned like this: - :returns: - - ``{filecontent.yourgroupname}`` -- The text matched with the named group - ``(?P)`` + **Returns:** - Examples: - - - Show the content of all your PDF files: - - .. code-block::yaml - :caption: config.yaml - - rules: - - folders: ~/Documents - filters: - - extension: pdf - - filecontent: '(?P.*)' - actions: - - echo: "{filecontent.all}" - - - - Match an invoice with a regular expression and sort by customer: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - filecontent: 'Invoice.*Customer (?P\w+)' - actions: - - move: '~/Documents/Invoices/{filecontent.customer}/' + - `{filecontent.groupname}`: The text matched with the named group + `(?P)` """ name = "filecontent" diff --git a/organize/filters/hash.py b/organize/filters/hash.py index 3efd8dd6..11b5bcd4 100644 --- a/organize/filters/hash.py +++ b/organize/filters/hash.py @@ -33,8 +33,9 @@ class Hash(Filter): {'shake_256', 'whirlpool', 'mdc2', 'blake2s', 'sha224', 'shake_128', 'sha3_512', 'sha3_224', 'sha384', 'md5', 'sha1', 'sha512_256', 'blake2b', 'sha256', 'sha512_224', 'ripemd160', 'sha3_384', 'md4', 'sm3', 'sha3_256', 'md5-sha1', 'sha512'} ``` - Returns: - str: The hash of the file. + **Returns:** + + - `{hash}`: The hash of the file. """ name = "hash" diff --git a/organize/filters/lastmodified.py b/organize/filters/lastmodified.py index 7d2d4b65..3cb54751 100644 --- a/organize/filters/lastmodified.py +++ b/organize/filters/lastmodified.py @@ -9,81 +9,21 @@ class LastModified(Filter): """Matches files by last modified date - :param int years: - specify number of years - - :param int months: - specify number of months - - :param float weeks: - specify number of weeks - - :param float days: - specify number of days - - :param float hours: - specify number of hours - - :param float minutes: - specify number of minutes - - :param float seconds: - specify number of seconds - - :param str mode: - either 'older' or 'newer'. 'older' matches all files last modified - before the given time, 'newer' matches all files last modified within - the given time. (default = 'older') - - :returns: - - ``{lastmodified.year}`` -- the year the file was last modified - - ``{lastmodified.month}`` -- the month the file was last modified - - ``{lastmodified.day}`` -- the day the file was last modified - - ``{lastmodified.hour}`` -- the hour the file was last modified - - ``{lastmodified.minute}`` -- the minute the file was last modified - - ``{lastmodified.second}`` -- the second the file was last modified - - Examples: - - Show all files on your desktop last modified at least 10 days ago: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - lastmodified: - days: 10 - actions: - - echo: 'Was modified at least 10 days ago' - - - Show all files on your desktop which were modified within the last - 5 hours: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - lastmodified: - hours: 5 - mode: newer - actions: - - echo: 'Was modified within the last 5 hours' - - - Sort pdfs by year of last modification - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Documents' - filters: - - extension: pdf - - lastmodified - actions: - - move: '~/Documents/PDF/{lastmodified.year}/' + Args: + years (int): specify number of years + months (int): specify number of months + weeks (float): specify number of weeks + days (float): specify number of days + hours (float): specify number of hours + minutes (float): specify number of minutes + seconds (float): specify number of seconds + mode (str): + either 'older' or 'newer'. 'older' matches all files created before the given + time, 'newer' matches all files created within the given time. + (default = 'older') + + Returns: + {lastmodified}: The datetime the file / dir was created. """ name = "lastmodified" diff --git a/organize/filters/mimetype.py b/organize/filters/mimetype.py index ea26be4f..d65b27bc 100644 --- a/organize/filters/mimetype.py +++ b/organize/filters/mimetype.py @@ -15,9 +15,16 @@ class MimeType(Filter): You can see a list of known MIME types on your system by running this oneliner: - .. code-block:: yaml + ```sh + python3 -c "import mimetypes as m; print('\\n'.join(sorted(set(m.common_types.values()) | set(m.types_map.values()))))" + ``` - python3 -c "import mimetypes as m; print('\\n'.join(sorted(set(m.common_types.values()) | set(m.types_map.values()))))" + Args: + *mimetypes (list(str) or str): The MIME types to filter for. + + **Returns:** + + - `{mimetype}`: The MIME type of the file. """ name = "mimetype" diff --git a/organize/filters/name.py b/organize/filters/name.py index 3c384b12..271a3df7 100644 --- a/organize/filters/name.py +++ b/organize/filters/name.py @@ -9,73 +9,22 @@ class Name(Filter): """Match files by filename - :param str match: - A matching string in `simplematch`-syntax - (https://github.com/tfeldmann/simplematch) + Args: + match (str): + A matching string in [simplematch-syntax](https://github.com/tfeldmann/simplematch) - :param str startswith: - The filename must begin with the given string + startswith (str): + The filename must begin with the given string - :param str contains: - The filename must contain the given string + contains (str): + The filename must contain the given string - :param str endswith: - The filename (without extension) must end with the given string + endswith (str): + The filename (without extension) must end with the given string - :param bool case_sensitive = True: - By default, the matching is case sensitive. Change this to False to use - case insensitive matching. - - Examples: - - Match all files starting with 'Invoice': - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - filename: - startswith: Invoice - actions: - - echo: 'This is an invoice' - - - Match all files starting with 'A' end containing the string 'hole' - (case insensitive) - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - filename: - startswith: A - contains: hole - case_sensitive: false - actions: - - echo: 'Found a match.' - - - Match all files starting with 'A' or 'B' containing '5' or '6' and ending with - '_end' - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - filename: - startswith: - - A - - B - contains: - - 5 - - 6 - endswith: _end - case_sensitive: false - actions: - - echo: 'Found a match.' + case_sensitive (bool): + By default, the matching is case sensitive. Change this to False to use + case insensitive matching. """ name = "name" diff --git a/organize/filters/python.py b/organize/filters/python.py index 000cbf02..75144e53 100644 --- a/organize/filters/python.py +++ b/organize/filters/python.py @@ -8,17 +8,19 @@ class Python(Filter): r"""Use python code to filter files. - :param str code: - The python code to execute. The code must contain a ``return`` statement. + Args: + code (str): + The python code to execute. The code must contain a `return` statement. - :returns: - - If your code returns ``False`` or ``None`` the file is filtered out, - otherwise the file is passed on to the next filters. - - ``{python}`` contains the returned value. If you return a dictionary (for - example ``return {"some_key": some_value, "nested": {"k": 2}}``) it will be - accessible via dot syntax in your actions: ``{python.some_key}``, - ``{python.nested.k}``. + **Returns:** + + - If your code returns `False` or `None` the file is filtered out, + otherwise the file is passed on to the next filters. + - `{python}` contains the returned value. If you return a dictionary (for + example `return {"some_key": some_value, "nested": {"k": 2}}`) it will be + accessible via dot syntax in your actions: `{python.some_key}`, + `{python.nested.k}`. """ name = "python" diff --git a/organize/filters/regex.py b/organize/filters/regex.py index 5c0b327d..127d8c52 100644 --- a/organize/filters/regex.py +++ b/organize/filters/regex.py @@ -8,42 +8,16 @@ class Regex(Filter): r"""Matches filenames with the given regular expression - :param str expr: - The regular expression to be matched. + Args: + expr (str): The regular expression to be matched. - Any named groups in your regular expression will be returned like this: - - :returns: - - ``{regex.yourgroupname}`` -- The text matched with the named group - ``(?P)`` - - Examples: - - Match an invoice with a regular expression: + **Returns:** - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Desktop' - filters: - - regex: '^RG(\d{12})-sig\.pdf$' - actions: - - move: '~/Documents/Invoices/1und1/' - - - Match and extract data from filenames with regex named groups: - This is just like the previous example but we rename the invoice using - the invoice number extracted via the regular expression and the named - group ``the_number``. + Any named groups in your regular expression will be returned like this: - .. code-block:: yaml - :caption: config.yaml + - `{regex.groupname}`: The text matched with the named + group `(?P.*)` - rules: - - folders: ~/Desktop - filters: - - regex: '^RG(?P\d{12})-sig\.pdf$' - actions: - - move: ~/Documents/Invoices/1und1/{regex.the_number}.pdf """ name = "regex" diff --git a/organize/filters/size.py b/organize/filters/size.py index cac9bba6..8c64d662 100644 --- a/organize/filters/size.py +++ b/organize/filters/size.py @@ -30,8 +30,8 @@ def create_constraints(inp: str) -> Set[Tuple[Callable[[int, int], bool], int]]: Given an input string it returns a list of tuples (comparison operator, number of bytes). - Accepted formats are: '30k', '>= 5 TiB, <10tb', '< 60 tb', ... - Calculation is in bytes, even if the 'b' is lowercase. If an 'i' is present + Accepted formats are: "30k", ">= 5 TiB, <10tb", "< 60 tb", ... + Calculation is in bytes, even if the "b" is lowercase. If an "i" is present we calculate base 1024. """ result = set() # type: Set[Tuple[Callable[[int, int], bool], int]] @@ -46,7 +46,7 @@ def create_constraints(inp: str) -> Set[Tuple[Callable[[int, int], bool], int]]: unit = match["unit"] base = 1024 if unit.endswith("i") else 1000 exp = "kmgtpezy".index(unit[0]) + 1 if unit else 0 - numbytes = num * base ** exp + numbytes = num * base**exp result.add((op, numbytes)) except (AttributeError, KeyError, IndexError, ValueError, TypeError) as e: raise ValueError("Invalid size format: %s" % part) from e @@ -60,53 +60,27 @@ def satisfies_constraints(size, constraints): class Size(Filter): """Matches files and folders by size - :param str conditions: + Args: + *conditions (list(str) or str): + The size constraints. - Accepts file size conditions, e.g: ``'>= 500 MB'``, ``'< 20k'``, ``'>0'``, - ``'= 10 KiB'``. + Accepts file size conditions, e.g: `">= 500 MB"`, `"< 20k"`, `">0"`, + `"= 10 KiB"`. It is possible to define both lower and upper conditions like this: - ``'>20k, < 1 TB'``, ``'>= 20 Mb, <25 Mb'``. The filter will match if all given + `">20k, < 1 TB"`, `">= 20 Mb, <25 Mb"`. The filter will match if all given conditions are satisfied. - Accepts all units from KB to YB. - If no unit is given, kilobytes are assumend. - If binary prefix is given (KiB, GiB) the size is calculated using base 1024. - :returns: - - ``{size.bytes}`` -- Size in bytes - - Examples: - - Trash big downloads: - - .. code-block:: yaml - :caption: config.yaml - - rules: - - locations: '~/Downloads' - targets: files - filters: - - filesize: '> 0.5 GB' - actions: - - trash - - - Move all JPEGS bigger > 1MB and <10 MB. Search all subfolders and keep the - original relative path. - - .. code-block:: yaml - :caption: config.yaml - - rules: - - folders: '~/Pictures' - subfolders: true - filters: - - extension: - - jpg - - jpeg - - filesize: '>1mb, <10mb' - actions: - - move: '~/Pictures/sorted/{relative_path}/' + **Returns:** + - `{size.bytes}`: (int) Size in bytes + - `{size.traditional}`: (str) Size with unit (powers of 1024, JDEC prefixes) + - `{size.binary}`: (str) Size with unit (powers of 1024, IEC prefixes) + - `{size.decimal}`: (str) Size with unit (powers of 1000, SI prefixes) """ name = "size" From 3d7eb8bd7ada24f5c202a12b67500fa1e238df31 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 30 Jan 2022 16:45:45 +0100 Subject: [PATCH 064/108] doc tests --- docs/03-filters.md | 61 +++++++++++++++++++++++------------------ docs/04-actions.md | 38 +++++++++++++------------ tests/docs/test_docs.py | 5 +++- 3 files changed, 59 insertions(+), 45 deletions(-) diff --git a/docs/03-filters.md b/docs/03-filters.md index cb74657d..51d4154d 100644 --- a/docs/03-filters.md +++ b/docs/03-filters.md @@ -7,7 +7,7 @@ look at the [Config](01-config.md) section. ::: organize.filters.Created -**Examples** +**Examples:** ```yaml rules: @@ -47,6 +47,8 @@ rules: ::: organize.filters.Duplicate +**Examples:** + ```yaml rules: - name: Show all duplicate files in your desktop and download folder (and their subfolders) @@ -63,6 +65,8 @@ rules: ::: organize.filters.Empty +**Examples:** + ```yaml rules: - name: "Recursively delete empty folders" @@ -83,8 +87,9 @@ rules: ```yaml rules: - name: "Show available EXIF data of your pictures" - folders: ~/Pictures - subfolders: true + locations: + - path: ~/Pictures + max_depth: null filters: - exif actions: @@ -96,8 +101,9 @@ Copy all images which contain GPS information while keeping subfolder structure: ```yaml rules: - name: "GPS demo" - locations: ~/Pictures - subfolders: true + locations: + - path: ~/Pictures + max_depth: null filters: - exif: gps.gpsdate actions: @@ -107,8 +113,9 @@ rules: ```yaml rules: - name: "Filter by camera manufacturer" - folders: ~/Pictures - subfolders: true + locations: + - path: ~/Pictures + max_depth: null filters: - exif: image.model: Nikon D3200 @@ -123,8 +130,9 @@ accordingly: ```yaml rules: - name: "camera sort" - locations: ~/Pictures - subfolders: true + locations: + - path: ~/Pictures + max_depth: null filters: - extension: jpg - exif: image.model @@ -136,7 +144,7 @@ rules: ::: organize.filters.Extension -**Examples** +**Examples:** ```yaml rules: @@ -196,12 +204,12 @@ rules: ::: organize.filters.FileContent -**Examples** +**Examples:** ```yaml rules: - name: "Show the content of all your PDF files" - folders: ~/Documents + locations: ~/Documents filters: - extension: pdf - filecontent: "(?P.*)" @@ -227,7 +235,7 @@ rules: ::: organize.filters.LastModified -**Examples**: +**Examples:** ```yaml rules: @@ -268,7 +276,7 @@ rules: ::: organize.filters.MimeType -**Examples** +**Examples:** ```yaml rules: @@ -316,7 +324,7 @@ rules: ::: organize.filters.Name -**Examples** +**Examples:** Match all files starting with 'Invoice': @@ -324,7 +332,7 @@ Match all files starting with 'Invoice': rules: - locations: "~/Desktop" filters: - - filename: + - name: startswith: Invoice actions: - echo: "This is an invoice" @@ -337,7 +345,7 @@ Match all files starting with 'A' end containing the string 'hole' rules: - locations: "~/Desktop" filters: - - filename: + - name: startswith: A contains: hole case_sensitive: false @@ -352,7 +360,7 @@ Match all files starting with 'A' or 'B' containing '5' or '6' and ending with rules: - locations: "~/Desktop" filters: - - filename: + - name: startswith: - A - B @@ -369,7 +377,7 @@ rules: ::: organize.filters.Python -**Examples** +**Examples:** ```yaml rules: @@ -436,13 +444,13 @@ Result: ::: organize.filters.Regex -**Examples** +**Examples:** Match an invoice with a regular expression: ```yaml rules: - - folders: "~/Desktop" + - locations: "~/Desktop" filters: - regex: '^RG(\d{12})-sig\.pdf$' actions: @@ -456,7 +464,7 @@ group `the_number`. ```yaml rules: - - folders: ~/Desktop + - locations: ~/Desktop filters: - regex: '^RG(?P\d{12})-sig\.pdf$' actions: @@ -476,7 +484,7 @@ rules: - locations: "~/Downloads" targets: files filters: - - filesize: "> 0.5 GB" + - size: "> 0.5 GB" actions: - trash ``` @@ -486,13 +494,14 @@ original relative path. ```yaml rules: - - folders: "~/Pictures" - subfolders: true + - locations: + - path: "~/Pictures" + max_depth: null filters: - extension: - jpg - jpeg - - filesize: ">1mb, <10mb" + - size: ">1mb, <10mb" actions: - move: "~/Pictures/sorted/{relative_path}/" ``` diff --git a/docs/04-actions.md b/docs/04-actions.md index 54587a2e..010f3c18 100644 --- a/docs/04-actions.md +++ b/docs/04-actions.md @@ -7,7 +7,7 @@ look at the [Config](01-config.md) section. ::: organize.actions.copy.Copy -**Examples** +**Examples:** Copy all pdfs into `~/Desktop/somefolder/` and keep filenames @@ -56,7 +56,7 @@ rules: ::: organize.actions.delete.Delete -**Examples** +**Examples:** ```yaml rules: @@ -88,7 +88,7 @@ rules: ::: organize.actions.Echo -**Examples** +**Examples:** ```yaml rules: @@ -147,7 +147,7 @@ rules: ::: organize.actions.MacOSTags -**Examples** +**Examples:** ```yaml rules: @@ -207,7 +207,7 @@ rules: ::: organize.actions.Move -**Examples** +**Examples:** Move all pdfs and jpgs from the desktop into the folder "~/Desktop/media/". Filenames are not changed. @@ -235,7 +235,7 @@ rules: actions: - move: dest: "~/Desktop/{extension.upper}/" - overwrite: true + on_conflict: "overwrite" ``` Move pdfs into the folder `Invoices`. Keep the filename but do not overwrite existing files. To prevent overwriting files, an index is added to the filename, so `somefile.jpg` becomes `somefile 2.jpg`. @@ -249,20 +249,22 @@ rules: actions: - move: dest: "~/Documents/Invoices/" - overwrite: false - counter_separator: "_" + on_conflict: "rename_new" + rename_template: "{name} {counter}{extension}" ``` ## python ::: organize.actions.Python +**Examples:** + A basic example that shows how to get the current file path and do some printing in a for loop. The `|` is yaml syntax for defining a string literal spanning multiple lines. ```yaml rules: - - folders: "~/Desktop" + - locations: "~/Desktop" actions: - python: | print('The path of the current file is %s' % path) @@ -273,7 +275,7 @@ rules: ```yaml rules: - name: "You can access filter data" - - folders: ~/Desktop + locations: ~/Desktop filters: - regex: '^(?P.*)\.(?P.*)$' actions: @@ -287,9 +289,9 @@ filename starting with an underscore: ```yaml rules: - - folders: ~/Desktop + - locations: ~/Desktop filters: - - filename: + - name: startswith: "_" actions: - python: | @@ -301,7 +303,7 @@ rules: ::: organize.actions.Rename -**Examples** +**Examples:** ```yaml rules: @@ -316,9 +318,9 @@ rules: ```yaml rules: - name: "Convert **all** file extensions to lowercase" - folders: "~/Desktop" + locations: "~/Desktop" filters: - - Extension + - extension actions: - rename: "{path.stem}.{extension.lower}" ``` @@ -327,12 +329,12 @@ rules: ::: organize.actions.Shell -**Examples** +**Examples:** ```yaml rules: - name: "On macOS: Open all pdfs on your desktop" - folders: "~/Desktop" + locations: "~/Desktop" filters: - extension: pdf actions: @@ -347,7 +349,7 @@ rules: ::: organize.actions.Trash -Example: +**Examples:** ```yaml rules: diff --git a/tests/docs/test_docs.py b/tests/docs/test_docs.py index 146ffbf7..8ef15993 100644 --- a/tests/docs/test_docs.py +++ b/tests/docs/test_docs.py @@ -21,11 +21,14 @@ def test_examples_are_valid(): for f in DOCS.values(): text = docdir.readtext(f) for match in RE_CONFIG.findall(text): + err = "" try: config = load_from_string(match) CONFIG_SCHEMA.validate(config) except SchemaError as e: - raise ValueError(e.autos[-1]) + print(f"{f}:\n({match})") + err = e.autos[-1] + assert not err def test_all_filters_documented(): From 291f317375d107393780c8eba499af8713bdbe12 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 30 Jan 2022 17:53:12 +0100 Subject: [PATCH 065/108] improved error messages, brought enabled and subfolders back --- organize/cli.py | 55 +++++++++++++++++++++---------------- organize/config.py | 4 ++- organize/core.py | 58 +++++++++++++++++++++++++-------------- organize/filters/regex.py | 4 +-- organize/output.py | 19 +++++++------ organize/utils.py | 2 -- 6 files changed, 84 insertions(+), 58 deletions(-) diff --git a/organize/cli.py b/organize/cli.py index 912882ed..428a74f2 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -4,35 +4,44 @@ The file management automation tool. """ import os -from pathlib import Path +import sys -import appdirs import click +from fs import appfs, osfs from . import output -from .output import console from .__version__ import __version__ +from .output import console -# prepare config and log folders -APP_DIRS = appdirs.AppDirs("organize") - -# setting the $ORGANIZE_CONFIG env variable overrides the default config path -if os.getenv("ORGANIZE_CONFIG"): - CONFIG_PATH = Path(os.getenv("ORGANIZE_CONFIG", "")).resolve() - CONFIG_DIR = CONFIG_PATH.parent -else: - CONFIG_DIR = Path(APP_DIRS.user_config_dir) - CONFIG_PATH = CONFIG_DIR / "config.yaml" - -LOG_DIR = Path(APP_DIRS.user_log_dir) -LOG_PATH = LOG_DIR / "organize.log" +DOCS_URL = "https://organize.readthedocs.io" +DEFAULT_CONFIG = """# organize configuration file +# {docs} + +rules: + locations: + - + filters: + - + actions: + - +""".format( + docs=DOCS_URL +) -for folder in (CONFIG_DIR, LOG_DIR): - folder.mkdir(parents=True, exist_ok=True) +try: + config_filename = "config.yaml" + if os.getenv("ORGANIZE_CONFIG"): + dirname, config_filename = os.path.split(os.getenv("ORGANIZE_CONFIG")) + config_fs = osfs.OSFS(dirname, create=False) + else: + config_fs = appfs.UserConfigFS("organize", create=True) -# create empty config file if it does not exist -if not CONFIG_PATH.exists(): - CONFIG_PATH.touch() + if not config_fs.exists(config_filename): + config_fs.writetext(config_filename, DEFAULT_CONFIG) + CONFIG_PATH = config_fs.getsyspath(config_filename) +except Exception as e: + output.error(str(e), title="Config file") + sys.exit(1) class NaturalOrderGroup(click.Group): @@ -43,7 +52,6 @@ def list_commands(self, ctx): CLI_CONFIG = click.argument( "config", required=False, - envvar="ORGANIZE_CONFIG", default=CONFIG_PATH, type=click.Path(exists=True), ) @@ -109,7 +117,6 @@ def sim(config, working_dir, config_file): "config", required=False, default=CONFIG_PATH, - envvar="ORGANIZE_CONFIG", type=click.Path(), ) @click.option( @@ -163,7 +170,7 @@ def schema(): @cli.command() def docs(): """Opens the documentation.""" - click.launch("https://organize.readthedocs.io") + click.launch(DOCS_URL) # deprecated - only here for backwards compatibility diff --git a/organize/config.py b/organize/config.py index c97638e7..52f832ff 100644 --- a/organize/config.py +++ b/organize/config.py @@ -11,6 +11,8 @@ Literal("rules", description="All rules are defined here."): [ { Optional("name", description="The name of the rule."): str, + Optional("enabled"): bool, + Optional("subfolders"): bool, Optional( "targets", description="Whether the rule should apply to directories or folders.", @@ -40,7 +42,7 @@ Optional(x.get_schema()) for x in FILTERS.values() ], "actions": [Optional(x.get_schema()) for x in ACTIONS.values()], - } + }, ], Optional("version"): int, }, diff --git a/organize/core.py b/organize/core.py index 4494864b..ef51fa28 100644 --- a/organize/core.py +++ b/organize/core.py @@ -7,6 +7,7 @@ import fs from fs.base import FS from fs.walk import Walker +from rich.console import Console from schema import SchemaError from . import output @@ -15,10 +16,10 @@ from .config import CONFIG_SCHEMA, load_from_file from .filters import FILTERS from .filters.filter import Filter -from .output import console from .utils import Template, deep_merge_inplace, ensure_list logger = logging.getLogger(__name__) +console = Console() class Location(NamedTuple): @@ -60,10 +61,14 @@ def walker_args_from_location_options(options): } -def instantiate_location(loc) -> Location: +def instantiate_location(loc, default_max_depth=0) -> Location: if isinstance(loc, str): loc = {"path": loc} + # set default max depth from rule + if not "max_depth" in loc: + loc["max_depth"] = default_max_depth + if "walker" not in loc: args = walker_args_from_location_options(loc) walker = Walker(**args) @@ -99,29 +104,42 @@ def instantiate_by_name(d, classes): def replace_with_instances(config): warnings = [] + # delete disabled rules + config["rules"] = [rule for rule in config["rules"] if rule.get("enabled", True)] + for rule in config["rules"]: - locations = [] + _locations = [] + default_depth = None if rule.get("subfolders", False) else 0 for loc in ensure_list(rule["locations"]): try: - instance = instantiate_location(loc) - locations.append(instance) + instance = instantiate_location(loc, default_max_depth=default_depth) + _locations.append(instance) except Exception as e: - if loc.get("ignore_errors", False): + if isinstance(loc, dict) and loc.get("ignore_errors", False): warnings.append(str(e)) else: - raise e - - rule["locations"] = locations + raise ValueError("Invalid location %s" % loc) from e # filters are optional - rule["filters"] = [ - instantiate_by_name(x, FILTERS) - for x in ensure_list(rule.get("filters", [])) - ] - rule["actions"] = [ - instantiate_by_name(x, ACTIONS) for x in ensure_list(rule["actions"]) - ] + _filters = [] + for x in ensure_list(rule.get("filters", [])): + try: + _filters.append(instantiate_by_name(x, FILTERS)) + except Exception as e: + raise ValueError("Invalid filter %s (%s)" % (x, e)) from e + + # actions + _actions = [] + for x in ensure_list(rule["actions"]): + try: + _actions.append(instantiate_by_name(x, ACTIONS)) + except Exception as e: + raise ValueError("Invalid action %s (%s)" % (x, e)) from e + + rule["locations"] = _locations + rule["filters"] = _filters + rule["actions"] = _actions return warnings @@ -160,14 +178,14 @@ def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bo def run(config, simulate: bool = True): - count = Counter(done=0, fail=0) # type: Counter + count = Counter(done=0, fail=0) # type: Counter if simulate: output.simulation_banner() - for rule in config["rules"]: + for rule_nr, rule in enumerate(config["rules"], start=1): target = rule.get("targets", "files") - output.rule(rule["name"]) + output.rule(rule.get("name", "Rule %s" % rule_nr)) with output.spinner(simulate=simulate): for walker, base_fs, base_path in rule["locations"]: @@ -218,7 +236,7 @@ def run_file(config_file: str, working_dir: str, simulate: bool): count = run(rules, simulate=simulate) output.summary(count) except SchemaError as e: - console.print("Invalid config file") + output.error("Invalid config file") console.print(e.autos[-1]) except Exception as e: console.print_exception() diff --git a/organize/filters/regex.py b/organize/filters/regex.py index 127d8c52..89e9d8c8 100644 --- a/organize/filters/regex.py +++ b/organize/filters/regex.py @@ -6,7 +6,7 @@ class Regex(Filter): - r"""Matches filenames with the given regular expression + """Matches filenames with the given regular expression Args: expr (str): The regular expression to be matched. @@ -33,6 +33,6 @@ def pipeline(self, args: dict) -> FilterResult: return FilterResult( matches=bool(match), updates={ - self.get_name(): match.groupdict(), + self.get_name(): match.groupdict() if match else "", }, ) diff --git a/organize/output.py b/organize/output.py index b912ae81..1b87a8ab 100644 --- a/organize/output.py +++ b/organize/output.py @@ -1,4 +1,3 @@ -from collections import Counter from fs.path import basename, dirname, forcedir from rich.console import Console from rich.panel import Panel @@ -7,8 +6,6 @@ from organize.__version__ import __version__ -from .utils import resource_description - ICON_DIR = "🗁" ICON_FILE = "" INDENT = " " * 2 @@ -17,15 +14,15 @@ { "info": "dim cyan", "warning": "yellow", - "error": "bold red", + "error": "red", "simulation": "bold green", "status": "bold green", "rule": "bold cyan", "location.fs": "yellow", "location.base": "green", "location.main": "bold green", - "path.base": "dim white", - "path.main": "white", + "path.base": "dim green", + "path.main": "green", "path.icon": "white", "pipeline.source": "cyan", "pipeline.msg": "white", @@ -82,6 +79,10 @@ def deprecated(msg): warn(msg, title="Deprecated") +def error(msg, title="Error"): + console.print("[error][b]{}:[/b] {}[/error]".format(title, msg)) + + def simulation_banner(): console.print() console.print(Panel("SIMULATION", style="simulation")) @@ -126,7 +127,7 @@ def path(fs, path): def pipeline_message(source: str, msg: str) -> None: line = Text.assemble( INDENT * 2, - ("- ({})".format(source), "pipeline.source"), + ("- ({}) ".format(source), "pipeline.source"), (msg, "pipeline.msg"), ) with_path.print(line) @@ -136,14 +137,14 @@ def pipeline_message(source: str, msg: str) -> None: def pipeline_error(source: str, msg: str): line = Text.assemble( INDENT * 2, - ("- ({})".format(source), "pipeline.source"), + ("- ({}) ".format(source), "pipeline.source"), ("ERROR! {}".format(msg), "pipeline.error"), ) with_path.print(line) with_newline.set_prefix("") -def summary(count: Counter): +def summary(count: dict): console.print() if not sum(count.values()): console.print("Nothing to do.") diff --git a/organize/utils.py b/organize/utils.py index 23c3c83d..6baf72db 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -12,14 +12,12 @@ Template = jinja2.Environment( variable_start_string="{", variable_end_string="}", - finalize=lambda x: x() if callable(x) else x, autoescape=False, ) NativeTemplate = nativetypes.NativeEnvironment( variable_start_string="{", variable_end_string="}", - finalize=lambda x: x() if callable(x) else x, autoescape=False, ) From b082796cd0bd8710ef5795ee198d80b90290a442 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 31 Jan 2022 12:39:33 +0100 Subject: [PATCH 066/108] add confirm action --- organize/actions/__init__.py | 2 +- organize/actions/action.py | 2 +- organize/actions/confirm.py | 29 ++++++----- organize/actions/echo.py | 8 +-- organize/cli.py | 15 +++--- organize/{output.py => console.py} | 75 ++++++++++++++++++++++----- organize/core.py | 82 +++++++++++++++--------------- organize/filters/filter.py | 2 +- organize/filters/python.py | 2 +- testconf.yaml | 3 +- 10 files changed, 134 insertions(+), 86 deletions(-) rename organize/{output.py => console.py} (70%) diff --git a/organize/actions/__init__.py b/organize/actions/__init__.py index 83d897ba..5141af73 100644 --- a/organize/actions/__init__.py +++ b/organize/actions/__init__.py @@ -13,7 +13,7 @@ from .trash import Trash ACTIONS = { - # Confirm.name: Confirm, + Confirm.name: Confirm, Copy.name: Copy, Delete.name: Delete, Echo.name: Echo, diff --git a/organize/actions/action.py b/organize/actions/action.py index f97e1227..6ca90528 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -3,7 +3,7 @@ from schema import Optional, Or, Schema -from organize.output import pipeline_error, pipeline_message +from organize.console import pipeline_error, pipeline_message class Error(Exception): diff --git a/organize/actions/confirm.py b/organize/actions/confirm.py index 9d2df1b1..259dffe4 100644 --- a/organize/actions/confirm.py +++ b/organize/actions/confirm.py @@ -1,27 +1,30 @@ -import logging - from rich.prompt import Prompt -from organize.output import console +from organize import console +from organize.utils import Template from .action import Action -logger = logging.getLogger(__name__) -# TODO not working right now +class Confirm(Action): + name = "confirm" + schema_support_instance_without_args = True -class Confirm(Action): - def __init__(self, msg, default): - self.msg = msg + def __init__(self, msg="Continue?", default=True): + self.msg = Template.from_string(msg) self.default = default self.prompt = Prompt(console=console) def pipeline(self, args: dict, simulate: bool): - chosen = self.prompt.ask("", default=self.default) - self.print(chosen) + msg = self.msg.render(**args) + result = console.pipeline_confirm( + self.get_name(), + msg, + default=self.default, + ) + if not result: + raise ValueError("Aborted") def __str__(self) -> str: - return 'Echo(msg="%s")' % self.msg - - name = "confirm" + return 'Confirm(msg="%s")' % self.msg diff --git a/organize/actions/echo.py b/organize/actions/echo.py index 6567a079..22d34bb3 100644 --- a/organize/actions/echo.py +++ b/organize/actions/echo.py @@ -1,10 +1,5 @@ -import logging - -from .action import Action - -logger = logging.getLogger(__name__) - from ..utils import Template +from .action import Action class Echo(Action): @@ -26,7 +21,6 @@ def get_schema(cls): def __init__(self, msg) -> None: self.msg = Template.from_string(msg) - self.log = logging.getLogger(__name__) def pipeline(self, args: dict, simulate: bool) -> None: full_msg = self.msg.render(**args) diff --git a/organize/cli.py b/organize/cli.py index 428a74f2..3ce8cc25 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -9,9 +9,9 @@ import click from fs import appfs, osfs -from . import output +from . import console from .__version__ import __version__ -from .output import console +from .console import console DOCS_URL = "https://organize.readthedocs.io" DEFAULT_CONFIG = """# organize configuration file @@ -36,11 +36,12 @@ else: config_fs = appfs.UserConfigFS("organize", create=True) + # create default config file if it not exists if not config_fs.exists(config_filename): config_fs.writetext(config_filename, DEFAULT_CONFIG) CONFIG_PATH = config_fs.getsyspath(config_filename) except Exception as e: - output.error(str(e), title="Config file") + console.error(str(e), title="Config file") sys.exit(1) @@ -90,7 +91,7 @@ def run(config, working_dir, config_file): if config_file: config = config_file - output.deprecated( + console.deprecated( "The --config-file option can now be omitted. See organize --help." ) run_file(config_file=config, working_dir=working_dir, simulate=False) @@ -106,7 +107,7 @@ def sim(config, working_dir, config_file): if config_file: config = config_file - output.deprecated( + console.deprecated( "The --config-file option can now be omitted. See organize --help." ) run_file(config_file=config, working_dir=working_dir, simulate=True) @@ -190,8 +191,8 @@ def config(ctx, path, debug, open_folder): ctx.invoke(check) else: ctx.invoke(edit) - output.deprecated("`organize config` is deprecated.") - output.deprecated("Please see `organize --help` for all available commands.") + console.deprecated("`organize config` is deprecated.") + console.deprecated("Please see `organize --help` for all available commands.") if __name__ == "__main__": diff --git a/organize/output.py b/organize/console.py similarity index 70% rename from organize/output.py rename to organize/console.py index 1b87a8ab..f73dfc14 100644 --- a/organize/output.py +++ b/organize/console.py @@ -1,8 +1,10 @@ -from fs.path import basename, dirname, forcedir +from fs.path import basename, dirname, forcedir, relpath from rich.console import Console from rich.panel import Panel from rich.text import Text from rich.theme import Theme +from rich.status import Status +from rich.prompt import Confirm as RichConfirm, Prompt as RichPrompt from organize.__version__ import __version__ @@ -27,11 +29,13 @@ "pipeline.source": "cyan", "pipeline.msg": "white", "pipeline.error": "bold red", + "pipeline.prompt": "bold yellow", "summary.done": "bold green", "summary.fail": "red", } ) console = Console(theme=theme, highlight=False) +status = Status("", console=console) class Prefixer: @@ -46,10 +50,13 @@ def set_prefix(self, *args, **kwargs): self._args = args self._kwargs = kwargs - def print(self, *args, **kwargs): + def ensure_prefix(self): if self._args is not None: console.print(*self._args, **self._kwargs) self.reset() + + def print(self, *args, **kwargs): + self.ensure_prefix() console.print(*args, **kwargs) @@ -57,10 +64,32 @@ def print(self, *args, **kwargs): with_newline = Prefixer() +class PipelineMixin: + @classmethod + def set_source(cls, source): + cls.validate_error_message = Text.assemble( + _pipeline_base(source), + ("Please enter Y or N", "prompt.invalid"), + ) + + def pre_prompt(self): + with_path.ensure_prefix() + + +class Confirm(PipelineMixin, RichConfirm): + pass + + +class Prompt(PipelineMixin, RichPrompt): + pass + + def _highlight_path(path, base_style, main_style): + dir_ = forcedir(dirname(path)) + name = basename(path) return Text.assemble( - (forcedir(dirname(path)), base_style), - (basename(path), main_style), + (dir_, base_style), + (name, main_style), ) @@ -90,7 +119,8 @@ def simulation_banner(): def spinner(simulate: bool): status_verb = "simulating" if simulate else "organizing" - return console.status("[status]%s..." % status_verb) + status.update(Text(status_verb, "status")) + status.start() def rule(rule): @@ -117,17 +147,23 @@ def path(fs, path): icon = ICON_DIR if fs.isdir(path) else ICON_FILE msg = Text.assemble( INDENT, - _highlight_path(path, "path.base", "path.main"), + _highlight_path(relpath(path), "path.base", "path.main"), " ", (icon, "path.icon"), ) with_path.set_prefix(msg) -def pipeline_message(source: str, msg: str) -> None: - line = Text.assemble( +def _pipeline_base(source: str): + return Text.assemble( INDENT * 2, ("- ({}) ".format(source), "pipeline.source"), + ) + + +def pipeline_message(source: str, msg: str) -> None: + line = Text.assemble( + _pipeline_base(source), (msg, "pipeline.msg"), ) with_path.print(line) @@ -135,16 +171,29 @@ def pipeline_message(source: str, msg: str) -> None: def pipeline_error(source: str, msg: str): - line = Text.assemble( - INDENT * 2, - ("- ({}) ".format(source), "pipeline.source"), - ("ERROR! {}".format(msg), "pipeline.error"), - ) + line = _pipeline_base(source) + line.append("ERROR! {}".format(msg), "pipeline.error") with_path.print(line) with_newline.set_prefix("") +def pipeline_confirm(source: str, msg: str, default: bool): + status.stop() + line = _pipeline_base(source) + line.append(msg, "pipeline.prompt") + Confirm.set_source(source) + result = Confirm.ask( + line, + console=console, + default=default, + ) + with_newline.set_prefix("") + status.start() + return result + + def summary(count: dict): + status.stop() console.print() if not sum(count.values()): console.print("Nothing to do.") diff --git a/organize/core.py b/organize/core.py index ef51fa28..08a51425 100644 --- a/organize/core.py +++ b/organize/core.py @@ -10,7 +10,7 @@ from rich.console import Console from schema import SchemaError -from . import output +from . import console from .actions import ACTIONS from .actions.action import Action from .config import CONFIG_SCHEMA, load_from_file @@ -19,7 +19,7 @@ from .utils import Template, deep_merge_inplace, ensure_list logger = logging.getLogger(__name__) -console = Console() +highlighted_console = Console() class Location(NamedTuple): @@ -171,7 +171,7 @@ def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bo if updates is not None: deep_merge_inplace(args, updates) except Exception as e: # pylint: disable=broad-except - logger.exception(e) + #logger.exception(e) action.print_error(str(e)) return False return True @@ -181,62 +181,62 @@ def run(config, simulate: bool = True): count = Counter(done=0, fail=0) # type: Counter if simulate: - output.simulation_banner() + console.simulation_banner() + console.spinner(simulate=simulate) for rule_nr, rule in enumerate(config["rules"], start=1): target = rule.get("targets", "files") - output.rule(rule.get("name", "Rule %s" % rule_nr)) - - with output.spinner(simulate=simulate): - for walker, base_fs, base_path in rule["locations"]: - output.location(base_fs, base_path) - walk = walker.files if target == "files" else walker.dirs - for path in walk(fs=base_fs, path=base_path): - output.path(base_fs, path) - relative_path = fs.path.relativefrom(base_path, path) - args = { - "fs": base_fs, - "fs_path": path, - "relative_path": relative_path, - "env": os.environ, - "now": datetime.now(), - "utcnow": datetime.utcnow(), - "path": lambda: base_fs.getsyspath(path), - } - match = filter_pipeline( - filters=rule["filters"], + console.rule(rule.get("name", "Rule %s" % rule_nr)) + + for walker, base_fs, base_path in rule["locations"]: + console.location(base_fs, base_path) + walk = walker.files if target == "files" else walker.dirs + for path in walk(fs=base_fs, path=base_path): + console.path(base_fs, path) + relative_path = fs.path.relativefrom(base_path, path) + args = { + "fs": base_fs, + "fs_path": path, + "relative_path": relative_path, + "env": os.environ, + "now": datetime.now(), + "utcnow": datetime.utcnow(), + "path": lambda: base_fs.getsyspath(path), + } + match = filter_pipeline( + filters=rule["filters"], + args=args, + ) + if match: + is_success = action_pipeline( + actions=rule["actions"], args=args, + simulate=simulate, ) - if match: - is_success = action_pipeline( - actions=rule["actions"], - args=args, - simulate=simulate, - ) - if is_success: - count["done"] += 1 - else: - count["fail"] += 1 + if is_success: + count["done"] += 1 + else: + count["fail"] += 1 if simulate: - output.simulation_banner() + console.simulation_banner() return count def run_file(config_file: str, working_dir: str, simulate: bool): - output.info(config_file, working_dir) + console.info(config_file, working_dir) try: rules = load_from_file(config_file) CONFIG_SCHEMA.validate(rules) warnings = replace_with_instances(rules) for msg in warnings: - output.warn(msg) + console.warn(msg) os.chdir(working_dir) count = run(rules, simulate=simulate) - output.summary(count) + console.summary(count) except SchemaError as e: - output.error("Invalid config file") - console.print(e.autos[-1]) + highlighted_console.error("Invalid config file") + highlighted_console.print(e.autos[-1]) except Exception as e: - console.print_exception() + highlighted_console.print_exception() diff --git a/organize/filters/filter.py b/organize/filters/filter.py index eb4a0f71..7652ace8 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -1,7 +1,7 @@ from schema import Schema, Optional, Or from textwrap import indent from typing import Any, Dict, Union, NamedTuple -from organize.output import pipeline_message, pipeline_error +from organize.console import pipeline_message, pipeline_error class FilterResult(NamedTuple): diff --git a/organize/filters/python.py b/organize/filters/python.py index 75144e53..b4e4f492 100644 --- a/organize/filters/python.py +++ b/organize/filters/python.py @@ -6,7 +6,7 @@ class Python(Filter): - r"""Use python code to filter files. + """Use python code to filter files. Args: code (str): diff --git a/testconf.yaml b/testconf.yaml index 2e6f5ea0..2a489587 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -14,8 +14,9 @@ rules: ignore_errors: true filters: - name: - startswith: Liasd + startswith: L actions: + - confirm - echo: "{name}" - name: "Folders" From ef5c3044379a78792a44625bc02f9c45c0c1df63 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 31 Jan 2022 12:49:10 +0100 Subject: [PATCH 067/108] code cleanup, use rel paths --- CHANGELOG.md | 1 + organize/console.py | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a502b06a..7420b03a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Please backup all your important stuff before running and use the simulate optio - Added filter `empty`. - Added filter `hash`. - Added action `symlink`. +- Added action `confirm`. ### changed diff --git a/organize/console.py b/organize/console.py index f73dfc14..c4dcf776 100644 --- a/organize/console.py +++ b/organize/console.py @@ -84,8 +84,10 @@ class Prompt(PipelineMixin, RichPrompt): pass -def _highlight_path(path, base_style, main_style): +def _highlight_path(path, base_style, main_style, relative=False): dir_ = forcedir(dirname(path)) + if relative: + dir_ = relpath(dir_) name = basename(path) return Text.assemble( (dir_, base_style), @@ -147,7 +149,7 @@ def path(fs, path): icon = ICON_DIR if fs.isdir(path) else ICON_FILE msg = Text.assemble( INDENT, - _highlight_path(relpath(path), "path.base", "path.main"), + _highlight_path(path, "path.base", "path.main", relative=True), " ", (icon, "path.icon"), ) From 0dc73a51c75de9020f2de00ea75db34a515a1c1d Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 31 Jan 2022 12:57:50 +0100 Subject: [PATCH 068/108] fix warnings --- organize/cli.py | 6 +++--- organize/core.py | 4 ++-- poetry.lock | 2 +- pyproject.toml | 1 - 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/organize/cli.py b/organize/cli.py index 3ce8cc25..e18699b3 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -11,7 +11,6 @@ from . import console from .__version__ import __version__ -from .console import console DOCS_URL = "https://organize.readthedocs.io" DEFAULT_CONFIG = """# organize configuration file @@ -31,7 +30,7 @@ try: config_filename = "config.yaml" if os.getenv("ORGANIZE_CONFIG"): - dirname, config_filename = os.path.split(os.getenv("ORGANIZE_CONFIG")) + dirname, config_filename = os.path.split(os.getenv("ORGANIZE_CONFIG", "")) config_fs = osfs.OSFS(dirname, create=False) else: config_fs = appfs.UserConfigFS("organize", create=True) @@ -159,13 +158,14 @@ def schema(): import json from .config import CONFIG_SCHEMA + from .console import console as richconsole js = json.dumps( CONFIG_SCHEMA.json_schema( schema_id="https://tfeldmann.de/organize.schema.json", ) ) - console.print_json(js) + richconsole.print_json(js) @cli.command() diff --git a/organize/core.py b/organize/core.py index 08a51425..9ea0325a 100644 --- a/organize/core.py +++ b/organize/core.py @@ -171,7 +171,7 @@ def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bo if updates is not None: deep_merge_inplace(args, updates) except Exception as e: # pylint: disable=broad-except - #logger.exception(e) + # logger.exception(e) action.print_error(str(e)) return False return True @@ -236,7 +236,7 @@ def run_file(config_file: str, working_dir: str, simulate: bool): count = run(rules, simulate=simulate) console.summary(count) except SchemaError as e: - highlighted_console.error("Invalid config file") + console.error("Invalid config file") highlighted_console.print(e.autos[-1]) except Exception as e: highlighted_console.print_exception() diff --git a/poetry.lock b/poetry.lock index 5202a816..266a9dc0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -966,7 +966,7 @@ textract = ["textract"] [metadata] lock-version = "1.1" python-versions = "^3.6.2" -content-hash = "f8bf1c60f540c1372b02cf4f7af43dee5ece0bdadfba5d751ca45a862bff88f1" +content-hash = "2dcfc08bd911333a7ea1da72b2af1c95985949d1ec2426828fe55a8472910eee" [metadata.files] appdirs = [ diff --git a/pyproject.toml b/pyproject.toml index 4e0f19fe..385c27a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,6 @@ macos-tags = { version = "^1.5.1", markers = "sys_platform == 'darwin'" } schema = "^0.7.5" Jinja2 = "^3.0.3" click = "^8.0.3" -appdirs = "^1.4.4" [tool.poetry.extras] textract = ["textract"] From 000ee56ff3c4e0a4f9ee6339bb8a684d12d96601 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 31 Jan 2022 14:23:33 +0100 Subject: [PATCH 069/108] support "not" before filter name --- CHANGELOG.md | 5 +++-- docs/04-actions.md | 25 ++++++++++++++++++++++++- mkdocs.yml | 1 + organize/actions/confirm.py | 2 ++ organize/config.py | 1 + organize/console.py | 2 +- organize/core.py | 37 +++++++++++++++++++++++-------------- organize/filters/filter.py | 3 +++ organize/utils.py | 23 +++++++++++++++++++++++ pyproject.toml | 3 +++ testconf.yaml | 6 +++--- 11 files changed, 87 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7420b03a..b3e89c14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,13 @@ Please backup all your important stuff before running and use the simulate optio ### what's new -- Respects your rule order - safer, less magic, less surprises. - (v1 tried to be clever. v2 now works your config file from top to bottom) - You can now target directories with your rules (copying, renaming, etc a whole folder) - Organize inside or between (S)FTP, S3 Buckets, Zip archives and many more. - [Available filesystems](https://www.pyfilesystem.org/page/index-of-filesystems/) - `max_depth` setting when recursing into subfolders +- Respects your rule order - safer, less magic, less surprises. + (v1 tried to be clever. v2 now works your config file from top to bottom) +- Jinja2 template engine for placeholders. - Instant start. (does not need to gather all the files before starting) - Filters can now be excluded. - Nice terminal output. diff --git a/docs/04-actions.md b/docs/04-actions.md index 010f3c18..e3ab74f6 100644 --- a/docs/04-actions.md +++ b/docs/04-actions.md @@ -3,9 +3,32 @@ This page shows the specifics of each action. For basic action usage and options have a look at the [Config](01-config.md) section. +## confirm + +::: organize.actions.Confirm + +**Examples** + +Confirm before deleting a duplicate + +```yaml +rules: + - name: "Delete duplicates" + locations: + - ~/Downloads + - ~/Documents + filters: + - not empty + - duplicate + - name + actions: + - confirm: "Delete {name}?" + - trash +``` + ## copy -::: organize.actions.copy.Copy +::: organize.actions.Copy **Examples:** diff --git a/mkdocs.yml b/mkdocs.yml index 51358a97..05e7d44a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -25,6 +25,7 @@ plugins: show_root_heading: false show_source: false watch: + - . - organize markdown_extensions: diff --git a/organize/actions/confirm.py b/organize/actions/confirm.py index 259dffe4..cb7d0497 100644 --- a/organize/actions/confirm.py +++ b/organize/actions/confirm.py @@ -8,6 +8,8 @@ class Confirm(Action): + """Ask for confirmation before continuing.""" + name = "confirm" schema_support_instance_without_args = True diff --git a/organize/config.py b/organize/config.py index 52f832ff..114f9ba1 100644 --- a/organize/config.py +++ b/organize/config.py @@ -13,6 +13,7 @@ Optional("name", description="The name of the rule."): str, Optional("enabled"): bool, Optional("subfolders"): bool, + Optional("filter_mode"): Or("all", "any", "none"), Optional( "targets", description="Whether the rule should apply to directories or folders.", diff --git a/organize/console.py b/organize/console.py index c4dcf776..44db7c91 100644 --- a/organize/console.py +++ b/organize/console.py @@ -25,7 +25,7 @@ "location.main": "bold green", "path.base": "dim green", "path.main": "green", - "path.icon": "white", + "path.icon": "green", "pipeline.source": "cyan", "pipeline.msg": "white", "pipeline.error": "bold red", diff --git a/organize/core.py b/organize/core.py index 9ea0325a..c7f78062 100644 --- a/organize/core.py +++ b/organize/core.py @@ -16,7 +16,7 @@ from .config import CONFIG_SCHEMA, load_from_file from .filters import FILTERS from .filters.filter import Filter -from .utils import Template, deep_merge_inplace, ensure_list +from .utils import Template, deep_merge_inplace, ensure_list, ensure_dict, to_args logger = logging.getLogger(__name__) highlighted_console = Console() @@ -89,16 +89,25 @@ def instantiate_location(loc, default_max_depth=0) -> Location: ) -def instantiate_by_name(d, classes): - if isinstance(d, str): - return classes[d]() - key, value = list(d.items())[0] - if isinstance(key, str): - Class = classes[key] - if isinstance(value, dict): - return Class(**value) - return Class(value) - return d +def instantiate_filter(filter_config): + spec = ensure_dict(filter_config) + name, value = next(iter(spec.items())) + parts = name.split(maxsplit=1) + invert = False + if len(parts) == 2 and parts[0] == "not": + name = parts[1] + invert = True + args, kwargs = to_args(value) + instance = FILTERS[name](*args, **kwargs) + instance.set_logic(inverted=invert) + return instance + + +def instantiate_action(action_config): + spec = ensure_dict(action_config) + name, value = next(iter(spec.items())) + args, kwargs = to_args(value) + return ACTIONS[name](*args, **kwargs) def replace_with_instances(config): @@ -125,7 +134,7 @@ def replace_with_instances(config): _filters = [] for x in ensure_list(rule.get("filters", [])): try: - _filters.append(instantiate_by_name(x, FILTERS)) + _filters.append(instantiate_filter(x)) except Exception as e: raise ValueError("Invalid filter %s (%s)" % (x, e)) from e @@ -133,7 +142,7 @@ def replace_with_instances(config): _actions = [] for x in ensure_list(rule["actions"]): try: - _actions.append(instantiate_by_name(x, ACTIONS)) + _actions.append(instantiate_action(x)) except Exception as e: raise ValueError("Invalid action %s (%s)" % (x, e)) from e @@ -152,7 +161,7 @@ def filter_pipeline(filters: Iterable[Filter], args: dict) -> bool: for filter_ in filters: try: match, updates = filter_.pipeline(args) - if not match: + if not (match ^ filter_.inverted): return False deep_merge_inplace(args, updates) except Exception as e: # pylint: disable=broad-except diff --git a/organize/filters/filter.py b/organize/filters/filter.py index 7652ace8..c0932a10 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -64,6 +64,9 @@ def print(self, msg: str) -> None: def print_error(self, msg: str): pipeline_error(self.get_name(), msg) + def set_logic(self, inverted=False): + self.inverted = inverted + def __str__(self) -> str: """Return filter name and properties""" return self.get_name() diff --git a/organize/utils.py b/organize/utils.py index 6baf72db..c3f12e93 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -55,6 +55,29 @@ def ensure_list(inp): return inp +def ensure_dict(inp): + if isinstance(inp, dict): + return inp + elif isinstance(inp, str): + return {inp: {}} + raise ValueError("Cannot ensure dict: %s" % inp) + + +def to_args(inp): + """Convert a argument into a (args, kwargs) tuple. + + >>> to_args('test') + (['test'], {}) + >>> to_args([1, 2, 3]) + ([1, 2, 3], {}) + >>> to_args({'a': {'b': 'c'}}) + ([], {'a': {'b': 'c'}}) + """ + if isinstance(inp, dict): + return ([], inp) + return (ensure_list(inp), {}) + + def flatten(arr: List[Any]) -> List[Any]: if arr == []: return [] diff --git a/pyproject.toml b/pyproject.toml index 385c27a0..82e7faa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,9 @@ module = [ ] ignore_missing_imports = true +[tool.pytest.ini_options] +addopts = "--doctest-modules" + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/testconf.yaml b/testconf.yaml index 2a489587..a3516494 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -1,5 +1,6 @@ rules: - name: "Files starting with L" + enabled: false targets: files locations: - path: ~/Downloads @@ -23,10 +24,9 @@ rules: targets: dirs locations: - path: ~/Desktop - max_depth: 3 filters: - - name: - startswith: Iasdawf + - not name: + startswith: Inbox actions: - echo: "{name}" From eed943d029edd9082df3e2bc4ffc5eef08cb08b5 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 31 Jan 2022 18:21:06 +0100 Subject: [PATCH 070/108] fixed the syspath issue --- CHANGELOG.md | 1 + docs/01-config.md | 32 ++++++++++---------- docs/03-filters.md | 8 ++--- organize/cli.py | 46 ---------------------------- organize/config.py | 4 ++- organize/console.py | 4 +-- organize/core.py | 61 +++++++++++++++++++++++++++++++------- organize/filters/filter.py | 1 + organize/filters/name.py | 14 ++++++++- organize/filters/python.py | 7 +++-- organize/filters/regex.py | 6 ++-- organize/filters/size.py | 2 +- organize/utils.py | 32 +++++++++++++++++++- testconf.yaml | 18 +++++++++-- 14 files changed, 147 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e89c14..662a75c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Please backup all your important stuff before running and use the simulate optio - Jinja2 template engine for placeholders. - Instant start. (does not need to gather all the files before starting) - Filters can now be excluded. +- Filter modes: `all`, `any` and `none`. - Nice terminal output. - Rule names. - new conflict resolution settings in `move`, `copy` and `rename` action: diff --git a/docs/01-config.md b/docs/01-config.md index 57877ced..ba78b774 100644 --- a/docs/01-config.md +++ b/docs/01-config.md @@ -2,36 +2,36 @@ ## Editing the configuration -All configuration takes place in your `config.yaml` file. +organize has a default config file if no other file is given. -To edit your configuration in `$EDITOR` run: +To edit the default configuration file: -```bash -$ organize config # example: "EDITOR=vim organize config" -``` - -To show the full path to your configuration file: - -```bash -$ organize config --path +```sh +$ organize edit # opens in $EDITOR +$ organize edit --editor=vim +$ EDITOR=code organize edit ``` To open the folder containing the configuration file: -```bash -$ organize config --open-folder +```sh +$ organize reveal +$ organize reveal --path # show the full path to the default config ``` To debug your configuration run: -```bash -$ organize config --debug +```sh +$ organize check ``` +## Configuration basics + ## Environment variables -- `$EDITOR` - The editor used to edit the config file. -- `$ORGANIZE_CONFIG` - The config file path. Is overridden by `--config-file` cmd line argument. +- `EDITOR` - The editor used to edit the config file. +- `ORGANIZE_CONFIG` - The path to the default config file. +- `NO_COLOR` - if this is set, the output is not colored. ## Rule syntax diff --git a/docs/03-filters.md b/docs/03-filters.md index 51d4154d..a71106c2 100644 --- a/docs/03-filters.md +++ b/docs/03-filters.md @@ -362,11 +362,11 @@ rules: filters: - name: startswith: - - A - - B + - "A" + - "B" contains: - - 5 - - 6 + - "5" + - "6" endswith: _end case_sensitive: false actions: diff --git a/organize/cli.py b/organize/cli.py index e18699b3..aee51803 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -197,49 +197,3 @@ def config(ctx, path, debug, open_folder): if __name__ == "__main__": cli() - - -# def config_debug(config_path: Path) -> None: -# """prints the config with resolved yaml aliases, checks rules syntax and checks -# whether the given folders exist -# """ -# print(str(config_path)) -# haserr = False -# # check config syntax -# try: -# print(Style.BRIGHT + "Your configuration as seen by the parser:") -# config = Config.from_file(config_path) -# if not config.config: -# print_error("Config file is empty") -# return -# print(config.yaml()) -# rules = config.rules -# print("Config file syntax seems fine!") -# except Config.Error as e: -# haserr = True -# print_error(e) -# else: -# # check whether all folders exists: -# allfolders = set(flatten([rule.folders for rule in rules])) -# for f in allfolders: -# if not fullpath(f).exists(): -# haserr = True -# print(Fore.YELLOW + 'Warning: "%s" does not exist!' % f) - -# if not haserr: -# print(Fore.GREEN + Style.BRIGHT + "No config problems found.") - - -# def list_actions_and_filters() -> None: -# """Prints a list of available actions and filters""" -# import inspect # pylint: disable=import-outside-toplevel - -# from organize import actions, filters # pylint: disable=import-outside-toplevel - -# print(Style.BRIGHT + "Filters:") -# for name, _ in inspect.getmembers(filters, inspect.isclass): -# print(" " + name) -# print() -# print(Style.BRIGHT + "Actions:") -# for name, _ in inspect.getmembers(actions, inspect.isclass): -# print(" " + name) diff --git a/organize/config.py b/organize/config.py index 114f9ba1..ba54a7ef 100644 --- a/organize/config.py +++ b/organize/config.py @@ -13,7 +13,9 @@ Optional("name", description="The name of the rule."): str, Optional("enabled"): bool, Optional("subfolders"): bool, - Optional("filter_mode"): Or("all", "any", "none"), + Optional("filter_mode", description="The filter mode."): Or( + "all", "any", "none", error='Invalid filter mode' + ), Optional( "targets", description="Whether the rule should apply to directories or folders.", diff --git a/organize/console.py b/organize/console.py index 44db7c91..4b27bd3d 100644 --- a/organize/console.py +++ b/organize/console.py @@ -16,7 +16,7 @@ { "info": "dim cyan", "warning": "yellow", - "error": "red", + "error": "bold red", "simulation": "bold green", "status": "bold green", "rule": "bold cyan", @@ -111,7 +111,7 @@ def deprecated(msg): def error(msg, title="Error"): - console.print("[error][b]{}:[/b] {}[/error]".format(title, msg)) + console.print("[error]{}: {}[/error]".format(title, msg)) def simulation_banner(): diff --git a/organize/core.py b/organize/core.py index c7f78062..c41526bc 100644 --- a/organize/core.py +++ b/organize/core.py @@ -1,10 +1,11 @@ import logging import os -from collections import Counter +from collections import Counter, defaultdict from datetime import datetime from typing import Iterable, NamedTuple import fs +from fs.errors import NoSysPath from fs.base import FS from fs.walk import Walker from rich.console import Console @@ -16,7 +17,14 @@ from .config import CONFIG_SCHEMA, load_from_file from .filters import FILTERS from .filters.filter import Filter -from .utils import Template, deep_merge_inplace, ensure_list, ensure_dict, to_args +from .utils import ( + Template, + deep_merge_inplace, + ensure_list, + ensure_dict, + to_args, + flatten_all_lists_in_dict, +) logger = logging.getLogger(__name__) highlighted_console = Console() @@ -42,6 +50,19 @@ class Location(NamedTuple): ] +def config_cleanup(rules): + result = defaultdict(list) + + # delete every root key except "rules" + for rule in rules.get("rules", []): + # delete disabled rules + if rule.get("enabled", True): + result["rules"].append(rule) + + # flatten all lists everywhere + return flatten_all_lists_in_dict(dict(result)) + + def walker_args_from_location_options(options): # combine system_exclude and exclude into a single list excludes = options.get("system_exlude_files", DEFAULT_SYSTEM_EXCLUDE_FILES) @@ -110,12 +131,16 @@ def instantiate_action(action_config): return ACTIONS[name](*args, **kwargs) +def syspath_or_exception(fs, path): + try: + return fs.getsyspath(path) + except NoSysPath as e: + return e + + def replace_with_instances(config): warnings = [] - # delete disabled rules - config["rules"] = [rule for rule in config["rules"] if rule.get("enabled", True)] - for rule in config["rules"]: _locations = [] default_depth = None if rule.get("subfolders", False) else 0 @@ -153,28 +178,39 @@ def replace_with_instances(config): return warnings -def filter_pipeline(filters: Iterable[Filter], args: dict) -> bool: +def filter_pipeline(filters: Iterable[Filter], args: dict, filter_mode: str) -> bool: """ run the filter pipeline. Returns True on a match, False otherwise and updates `args` in the process. """ + results = [] for filter_ in filters: try: match, updates = filter_.pipeline(args) - if not (match ^ filter_.inverted): + result = match ^ filter_.inverted + # we cannot exit early on "any". + if (filter_mode == "none" and result) or ( + filter_mode == "all" and not result + ): return False + results.append(result) deep_merge_inplace(args, updates) except Exception as e: # pylint: disable=broad-except logger.exception(e) # console.print_exception() filter_.print_error(str(e)) return False + + if filter_mode == "any": + return any(results) return True def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bool: for action in actions: try: + # update path + args["path"] = syspath_or_exception(args["fs"], args["fs_path"]) updates = action.pipeline(args, simulate=simulate) # jobs may return a dict with updates that should be merged into args if updates is not None: @@ -196,6 +232,7 @@ def run(config, simulate: bool = True): for rule_nr, rule in enumerate(config["rules"], start=1): target = rule.get("targets", "files") console.rule(rule.get("name", "Rule %s" % rule_nr)) + filter_mode = rule.get("filter_mode", "all") for walker, base_fs, base_path in rule["locations"]: console.location(base_fs, base_path) @@ -210,11 +247,12 @@ def run(config, simulate: bool = True): "env": os.environ, "now": datetime.now(), "utcnow": datetime.utcnow(), - "path": lambda: base_fs.getsyspath(path), + "path": syspath_or_exception(base_fs, path), } match = filter_pipeline( filters=rule["filters"], args=args, + filter_mode=filter_mode, ) if match: is_success = action_pipeline( @@ -237,6 +275,7 @@ def run_file(config_file: str, working_dir: str, simulate: bool): console.info(config_file, working_dir) try: rules = load_from_file(config_file) + rules = config_cleanup(rules) CONFIG_SCHEMA.validate(rules) warnings = replace_with_instances(rules) for msg in warnings: @@ -245,7 +284,9 @@ def run_file(config_file: str, working_dir: str, simulate: bool): count = run(rules, simulate=simulate) console.summary(count) except SchemaError as e: - console.error("Invalid config file") - highlighted_console.print(e.autos[-1]) + console.error("Invalid config file!") + for err in e.autos: + if err: + highlighted_console.print(err) except Exception as e: highlighted_console.print_exception() diff --git a/organize/filters/filter.py b/organize/filters/filter.py index c0932a10..51f8d02a 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -24,6 +24,7 @@ def get_name(cls): def get_name_schema(cls): return Schema( Or("not " + cls.get_name(), cls.get_name()), + name=cls.get_name(), description=cls.get_description(), ) diff --git a/organize/filters/name.py b/organize/filters/name.py index 271a3df7..b6cf6372 100644 --- a/organize/filters/name.py +++ b/organize/filters/name.py @@ -1,4 +1,5 @@ -from typing import Any, List, Union, Optional, Dict +from schema import Or, Optional +from typing import Any, List, Union, Dict import simplematch from fs import path @@ -30,6 +31,17 @@ class Name(Filter): name = "name" schema_support_instance_without_args = True + arg_schema = Or( + str, + { + Optional("match"): str, + Optional("startswith"): Or(str, [str]), + Optional("contains"): Or(str, [str]), + Optional("endswith"): Or(str, [str]), + Optional("case_sensitive"): bool, + }, + ) + def __init__( self, match="*", diff --git a/organize/filters/python.py b/organize/filters/python.py index b4e4f492..f5b39620 100644 --- a/organize/filters/python.py +++ b/organize/filters/python.py @@ -1,5 +1,6 @@ import textwrap -from typing import Any, Dict, Optional, Sequence +from schema import Or +from typing import Any, Optional as tyOpt, Sequence from .filter import Filter, FilterResult @@ -25,12 +26,14 @@ class Python(Filter): name = "python" + arg_schema = Or(str, {"code": str}) + def __init__(self, code) -> None: self.code = textwrap.dedent(code) if "return" not in self.code: raise ValueError("No return statement found in your code!") - def usercode(self, *args, **kwargs) -> Optional[Any]: + def usercode(self, *args, **kwargs) -> tyOpt[Any]: pass # will be overwritten by `create_method` def create_method(self, name: str, argnames: Sequence[str], code: str) -> None: diff --git a/organize/filters/regex.py b/organize/filters/regex.py index 89e9d8c8..f78af17b 100644 --- a/organize/filters/regex.py +++ b/organize/filters/regex.py @@ -1,6 +1,4 @@ import re -from typing import Any, Dict, Mapping, Optional - from .filter import Filter, FilterResult @@ -22,10 +20,12 @@ class Regex(Filter): name = "regex" + arg_schema = str + def __init__(self, expr) -> None: self.expr = re.compile(expr, flags=re.UNICODE) - def matches(self, path: str) -> Any: + def matches(self, path: str): return self.expr.search(path) def pipeline(self, args: dict) -> FilterResult: diff --git a/organize/filters/size.py b/organize/filters/size.py index 8c64d662..7fb9e82c 100644 --- a/organize/filters/size.py +++ b/organize/filters/size.py @@ -84,7 +84,7 @@ class Size(Filter): """ name = "size" - arg_schema = Optional(Or(str, [str], int, [int])) + arg_schema = Or(object, [object]) schema_support_instance_without_args = True def __init__(self, *conditions: Sequence[str]) -> None: diff --git a/organize/utils.py b/organize/utils.py index c3f12e93..744d160c 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -9,16 +9,25 @@ import jinja2 from jinja2 import nativetypes + +def raise_exceptions(x): + if isinstance(x, Exception): + raise x + return x + + Template = jinja2.Environment( variable_start_string="{", variable_end_string="}", autoescape=False, + finalize=raise_exceptions, ) NativeTemplate = nativetypes.NativeEnvironment( variable_start_string="{", variable_end_string="}", autoescape=False, + finalize=raise_exceptions, ) @@ -66,16 +75,22 @@ def ensure_dict(inp): def to_args(inp): """Convert a argument into a (args, kwargs) tuple. + >>> to_args(None) + ([], {}) >>> to_args('test') (['test'], {}) >>> to_args([1, 2, 3]) ([1, 2, 3], {}) >>> to_args({'a': {'b': 'c'}}) ([], {'a': {'b': 'c'}}) + >>> to_args([[1, 2, [3, 4], [5, 6]]]) + ([1, 2, 3, 4, 5, 6], {}) """ + if inp is None: + return ([], {}) if isinstance(inp, dict): return ([], inp) - return (ensure_list(inp), {}) + return (flatten(ensure_list(inp)), {}) def flatten(arr: List[Any]) -> List[Any]: @@ -97,6 +112,21 @@ def first_key(dic: Mapping) -> Hashable: return list(dic.keys())[0] +def flatten_all_lists_in_dict(obj): + """ + >>> flatten_all_lists_in_dict({1: [[2], [3, {5: [5, 6]}]]}) + {1: [2, 3, {5: [5, 6]}]} + """ + if isinstance(obj, dict): + for key, value in obj.items(): + obj[key] = flatten_all_lists_in_dict(value) + return obj + elif isinstance(obj, list): + return [flatten_all_lists_in_dict(x) for x in flatten(obj)] + else: + return obj + + def deep_merge(a: dict, b: dict) -> dict: result = deepcopy(a) for bk, bv in b.items(): diff --git a/testconf.yaml b/testconf.yaml index a3516494..0cde8c3f 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -20,15 +20,29 @@ rules: - confirm - echo: "{name}" + - name: Zip syspath + locations: + - filesystem: "zip:///Users/thomasfeldmann/Documents/Nachrichten Backup 2016-11-04.zip" + path: "/" + filters: + - name + actions: + - echo: "{name}" + - name: "Folders" + enabled: false targets: dirs locations: - path: ~/Desktop + filter_mode: nones filters: - not name: - startswith: Inbox + startswith2: Inbox + - size + - name: + startswith: Projekt actions: - - echo: "{name}" + - echo: "{name} {size.decimal}" # - name: Find some folders # targets: dirs From 47cf60172fa77f0bb29dad024900b34b395cc2b2 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 31 Jan 2022 18:37:59 +0100 Subject: [PATCH 071/108] support multiple line print --- organize/actions/action.py | 8 +++++--- organize/core.py | 2 +- organize/filters/filecontent.py | 5 +++-- organize/filters/filter.py | 6 ++++-- organize/filters/hash.py | 1 + testconf.yaml | 8 ++++---- 6 files changed, 18 insertions(+), 12 deletions(-) diff --git a/organize/actions/action.py b/organize/actions/action.py index 6ca90528..ca1db8e3 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -11,7 +11,7 @@ class Error(Exception): class Action: - name = None # type: Union[str, None] + name = None # type: Union[str, None] arg_schema = None schema_support_instance_without_args = False @@ -50,10 +50,12 @@ def pipeline(self, args: dict, simulate: bool) -> tyOptional[Dict[str, Any]]: def print(self, msg) -> None: """print a message for the user""" - pipeline_message(source=self.get_name(), msg=msg) + for line in msg.splitlines(): + pipeline_message(source=self.get_name(), msg=line) def print_error(self, msg: str): - pipeline_error(source=self.get_name(), msg=msg) + for line in msg.splitlines(): + pipeline_error(source=self.get_name(), msg=line) def __str__(self) -> str: return self.__class__.__name__ diff --git a/organize/core.py b/organize/core.py index c41526bc..978fb583 100644 --- a/organize/core.py +++ b/organize/core.py @@ -286,7 +286,7 @@ def run_file(config_file: str, working_dir: str, simulate: bool): except SchemaError as e: console.error("Invalid config file!") for err in e.autos: - if err: + if err and len(err) < 200: highlighted_console.print(err) except Exception as e: highlighted_console.print_exception() diff --git a/organize/filters/filecontent.py b/organize/filters/filecontent.py index ad88b648..9742e367 100644 --- a/organize/filters/filecontent.py +++ b/organize/filters/filecontent.py @@ -28,8 +28,9 @@ class FileContent(Filter): """ name = "filecontent" + schema_support_instance_without_args = True - def __init__(self, expr) -> None: + def __init__(self, expr="(?P.*)") -> None: self.expr = re.compile(expr, re.MULTILINE | re.DOTALL) def matches(self, path: str, extension: str) -> Any: @@ -66,6 +67,6 @@ def pipeline(self, args: dict) -> FilterResult: ) from e match = self.matches(path=syspath, extension=extension) return FilterResult( - matches=match, + matches=bool(match), updates={self.get_name(): match.groupdict()}, ) diff --git a/organize/filters/filter.py b/organize/filters/filter.py index 51f8d02a..5cfe1a92 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -60,10 +60,12 @@ def pipeline(self, args: dict) -> FilterResult: def print(self, msg: str) -> None: """print a message for the user""" - pipeline_message(self.get_name(), msg) + for line in msg.splitlines(): + pipeline_message(self.get_name(), line) def print_error(self, msg: str): - pipeline_error(self.get_name(), msg) + for line in msg.splitlines(msg): + pipeline_error(self.get_name(), line) def set_logic(self, inverted=False): self.inverted = inverted diff --git a/organize/filters/hash.py b/organize/filters/hash.py index 11b5bcd4..e5a0a6e4 100644 --- a/organize/filters/hash.py +++ b/organize/filters/hash.py @@ -39,6 +39,7 @@ class Hash(Filter): """ name = "hash" + schema_support_instance_without_args = True def __init__(self, algorithm="md5"): self.algorithm = Template.from_string(algorithm) diff --git a/testconf.yaml b/testconf.yaml index 0cde8c3f..a3e7f6c5 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -22,12 +22,12 @@ rules: - name: Zip syspath locations: - - filesystem: "zip:///Users/thomasfeldmann/Documents/Nachrichten Backup 2016-11-04.zip" - path: "/" + - path: ~/Desktop filters: - - name + - extension: pdf + - filecontent actions: - - echo: "{name}" + - echo: "{filecontent.all[:100]}" - name: "Folders" enabled: false From 776f5e69483ba897e0b5ab5577cb516a05191815 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 1 Feb 2022 12:14:24 +0100 Subject: [PATCH 072/108] rename docs --- README.md | 2 +- docs/01-config.md | 296 ------------------ docs/02-locations.md | 1 - docs/{04-actions.md => actions.md} | 2 +- docs/{05-changelog.md => changelog.md} | 0 docs/configuration.md | 55 ++++ docs/{03-filters.md => filters.md} | 2 +- docs/index.md | 2 +- docs/locations.md | 82 +++++ docs/rules.md | 139 ++++++++ ...pdating-from-v1.md => updating-from-v1.md} | 0 mkdocs.yml | 14 +- organize/config.py | 1 - 13 files changed, 288 insertions(+), 308 deletions(-) delete mode 100644 docs/01-config.md delete mode 100644 docs/02-locations.md rename docs/{04-actions.md => actions.md} (99%) rename docs/{05-changelog.md => changelog.md} (100%) create mode 100644 docs/configuration.md rename docs/{03-filters.md => filters.md} (99%) create mode 100644 docs/locations.md create mode 100644 docs/rules.md rename docs/{06-updating-from-v1.md => updating-from-v1.md} (100%) diff --git a/README.md b/README.md index f164c729..75cffd4e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- organize logo + organize logo

diff --git a/docs/01-config.md b/docs/01-config.md deleted file mode 100644 index ba78b774..00000000 --- a/docs/01-config.md +++ /dev/null @@ -1,296 +0,0 @@ -# Configuration - -## Editing the configuration - -organize has a default config file if no other file is given. - -To edit the default configuration file: - -```sh -$ organize edit # opens in $EDITOR -$ organize edit --editor=vim -$ EDITOR=code organize edit -``` - -To open the folder containing the configuration file: - -```sh -$ organize reveal -$ organize reveal --path # show the full path to the default config -``` - -To debug your configuration run: - -```sh -$ organize check -``` - -## Configuration basics - -## Environment variables - -- `EDITOR` - The editor used to edit the config file. -- `ORGANIZE_CONFIG` - The path to the default config file. -- `NO_COLOR` - if this is set, the output is not colored. - -## Rule syntax - -The rule configuration is done in [YAML](https://learnxinyminutes.com/docs/yaml/). -You need a top-level element `rules` which contains a list of rules. -Each rule defines `folders`, `filters` (optional) and `actions`. - -```yaml -rules: -- folders: - - ~/Desktop - - /some/folder/ - filters: - - lastmodified: - days: 40 - mode: newer - extension: pdf - actions: - - move: ~/Desktop/Target/ - trash - - - folders: - - ~/Inbox - filters: - - extension: pdf - actions: - - move: ~/otherinbox -``` - -- `folders` is a list of folders you want to organize. -- `filters` is a list of filters to apply to the files - you can filter by file extension, last modified date, regular expressions and many more. See :ref:`Filters`. -- `actions` is a list of actions to apply to the filtered files. You can put them into the trash, move them into another folder and many more. See :ref:`Actions`. - -Other optional per rule settings: - -- `enabled` can be used to temporarily disable single rules. Default = true -- `subfolders` specifies whether subfolders should be included in the search. Default = false. This setting only applies to folders without glob wildcards. -- `system_files` specifies whether to include system files (desktop.ini, thumbs.db, .DS_Store) in the search. Default = false - -## Folder syntax - -Every rule in your configuration file needs to know the folders it applies to. -The easiest way is to define the rules like this: - -.. code-block:: yaml -:caption: config.yaml - -rules: - folders: - /path/one - /path/two -filters: ... -actions: ... - - - folders: - - /path/one - - /another/path - filters: ... - actions: ... - -.. note:: - -- You can use environment variables in your folder names. On windows this means you can use `%public%/Desktop`, `%APPDATA%`, `%PROGRAMDATA%` etc. - -### Globstrings - -You can use globstrings in the folder lists. For example to get all files with filenames ending with `_ui` and any file extension you can use: - -.. code-block:: yaml -:caption: config.yaml - -rules: - folders: - '~/Downloads/_\_ui._' -actions: - echo: '{path}' - -You can use globstrings to recurse through subdirectories (alternatively you can use the `subfolders: true` setting as shown below) - -.. code-block:: yaml -:caption: config.yaml - -rules: - folders: - '~/Downloads/\*_/_.\*' -actions: - echo: 'base {basedir}, path {path}, relative: {relative_path}' - - # alternative syntax - - folders: - - ~/Downloads - subfolders: true - actions: - - echo: 'base {basedir}, path {path}, relative: {relative_path}' - -The following example recurses through all subdirectories in your downloads folder and finds files with ending in `.c` and `.h`. - -.. code-block:: yaml -:caption: config.yaml - -rules: - folders: - '~/Downloads/\*_/_.[c|h]' -actions: - echo: '{path}' - -.. note:: - -- You have to target files with the globstring, not folders. So to scan through all folders starting with \_log\__ you would write `yourpath/log\_\_/_` - -### Excluding files and folders - -Files and folders can be excluded by prepending an exclamation mark. The following example selects all files -in `~/Downloads` and its subfolders - excluding the folder `Software`: - -.. code-block:: yaml -:caption: config.yaml - -rules: - folders: - '~/Downloads/\*_/_' - '! ~/Downloads/Software' -actions: - echo: '{path}' - -Globstrings can be used to exclude only specific files / folders. This example: - -- adds all files in `~/Downloads` -- exludes files from that list whose name contains the word `system` ending in `.bak` -- adds all files from `~/Documents` -- excludes the file `~/Documents/important.txt`. - -.. code-block:: yaml -:caption: config.yaml - -rules: - folders: - '~/Downloads/**/\*' - '! ~/Downloads/**/_system_.bak' - '~/Documents' - '! ~/Documents/important.txt' -actions: - echo: '{path}' - -.. note:: - -- Files and folders are included and excluded in the order you specify them! -- Please make sure your are putting the exclamation mark within quotation marks. - -### Aliases - -Instead of repeating the same folders in each and every rule you can use an alias for multiple folders which you can then reference in each rule. -Aliases are a standard feature of the YAML syntax. - -.. code-block:: yaml -:caption: config.yaml - -all_my_messy_folders: &all - ~/Desktop - ~/Downloads - ~/Documents - ~/Dropbox - -rules: - folders: \*all -filters: ... -actions: ... - - - folders: *all - filters: ... - actions: ... - -You can even use multiple folder lists: - -.. code-block:: yaml -:caption: config.yaml - -private_folders: &private - '/path/private' - '~/path/private' - -work_folders: &work - '/path/work' - '~/My work folder' - -all_folders: &all - *private - *work - -rules: - folders: \*private -filters: ... -actions: ... - - - folders: *work - filters: ... - actions: ... - - - folders: *all - filters: ... - actions: ... - - # same as *all - - folders: - - *work - - *private - filters: ... - actions: ... - -## Filter syntax - -`filters` is a list of :ref:`Filters`. -Filters are defined like this: - -.. code-block:: yaml -:caption: config.yaml - -rules: - folders: ... -actions: ... -filters: # filter without parameters - FilterName - - # filter with a single parameter - - FilterName: parameter - - # filter expecting a list as parameter - - FilterName: - - first - - second - - third - - # filter with multiple parameters - - FilterName: - parameter1: true - option2: 10.51 - third_argument: test string - -.. note:: -Every filter comes with multiple usage examples which should be easy to adapt for your use case! - -## Action syntax - -`actions` is a list of :ref:`Actions`. -Actions can be defined like this: - -.. code-block:: yaml -:caption: config.yaml - -rules: - folders: ... -actions: # action without parameters - ActionName - - # action with a single parameter - - ActionName: parameter - - # filter with multiple parameters - - ActionName: - parameter1: true - option2: 10.51 - third_argument: test string - -.. note:: -Every action comes with multiple usage examples which should be easy to adapt for your use case! - -### Variable substitution (placeholders) - -**You can use placeholder variables in your actions.** - -Placeholder variables are used with curly braces `{var}`. -You always have access to the variables `{path}`, `{basedir}` and `{relative_path}`: - -- `{path}` -- is the full path to the current file -- `{basedir}` -- the current base folder (the base folder is the folder you - specify in your configuration). -- `{relative_path}` -- the relative path from `{basedir}` to `{path}` - -Use the dot notation to access properties of `{path}`, `{basedir}` and `{relative_path}`: - -- `{path}` -- the full path to the current file -- `{path.name}` -- the full filename including extension -- `{path.stem}` -- just the file name without extension -- `{path.suffix}` -- the file extension -- `{path.parent}` -- the parent folder of the current file -- `{path.parent.parent}` -- parent calls are chainable... - -- `{basedir}` -- the full path to the current base folder -- `{basedir.parent}` -- the full path to the base folder's parent - -and any other property of the python `pathlib.Path` (`official documentation `\_) object. - -Additionally :ref:`Filters` may emit placeholder variables when applied to a -path. Check the documentation and examples of the filter to see available -placeholder variables and usage examples. - -Some examples include: - -- `{lastmodified.year}` -- the year the file was last modified -- `{regex.yournamedgroup}` -- anything you can extract via regular expressions -- `{extension.upper}` -- the file extension in uppercase -- ... and many more. diff --git a/docs/02-locations.md b/docs/02-locations.md deleted file mode 100644 index b7a7f0de..00000000 --- a/docs/02-locations.md +++ /dev/null @@ -1 +0,0 @@ -# Locations diff --git a/docs/04-actions.md b/docs/actions.md similarity index 99% rename from docs/04-actions.md rename to docs/actions.md index e3ab74f6..125f814a 100644 --- a/docs/04-actions.md +++ b/docs/actions.md @@ -22,7 +22,7 @@ rules: - duplicate - name actions: - - confirm: "Delete {name}?" + - confirm: "Delete {duplicate}?" - trash ``` diff --git a/docs/05-changelog.md b/docs/changelog.md similarity index 100% rename from docs/05-changelog.md rename to docs/changelog.md diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..b20ccc88 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,55 @@ +# Configuration + +## Editing the configuration + +organize has a default config file if no other file is given. + +To edit the default configuration file: + +```sh +$ organize edit # opens in $EDITOR +$ organize edit --editor=vim +$ EDITOR=code organize edit +``` + +To open the folder containing the configuration file: + +```sh +$ organize reveal +$ organize reveal --path # show the full path to the default config +``` + +To check your configuration run: + +```sh +$ organize check +$ organize check --debug # check with debug output +``` + +## Running and simulating + +To run / simulate the default config file: + +```sh +$ organize sim +$ organize run +``` + +To run / simulate a specific config file: + +```sh +$ organize sim [FILE] +$ organize run [FILE] +``` + +## Environment variables + +- `ORGANIZE_CONFIG` - The path to the default config file. +- `NO_COLOR` - if this is set, the output is not colored. +- `EDITOR` - The editor used to edit the config file. + +## Command line interface + +::: mkdocs-click + :module: organize.cli + :command: organize diff --git a/docs/03-filters.md b/docs/filters.md similarity index 99% rename from docs/03-filters.md rename to docs/filters.md index a71106c2..1484c290 100644 --- a/docs/03-filters.md +++ b/docs/filters.md @@ -1,7 +1,7 @@ # Filters This page shows the specifics of each filter. For basic filter usage and options have a -look at the [Config](01-config.md) section. +look at the [Configuration](00-configuration.md) section. ## created diff --git a/docs/index.md b/docs/index.md index d064e031..8dc9557c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,4 @@ -# Welcome to the documentation for organize +# Welcome to organize's documentation {% include-markdown "../README.md" diff --git a/docs/locations.md b/docs/locations.md new file mode 100644 index 00000000..8811dc4f --- /dev/null +++ b/docs/locations.md @@ -0,0 +1,82 @@ +# Locations + +**Locations** are the folders in which organize searches for resources. +You can set multiple locations for each rule if you want. + +A minimum location definition is just a path where to look for files / folders: + +```yml +rules: + - name: "Single location" + locations: ~/Desktop + actions: ... +``` + +If you want to handle multiple locations in a rule, create a list: + +```yaml +rules: + - name: "Location list" + locations: + - ~/Desktop + - /usr/bin/ + - "%PROGRAMDATA%/test" + actions: ... +``` + +Using options: + +```yaml +rules: + - name: "Location list" + locations: + - path: "~/Desktop" + max_depth: 3 + actions: ... +``` + +Note that you can use environment variables in your locations. + +## Location options + +```yaml +rules: + - locations: + path: ... + max_depth: ... + search: ... + exclude_files: ... + exclude_dirs: ... + system_exlude_files: ... + system_exclude_dirs: ... + ignore_errors: ... + filter: ... + filter_dirs: ... + filesystem: ... +``` + +- **path** (`str`): +- **max_depth** (`int` or `null`): +- **search** (`str`): "depth", "breadth")): +- **exclude_files** (list of `str`): +- **exclude_dirs** (list of `str`): +- **system_exlude_files** (list of `str`): +- **system_exclude_dirs** (list of `str`): +- **ignore_errors** (`bool`): +- **filter** (list of `str`): +- **filter_dirs** (list of `str`): +- **filesystem** (str): + +### `filesystem` and `path` + + + +## Relative locations + +## Filesystems + + actions: ... + +.. note:: + +- You can use environment variables in your folder names. On windows this means you can use `%public%/Desktop`, `%APPDATA%`, `%PROGRAMDATA%` etc. diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 00000000..c4d7f06c --- /dev/null +++ b/docs/rules.md @@ -0,0 +1,139 @@ +# Rules + +A organize config file can be written in [YAML](https://learnxinyminutes.com/docs/yaml/) +or [JSON](https://learnxinyminutes.com/docs/json/). See [configuration](00-configuration.md) +on how to locate your config file. + +The top level element must be a dict with a key "rules". +"rules" contains a list of objects with the required keys "locations" and "actions". + +A minimum config: + +```yaml +rules: + - locations: "~/some/location" + actions: + - echo: "Hello World!" +``` + +Organize checks your rules from top to bottom. For every resource in each location (top to bottom) +it will check whether the filters apply (top to bottom) and then execute the given actions (top to bottom). + +So with this minimal configuration it will print "Hello World!" for each file it finds in `"~/some/location"`. + +## Rule options + +```yaml +rules: + # First rule + - name: ... + enabled: ... + targets: ... + locations: ... + subfolders: ... + filter_mode: ... + filters: ... + actions: ... + + # Another rule + - name: ... + enabled: ... + # ... and so on +``` + +The rule options in detail: + +- **name** (`str`): The rule name +- **enabled** (`bool`): Whether the rule is enabled / disabled _(Default: `true`)_ +- **targets** (`str`): `"dirs"` or `"files"` _(Default: `"files"`)_ +- **locations** (`str`|`list`) - A single location string or list of [locations](02-locations.md) +- **subfolders** (`bool`): Whether to recurse into subfolders of all locations _(Default: `false`)_ +- **filter_mode** (`str`): `"all"`, `"any"` or `"none"` of the filters must apply _(Default: `"all"`)_ +- **filters** (`list`): A list of [filters](03-filters.md) _(Default: `[]`)_ +- **actions** (`list`): A list of [actions](04-actions.md) + +## Templates and placeholders + +**You can use placeholder variables in your actions.** + +Placeholder variables are used with curly braces `{var}`. +You always have access to the variables `{path}`, `{basedir}` and `{relative_path}`: + +- `{path}` -- is the full path to the current file +- `{basedir}` -- the current base folder (the base folder is the folder you + specify in your configuration). +- `{relative_path}` -- the relative path from `{basedir}` to `{path}` + +Use the dot notation to access properties of `{path}`, `{basedir}` and `{relative_path}`: + +- `{path}` -- the full path to the current file +- `{path.name}` -- the full filename including extension +- `{path.stem}` -- just the file name without extension +- `{path.suffix}` -- the file extension +- `{path.parent}` -- the parent folder of the current file +- `{path.parent.parent}` -- parent calls are chainable... + +- `{basedir}` -- the full path to the current base folder +- `{basedir.parent}` -- the full path to the base folder's parent + +and any other property of the python `pathlib.Path` (`official documentation `\_) object. + +Additionally :ref:`Filters` may emit placeholder variables when applied to a +path. Check the documentation and examples of the filter to see available +placeholder variables and usage examples. + +Some examples include: + +- `{lastmodified.year}` -- the year the file was last modified +- `{regex.yournamedgroup}` -- anything you can extract via regular expressions +- `{extension.upper}` -- the file extension in uppercase +- ... and many more. + + +## Advanced: Aliases + +Instead of repeating the same folders in each and every rule you can use an alias for multiple folders which you can then reference in each rule. +Aliases are a standard feature of the YAML syntax. + +.. code-block:: yaml +:caption: config.yaml + +all_my_messy_folders: &all - ~/Desktop - ~/Downloads - ~/Documents - ~/Dropbox + +rules: - folders: \*all +filters: ... +actions: ... + + - folders: *all + filters: ... + actions: ... + +You can even use multiple folder lists: + +.. code-block:: yaml +:caption: config.yaml + +private_folders: &private - '/path/private' - '~/path/private' + +work_folders: &work - '/path/work' - '~/My work folder' + +all_folders: &all - *private - *work + +rules: - folders: \*private +filters: ... +actions: ... + + - folders: *work + filters: ... + actions: ... + + - folders: *all + filters: ... + actions: ... + + # same as *all + - folders: + - *work + - *private + filters: ... + actions: ... diff --git a/docs/06-updating-from-v1.md b/docs/updating-from-v1.md similarity index 100% rename from docs/06-updating-from-v1.md rename to docs/updating-from-v1.md diff --git a/mkdocs.yml b/mkdocs.yml index 05e7d44a..b710ee99 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,12 +3,13 @@ repo_url: https://github.com/tfeldmann/organize/ site_author: "Thomas Feldmann" nav: - Home: index.md - - Configuration: 01-config.md - - Locations: 02-locations.md - - Filters: 03-filters.md - - Actions: 04-actions.md - - Changelog: 05-changelog.md - - Updating from organize v1.x: 06-updating-from-v1.md + - Configuration: configuration.md + - Rules: rules.md + - Locations: locations.md + - Filters: filters.md + - Actions: actions.md + - Changelog: changelog.md + - Updating from organize v1.x: updating-from-v1.md plugins: - search - include-markdown @@ -31,6 +32,7 @@ plugins: markdown_extensions: - toc: permalink: "#" + - mkdocs-click theme: name: readthedocs diff --git a/organize/config.py b/organize/config.py index ba54a7ef..aac78351 100644 --- a/organize/config.py +++ b/organize/config.py @@ -47,7 +47,6 @@ "actions": [Optional(x.get_schema()) for x in ACTIONS.values()], }, ], - Optional("version"): int, }, name="organize rule configuration", ) From f97529890ddb7478de3b9bff1c4a9e08fbf0c342 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 1 Feb 2022 13:18:31 +0100 Subject: [PATCH 073/108] update docs --- docs/actions.md | 6 --- docs/configuration.md | 6 --- docs/locations.md | 100 +++++++++++++++++++++++++++++++----------- docs/rules.md | 8 +--- poetry.lock | 12 ++--- testconf.yaml | 9 ++-- 6 files changed, 88 insertions(+), 53 deletions(-) diff --git a/docs/actions.md b/docs/actions.md index 125f814a..3a08c70d 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -127,8 +127,6 @@ rules: Prints "Hello World!" and filepath for each file on the desktop: ```yaml -:caption: config.yaml - rules: - locations: - ~/Desktop @@ -139,8 +137,6 @@ rules: This will print something like `Found a PNG: "test.png"` for each file on your desktop ```yaml -:caption: config.yaml - rules: - locations: - ~/Desktop @@ -153,8 +149,6 @@ rules: Show the `{basedir}` and `{path}` of all files in '~/Downloads', '~/Desktop' and their subfolders: ```yaml -:caption: config.yaml - rules: - locations: - path: ~/Desktop diff --git a/docs/configuration.md b/docs/configuration.md index b20ccc88..0279157d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,9 +47,3 @@ $ organize run [FILE] - `ORGANIZE_CONFIG` - The path to the default config file. - `NO_COLOR` - if this is set, the output is not colored. - `EDITOR` - The editor used to edit the config file. - -## Command line interface - -::: mkdocs-click - :module: organize.cli - :command: organize diff --git a/docs/locations.md b/docs/locations.md index 8811dc4f..d519910d 100644 --- a/docs/locations.md +++ b/docs/locations.md @@ -7,8 +7,7 @@ A minimum location definition is just a path where to look for files / folders: ```yml rules: - - name: "Single location" - locations: ~/Desktop + - locations: ~/Desktop actions: ... ``` @@ -16,8 +15,7 @@ If you want to handle multiple locations in a rule, create a list: ```yaml rules: - - name: "Location list" - locations: + - locations: - ~/Desktop - /usr/bin/ - "%PROGRAMDATA%/test" @@ -42,34 +40,82 @@ Note that you can use environment variables in your locations. ```yaml rules: - locations: - path: ... - max_depth: ... - search: ... - exclude_files: ... - exclude_dirs: ... - system_exlude_files: ... - system_exclude_dirs: ... - ignore_errors: ... - filter: ... - filter_dirs: ... - filesystem: ... + - path: ... + max_depth: ... + search: ... + exclude_files: ... + exclude_dirs: ... + system_exlude_files: ... + system_exclude_dirs: ... + ignore_errors: ... + filter: ... + filter_dirs: ... + filesystem: ... ``` -- **path** (`str`): -- **max_depth** (`int` or `null`): -- **search** (`str`): "depth", "breadth")): -- **exclude_files** (list of `str`): -- **exclude_dirs** (list of `str`): -- **system_exlude_files** (list of `str`): -- **system_exclude_dirs** (list of `str`): -- **ignore_errors** (`bool`): -- **filter** (list of `str`): -- **filter_dirs** (list of `str`): -- **filesystem** (str): +**path** (`str`)
+Path to a local folder or a [Filesystem URL](#filesystems). + +**max_depth** (`int` or `null`)
+Maximum directory depth to search. + +**search** (`"breadth"` or `"depth"`)
+Whether to use breadth or depth search to recurse into subfolders. Note that if you +want to move or delete files from this location, this has to be set to `"depth"`. +_(Default: `"depth"`)_ + +**exclude_files** (`List[str]`)
+A list of filename patterns that should be excluded in this location, e.g. `["~*"]`. + +**exclude_dirs** (`List[str]`)
+A list of patterns that will filter be used to filter out directory names in this location. +e.g. `['do-not-move', '*-Important']` + +**system_exlude_files** (`List[str]`)
+The list of filename patterns that are excluded by default. Defaults to: +`["thumbs.db", "desktop.ini", "~$*", ".DS_Store", ".localized"]` + +**system_exclude_dirs** (`List[str]`)
+The list of dir names that are excluded by default (`['.git', '.svn']`) + +**ignore_errors** (`bool`)
+If `true`, any errors reading the location will be ignored. + +**filter** (`List[str]`)
+A list of filename patterns that should be used in this location, e.g. `["*.py"]`. +All other files are skipped. + +**filter_dirs** (`List[str]`)
+A list of patterns to match directory names that are included in this location. + All other directories are skipped. + +**filesystem** (str)
+A [Filesystem URL](#filesystems). ### `filesystem` and `path` +If you want the location to be the root (`"/"`) of a filesystem, use `path`: +```yaml +rules: + - locations: + - path: zip:///Users/theuser/Downloads/Test.zip +``` + +If you want the location to be a subfolder inside a filesystem, use `path` and `filesystem`: + +```yaml +rules: + - locations: + - filesystem: zip:///Users/theuser/Downloads/Test.zip + path: "/folder/in/the/zipfile/" +``` + +### `max_depth` and `subfolders` + +- If `subfolders: true` is specified on the rule, all locations are set to `max_depth: null` +by default. +- A `max_depth` setting in a location is given precedence over the rule's `subfolders` setting. ## Relative locations @@ -80,3 +126,5 @@ rules: .. note:: - You can use environment variables in your folder names. On windows this means you can use `%public%/Desktop`, `%APPDATA%`, `%PROGRAMDATA%` etc. + +[PyFilesystem URL](https://docs.pyfilesystem.org/en/latest/openers.html) diff --git a/docs/rules.md b/docs/rules.md index c4d7f06c..3bf267ce 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -11,7 +11,7 @@ A minimum config: ```yaml rules: - - locations: "~/some/location" + - locations: "~/Desktop" actions: - echo: "Hello World!" ``` @@ -19,7 +19,7 @@ rules: Organize checks your rules from top to bottom. For every resource in each location (top to bottom) it will check whether the filters apply (top to bottom) and then execute the given actions (top to bottom). -So with this minimal configuration it will print "Hello World!" for each file it finds in `"~/some/location"`. +So with this minimal configuration it will print "Hello World!" for each file it finds in your Desktop. ## Rule options @@ -96,8 +96,6 @@ Instead of repeating the same folders in each and every rule you can use an alia Aliases are a standard feature of the YAML syntax. .. code-block:: yaml -:caption: config.yaml - all_my_messy_folders: &all - ~/Desktop - ~/Downloads - ~/Documents - ~/Dropbox rules: - folders: \*all @@ -111,8 +109,6 @@ actions: ... You can even use multiple folder lists: .. code-block:: yaml -:caption: config.yaml - private_folders: &private - '/path/private' - '~/path/private' work_folders: &work - '/path/work' - '~/My work folder' diff --git a/poetry.lock b/poetry.lock index 266a9dc0..90f72fee 100644 --- a/poetry.lock +++ b/poetry.lock @@ -116,7 +116,7 @@ python-versions = "*" [[package]] name = "charset-normalizer" -version = "2.0.10" +version = "2.0.11" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "dev" optional = false @@ -858,7 +858,7 @@ python-versions = ">=3.6" [[package]] name = "types-pyyaml" -version = "6.0.3" +version = "6.0.4" description = "Typing stubs for PyYAML" category = "dev" optional = false @@ -1077,8 +1077,8 @@ chardet = [ {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.10.tar.gz", hash = "sha256:876d180e9d7432c5d1dfd4c5d26b72f099d503e8fcc0feb7532c9289be60fcbd"}, - {file = "charset_normalizer-2.0.10-py3-none-any.whl", hash = "sha256:cb957888737fc0bbcd78e3df769addb41fd1ff8cf950dc9e7ad7793f1bf44455"}, + {file = "charset-normalizer-2.0.11.tar.gz", hash = "sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c"}, + {file = "charset_normalizer-2.0.11-py3-none-any.whl", hash = "sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45"}, ] click = [ {file = "click-8.0.3-py3-none-any.whl", hash = "sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3"}, @@ -1584,8 +1584,8 @@ typed-ast = [ {file = "typed_ast-1.5.2.tar.gz", hash = "sha256:525a2d4088e70a9f75b08b3f87a51acc9cde640e19cc523c7e41aa355564ae27"}, ] types-pyyaml = [ - {file = "types-PyYAML-6.0.3.tar.gz", hash = "sha256:6ea4eefa8579e0ce022f785a62de2bcd647fad4a81df5cf946fd67e4b059920b"}, - {file = "types_PyYAML-6.0.3-py3-none-any.whl", hash = "sha256:8b50294b55a9db89498cdc5a65b1b4545112b6cd1cf4465bd693d828b0282a17"}, + {file = "types-PyYAML-6.0.4.tar.gz", hash = "sha256:6252f62d785e730e454dfa0c9f0fb99d8dae254c5c3c686903cf878ea27c04b7"}, + {file = "types_PyYAML-6.0.4-py3-none-any.whl", hash = "sha256:693b01c713464a6851f36ff41077f8adbc6e355eda929addfb4a97208aea9b4b"}, ] typing-extensions = [ {file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"}, diff --git a/testconf.yaml b/testconf.yaml index a3e7f6c5..7b5ea852 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -21,13 +21,16 @@ rules: - echo: "{name}" - name: Zip syspath + targets: dirs locations: - path: ~/Desktop + exclude_dirs: ["Inbox"] + max_depth: null filters: - - extension: pdf - - filecontent + - name: + startswith: "I" actions: - - echo: "{filecontent.all[:100]}" + - echo: "{path}" - name: "Folders" enabled: false From 32c5114a6b5baddaa128580d1ed87dca05d3ddf7 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 1 Feb 2022 14:50:53 +0100 Subject: [PATCH 074/108] update docs --- docs/locations.md | 86 +++++++++++++++++++++-- docs/rules.md | 140 +++++++++++++++++++++++-------------- organize/actions/copy.py | 13 ++-- organize/actions/move.py | 12 ++-- organize/actions/rename.py | 4 -- organize/core.py | 9 +-- testconf.yaml | 12 ++++ 7 files changed, 196 insertions(+), 80 deletions(-) diff --git a/docs/locations.md b/docs/locations.md index d519910d..9f807b77 100644 --- a/docs/locations.md +++ b/docs/locations.md @@ -87,7 +87,7 @@ All other files are skipped. **filter_dirs** (`List[str]`)
A list of patterns to match directory names that are included in this location. - All other directories are skipped. +All other directories are skipped. **filesystem** (str)
A [Filesystem URL](#filesystems). @@ -114,17 +114,89 @@ rules: ### `max_depth` and `subfolders` - If `subfolders: true` is specified on the rule, all locations are set to `max_depth: null` -by default. + by default. - A `max_depth` setting in a location is given precedence over the rule's `subfolders` setting. +## Remote filesystems and archives + +Locations in organize can include: + +- Folders on the harddrive +- ZIP archives +- TAR archives +- FTP servers +- S3 Buckets +- SSH and SMB connections +- IMAP servers +- WebDAV storages +- Dropbox / OneDrive / Google Drive storage (no need to install the client) +- Azure Datalake / Google Cloud Storage +- [and many more!](https://www.pyfilesystem.org/page/index-of-filesystems) + +You can uses these just like the local harddrive, move/copy files or folders between +them or organize them however you want. + +Filesystem URLs are formatted like this: + +```sh +://:@ + +# Examples: +ftp://ftp.example.org/pub +ftps://will:daffodil@ftp.example.org/private +zip://projects.zip +s3://mybucket +dropbox://dropbox.com?access_token= +ssh://[user[:password]@]host[:port] +``` + +The ZIP, TAR, FTP and AppFS filesystems are builtin. +For all other filesystems you need to +[install the appropriate library](https://www.pyfilesystem.org/page/index-of-filesystems). + +**FTP Example** + +Show the size of all JPGs on a remote FTP server and put them into a local ZIP file. + +```yaml +rules: + - locations: "ftps://demo:{env.FTP_PASSWORD}@demo.wftpserver.com" + subfolders: true + filters: + - size + - extension: jpg + actions: + - echo: "Found file! Size: {size.decimal}" + - copy: + dest: "{relative_path}" + filesystem: zip:///Users/thomas/Desktop/ftpfiles.zip +``` + +**Note:** + +You should never include a password in a config file. Better pass them in via an +environment variable (`{env.FTP_PASSWORD}`) as you can see above. + ## Relative locations -## Filesystems +Locations can be relative. This allows you to create simple one-off rules that can be +copied between projects. - actions: ... +There is a command line option to change the working directory should you need it. -.. note:: +```yaml +# huge-pic-warner.yaml +rules: + - locations: "docs" # here "docs" is relative to the current working dir + filters: + - extension: jpg + - size: ">3 MB" + actions: + - echo: "Warning - huge pic found!" +``` -- You can use environment variables in your folder names. On windows this means you can use `%public%/Desktop`, `%APPDATA%`, `%PROGRAMDATA%` etc. +Then run it with: -[PyFilesystem URL](https://docs.pyfilesystem.org/en/latest/openers.html) +```sh +organize sim huge-pic-warner.yaml --working-dir=some/other/dir/ +``` diff --git a/docs/rules.md b/docs/rules.md index 3bf267ce..3a295761 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -52,84 +52,118 @@ The rule options in detail: - **filters** (`list`): A list of [filters](03-filters.md) _(Default: `[]`)_ - **actions** (`list`): A list of [actions](04-actions.md) -## Templates and placeholders +## Targeting directories + +When `targets` is set to `dirs`, organize will work on the folders, not on files. + +The filters adjust their meaning automatically. For example the `size` filter sums up +the size of all files contained in the given folder instead of returning the size of a +single file. + +Of course other filters like `exif` or `filecontent` do not work on folders and will +return an error. -**You can use placeholder variables in your actions.** +## Templates and placeholders Placeholder variables are used with curly braces `{var}`. -You always have access to the variables `{path}`, `{basedir}` and `{relative_path}`: -- `{path}` -- is the full path to the current file -- `{basedir}` -- the current base folder (the base folder is the folder you - specify in your configuration). -- `{relative_path}` -- the relative path from `{basedir}` to `{path}` +These variables are **always available**: + +`{env}` (`dict`)
+All your environment variables. You can access individual env vars like this: `{env.MY_VARIABLE}`. -Use the dot notation to access properties of `{path}`, `{basedir}` and `{relative_path}`: +`{path}` ([`pathlib.Path`](https://docs.python.org/3/library/pathlib.html#methods-and-properties))
+The full path to the current file / folder on the local harddrive. +This is not available for remote locations - in this case use `fs` and `fs_path`. -- `{path}` -- the full path to the current file -- `{path.name}` -- the full filename including extension -- `{path.stem}` -- just the file name without extension -- `{path.suffix}` -- the file extension -- `{path.parent}` -- the parent folder of the current file -- `{path.parent.parent}` -- parent calls are chainable... +`{now}` (`datetime`)
+The current datetime in the local timezone. -- `{basedir}` -- the full path to the current base folder -- `{basedir.parent}` -- the full path to the base folder's parent +`{utcnow}` (`datetime`)
+The current UTC datetime. -and any other property of the python `pathlib.Path` (`official documentation `\_) object. +`{fs}` (`FS`)
+The filesystem of the current location. -Additionally :ref:`Filters` may emit placeholder variables when applied to a -path. Check the documentation and examples of the filter to see available -placeholder variables and usage examples. +`{fs_path}` (`str`)
+The path of the current file / folder in related to `fs`. -Some examples include: +`{relative_path}` (`str`)
+the relative path of the current file in `{fs}`. -- `{lastmodified.year}` -- the year the file was last modified -- `{regex.yournamedgroup}` -- anything you can extract via regular expressions -- `{extension.upper}` -- the file extension in uppercase -- ... and many more. +In addition to that nearly all filters add new placeholders with information about +the currently handled file / folder. +Example on how to access the size and hash of a file: + +```yaml +rules: + - locations: ~/Desktop + filters: + - size + - hash + actions: + - echo: "{size} {hash}" +``` + +Note: In order to use a value returned by a filter it must be listed in the filters! ## Advanced: Aliases -Instead of repeating the same folders in each and every rule you can use an alias for multiple folders which you can then reference in each rule. +Instead of repeating the same locations / actions / filters in each and every rule you +can use an alias for multiple locations which you can then reference in each rule. + Aliases are a standard feature of the YAML syntax. -.. code-block:: yaml -all_my_messy_folders: &all - ~/Desktop - ~/Downloads - ~/Documents - ~/Dropbox +```yml +all_my_messy_folders: &all + - ~/Desktop + - ~/Downloads + - ~/Documents + - ~/Dropbox -rules: - folders: \*all -filters: ... -actions: ... +rules: + - locations: *all + filters: ... + actions: ... - - folders: *all - filters: ... - actions: ... + - locations: *all + filters: ... + actions: ... +``` You can even use multiple folder lists: -.. code-block:: yaml -private_folders: &private - '/path/private' - '~/path/private' +```yml +private_folders: &private + - "/path/private" + - "~/path/private" -work_folders: &work - '/path/work' - '~/My work folder' +work_folders: &work + - "/path/work" + - "~/My work folder" -all_folders: &all - *private - *work +all_folders: &all + - *private + - *work -rules: - folders: \*private -filters: ... -actions: ... +rules: + - locations: *private + filters: ... + actions: ... - - folders: *work - filters: ... - actions: ... + - locations: *work + filters: ... + actions: ... - - folders: *all - filters: ... - actions: ... + - locations: *all + filters: ... + actions: ... - # same as *all - - folders: - - *work - - *private - filters: ... - actions: ... + # same as *all + - locations: + - *work + - *private + filters: ... + actions: ... +``` diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 723dcd4c..344a31cc 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -36,7 +36,7 @@ class Copy(Action): A template for renaming the file / dir in case of a conflict. Defaults to `{name} {counter}{extension}`. - dest_filesystem (str): + filesystem (str): (Optional) A pyfilesystem opener url of the filesystem you want to copy to. If this is not given, the local filesystem is used. @@ -50,7 +50,7 @@ class Copy(Action): "dest": str, Optional("on_conflict"): Or(*CONFLICT_OPTIONS), Optional("rename_template"): str, - Optional("dest_filesystem"): str, + Optional("filesystem"): str, }, ) @@ -59,7 +59,7 @@ def __init__( dest: str, on_conflict="rename_new", rename_template="{name} {counter}{extension}", - dest_filesystem=None, + filesystem=None, ) -> None: if on_conflict not in CONFLICT_OPTIONS: raise ValueError( @@ -69,7 +69,7 @@ def __init__( self.dest = Template.from_string(dest) self.conflict_mode = on_conflict self.rename_template = Template.from_string(rename_template) - self.dest_filesystem = dest_filesystem + self.filesystem = filesystem def pipeline(self, args: dict, simulate: bool): src_fs = args["fs"] # type: FS @@ -80,8 +80,8 @@ def pipeline(self, args: dict, simulate: bool): if dst_path.endswith(("\\", "/")): dst_path = join(dst_path, basename(src_path)) - if self.dest_filesystem: - dst_fs_ = self.dest_filesystem + if self.filesystem: + dst_fs_ = self.filesystem # render if we have a template if isinstance(dst_fs_, str): dst_fs_ = Template.from_string(dst_fs_).render(**args) @@ -113,6 +113,7 @@ def pipeline(self, args: dict, simulate: bool): ) if not skip: if not simulate: + dst_fs.makedirs(dirname(dst_path)) copy_action(src_fs, src_path, dst_fs, dst_path) self.print("Copied to %s" % resource_description(dst_fs, dst_path)) diff --git a/organize/actions/move.py b/organize/actions/move.py index 493066a0..1db798c6 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -40,7 +40,7 @@ class Move(Action): A template for renaming the file / dir in case of a conflict. Defaults to `{name} {counter}{extension}`. - dest_filesystem (str): + filesystem (str): (Optional) A pyfilesystem opener url of the filesystem you want to copy to. If this is not given, the local filesystem is used. @@ -54,7 +54,7 @@ class Move(Action): "dest": str, Optional("on_conflict"): Or(*CONFLICT_OPTIONS), Optional("rename_template"): str, - Optional("dest_filesystem"): str, + Optional("filesystem"): str, }, ) @@ -63,7 +63,7 @@ def __init__( dest: str, on_conflict="rename_new", rename_template="{name} {counter}{extension}", - dest_filesystem=None, + filesystem=None, ) -> None: if on_conflict not in CONFLICT_OPTIONS: raise ValueError( @@ -73,7 +73,7 @@ def __init__( self.dest = Template.from_string(dest) self.conflict_mode = on_conflict self.rename_template = Template.from_string(rename_template) - self.dest_filesystem = dest_filesystem + self.filesystem = filesystem def pipeline(self, args: dict, simulate: bool): src_fs = args["fs"] # type: FS @@ -84,8 +84,8 @@ def pipeline(self, args: dict, simulate: bool): if dst_path.endswith(("\\", "/")): dst_path = join(dst_path, basename(src_path)) - if self.dest_filesystem: - dst_fs_ = self.dest_filesystem + if self.filesystem: + dst_fs_ = self.filesystem # render if we have a template if isinstance(dst_fs_, str): dst_fs_ = Template.from_string(dst_fs_).render(**args) diff --git a/organize/actions/rename.py b/organize/actions/rename.py index 166ac523..132ed825 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -32,10 +32,6 @@ class Rename(Action): A template for renaming the file / dir in case of a conflict. Defaults to `{name} {counter}{extension}`. - dest_filesystem (str): - (Optional) A pyfilesystem opener url of the filesystem you want to copy to. - If this is not given, the local filesystem is used. - The next action will work with the renamed file / dir. """ diff --git a/organize/core.py b/organize/core.py index 978fb583..dcf6bb77 100644 --- a/organize/core.py +++ b/organize/core.py @@ -2,11 +2,12 @@ import os from collections import Counter, defaultdict from datetime import datetime +from pathlib import Path from typing import Iterable, NamedTuple import fs -from fs.errors import NoSysPath from fs.base import FS +from fs.errors import NoSysPath from fs.walk import Walker from rich.console import Console from schema import SchemaError @@ -20,10 +21,10 @@ from .utils import ( Template, deep_merge_inplace, - ensure_list, ensure_dict, - to_args, + ensure_list, flatten_all_lists_in_dict, + to_args, ) logger = logging.getLogger(__name__) @@ -133,7 +134,7 @@ def instantiate_action(action_config): def syspath_or_exception(fs, path): try: - return fs.getsyspath(path) + return Path(fs.getsyspath(path)) except NoSysPath as e: return e diff --git a/testconf.yaml b/testconf.yaml index 7b5ea852..6ebee481 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -20,7 +20,19 @@ rules: - confirm - echo: "{name}" + - locations: "ftps://demo:demo@demo.wftpserver.com" + subfolders: true + filters: + - size + - extension: jpg + actions: + - echo: "Found file! Size: {size.decimal}" + - copy: + dest: "{relative_path}" + filesystem: zip:///Users/thomasfeldmann/Desktop/ftpfiles.zip + - name: Zip syspath + enabled: false targets: dirs locations: - path: ~/Desktop From 8098c9f950fd10abc1a90795c10ee29132673d6d Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 1 Feb 2022 15:43:39 +0100 Subject: [PATCH 075/108] add SimulationFS to not create folders --- docs/filters.md | 34 ++++++++++++++++++++++++++++++++-- organize/actions/copy.py | 19 ++++++++++++++----- organize/actions/move.py | 17 +++++++++++++---- organize/core.py | 1 + organize/utils.py | 36 +++++++++++++++++++++++++++++------- testconf.yaml | 3 +-- 6 files changed, 90 insertions(+), 20 deletions(-) diff --git a/docs/filters.md b/docs/filters.md index 1484c290..7ce70fa2 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -1,7 +1,37 @@ # Filters -This page shows the specifics of each filter. For basic filter usage and options have a -look at the [Configuration](00-configuration.md) section. +This page shows the specifics of each filter. + +## - How to exclude filters - + +- To exclude all filters, simply set the `filter_mode` of the rule to `none`. +- To exclude a single filter, prefix the filter name with `not` (e.g. `not empty`, + `not extension: jpg`, etc). + +Example: + +```yaml +rules: + # using filter_mode + - locations: ~/Desktop + filter_mode: "none" + filters: + - empty + - name: + endswith: "2022" + actions: + - echo: "{name}" + + # Exclude a single filter + - locations: ~/Desktop + filters: + - not extension: jpg + - name: + startswith: "Invoice" + - not empty + actions: + - echo: "{name}" +``` ## created diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 344a31cc..be44ead0 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -1,13 +1,12 @@ import logging from typing import Callable -from fs import open_fs from fs.base import FS from fs.copy import copy_dir, copy_file from fs.path import basename, dirname, join from schema import Optional, Or -from organize.utils import Template, resource_description +from organize.utils import Template, open_fs_or_sim, resource_description from .action import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict @@ -85,13 +84,23 @@ def pipeline(self, args: dict, simulate: bool): # render if we have a template if isinstance(dst_fs_, str): dst_fs_ = Template.from_string(dst_fs_).render(**args) - dst_fs = open_fs(dst_fs_, writeable=True, create=True) + dst_fs = open_fs_or_sim( + dst_fs_, + writeable=True, + create=True, + simulate=simulate, + ) dst_path = dst_path else: - dst_fs = open_fs(dirname(dst_path), writeable=True, create=True) + dst_fs = open_fs_or_sim( + dirname(dst_path), + writeable=True, + create=True, + simulate=simulate, + ) dst_path = basename(dst_path) - copy_action: Callable[[FS, str, FS, str],None] + copy_action: Callable[[FS, str, FS, str], None] if src_fs.isdir(src_path): copy_action = copy_dir elif src_fs.isfile(src_path): diff --git a/organize/actions/move.py b/organize/actions/move.py index 1db798c6..b0ded4a1 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -1,13 +1,12 @@ import logging from typing import Callable -from fs import open_fs from fs.base import FS from fs.move import move_dir, move_file from fs.path import basename, dirname, join from schema import Optional, Or -from organize.utils import Template, resource_description +from organize.utils import Template, open_fs_or_sim, resource_description from .action import Action from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict @@ -89,10 +88,20 @@ def pipeline(self, args: dict, simulate: bool): # render if we have a template if isinstance(dst_fs_, str): dst_fs_ = Template.from_string(dst_fs_).render(**args) - dst_fs = open_fs(dst_fs_, writeable=True, create=True) + dst_fs = open_fs_or_sim( + dst_fs_, + writeable=True, + create=True, + simulate=simulate, + ) dst_path = dst_path else: - dst_fs = open_fs(dirname(dst_path), writeable=True, create=True) + dst_fs = open_fs_or_sim( + dirname(dst_path), + writeable=True, + create=True, + simulate=True, + ) dst_path = basename(dst_path) move_action: Callable[[FS, str, FS, str], None] diff --git a/organize/core.py b/organize/core.py index dcf6bb77..45a7a010 100644 --- a/organize/core.py +++ b/organize/core.py @@ -27,6 +27,7 @@ to_args, ) + logger = logging.getLogger(__name__) highlighted_console = Console() diff --git a/organize/utils.py b/organize/utils.py index 744d160c..d95cd59b 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -1,12 +1,12 @@ -import os from collections.abc import Mapping from copy import deepcopy -from pathlib import Path -from typing import Any, Hashable, List, Sequence, Union +from typing import Any, Hashable, List, Sequence +import jinja2 +from fs import open_fs, path as fspath from fs.base import FS +from fs.memoryfs import MemoryFS from fs.osfs import OSFS -import jinja2 from jinja2 import nativetypes @@ -31,10 +31,30 @@ def raise_exceptions(x): ) +class SimulationFS(MemoryFS): + def __init__(self, fs_url, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fs_url = fs_url + + def __str__(self): + if not self.fs_url: + return "" + elif "://" in self.fs_url: + return "<%s>" % self.fs_url + return self.fs_url + + +def open_fs_or_sim(fs_url, *args, simulate=False, **kwargs): + if simulate: + simFS = SimulationFS(fs_url) + return simFS + return open_fs(fs_url, *args, **kwargs) + + def is_same_resource(fs1, path1, fs2, path2): - from fs.zipfs import WriteZipFS, ReadZipFS - from fs.tarfs import WriteTarFS, ReadTarFS from fs.errors import NoSysPath, NoURL + from fs.tarfs import ReadTarFS, WriteTarFS + from fs.zipfs import ReadZipFS, WriteZipFS try: return fs1.getsyspath(path1) == fs2.getsyspath(path2) @@ -51,7 +71,9 @@ def is_same_resource(fs1, path1, fs2, path2): def resource_description(fs, path): - if isinstance(fs, OSFS): + if isinstance(fs, SimulationFS): + return "%s%s" % (str(fs), fspath.abspath(path)) + elif isinstance(fs, OSFS): return fs.getsyspath(path) elif path == "/": return str(fs) diff --git a/testconf.yaml b/testconf.yaml index 6ebee481..b0a48f0c 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -28,8 +28,7 @@ rules: actions: - echo: "Found file! Size: {size.decimal}" - copy: - dest: "{relative_path}" - filesystem: zip:///Users/thomasfeldmann/Desktop/ftpfiles.zip + dest: "~/Desktop/test/{relative_path}" - name: Zip syspath enabled: false From 883da016f8358eb1b2524f954524be08cf11784f Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 1 Feb 2022 16:02:27 +0100 Subject: [PATCH 076/108] fix examples --- docs/actions.md | 2 +- docs/locations.md | 13 +++++++------ docs/rules.md | 2 +- docs/updating-from-v1.md | 4 ++-- tests/docs/test_docs.py | 30 +++++++++++++++++++++--------- 5 files changed, 32 insertions(+), 19 deletions(-) diff --git a/docs/actions.md b/docs/actions.md index 3a08c70d..7f6e942d 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -141,7 +141,7 @@ rules: - locations: - ~/Desktop filters: - - Extension + - extension actions: - echo: 'Found a {extension.upper}: "{path.name}"' ``` diff --git a/docs/locations.md b/docs/locations.md index 9f807b77..65870fa5 100644 --- a/docs/locations.md +++ b/docs/locations.md @@ -13,7 +13,7 @@ rules: If you want to handle multiple locations in a rule, create a list: -```yaml +```yml rules: - locations: - ~/Desktop @@ -24,7 +24,7 @@ rules: Using options: -```yaml +```yml rules: - name: "Location list" locations: @@ -37,7 +37,7 @@ Note that you can use environment variables in your locations. ## Location options -```yaml +```yml rules: - locations: - path: ... @@ -96,7 +96,7 @@ A [Filesystem URL](#filesystems). If you want the location to be the root (`"/"`) of a filesystem, use `path`: -```yaml +```yml rules: - locations: - path: zip:///Users/theuser/Downloads/Test.zip @@ -104,7 +104,7 @@ rules: If you want the location to be a subfolder inside a filesystem, use `path` and `filesystem`: -```yaml +```yml rules: - locations: - filesystem: zip:///Users/theuser/Downloads/Test.zip @@ -184,8 +184,9 @@ copied between projects. There is a command line option to change the working directory should you need it. +**huge-pic-warner.yaml** + ```yaml -# huge-pic-warner.yaml rules: - locations: "docs" # here "docs" is relative to the current working dir filters: diff --git a/docs/rules.md b/docs/rules.md index 3a295761..fa3cb0ac 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -23,7 +23,7 @@ So with this minimal configuration it will print "Hello World!" for each file it ## Rule options -```yaml +```yml rules: # First rule - name: ... diff --git a/docs/updating-from-v1.md b/docs/updating-from-v1.md index bbdd2f1c..733d03b8 100644 --- a/docs/updating-from-v1.md +++ b/docs/updating-from-v1.md @@ -30,7 +30,7 @@ Alternative for organize v1.x: -```yaml +```yml rules: # find some pdf files in various dirs and echo "Hello" for each one - folders: @@ -47,7 +47,7 @@ rules: becomes (organize v2.x) -```yaml +```yml rules: - name: find some pdf files in various dirs and echo "Hello" for each one locations: diff --git a/tests/docs/test_docs.py b/tests/docs/test_docs.py index 8ef15993..a41540df 100644 --- a/tests/docs/test_docs.py +++ b/tests/docs/test_docs.py @@ -1,3 +1,21 @@ +""" + +Use + +```yaml +rules: + +``` + +for examples you want to test. Other yaml: + +```yml + +``` + +""" + + import re import fs @@ -10,15 +28,9 @@ RE_CONFIG = re.compile(r"```yaml\n(?Prules:(?:.*?\n)+?)```", re.MULTILINE) -DOCS = { - "filters": "03-filters.md", - "actions": "04-actions.md", -} - - def test_examples_are_valid(): docdir = fs.open_fs("docs") - for f in DOCS.values(): + for f in docdir.walk.files(filter=["*.md"]): text = docdir.readtext(f) for match in RE_CONFIG.findall(text): err = "" @@ -33,13 +45,13 @@ def test_examples_are_valid(): def test_all_filters_documented(): docdir = fs.open_fs("docs") - filter_docs = docdir.readtext(DOCS["filters"]) + filter_docs = docdir.readtext("filters.md") for name in FILTERS.keys(): assert "## {}".format(name) in filter_docs def test_all_actions_documented(): docdir = fs.open_fs("docs") - action_docs = docdir.readtext(DOCS["actions"]) + action_docs = docdir.readtext("actions.md") for name in ACTIONS.keys(): assert "## {}".format(name) in action_docs From 17536fe126ece35df2da50cf9ce5fd4ae1e97fc4 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 1 Feb 2022 16:16:42 +0100 Subject: [PATCH 077/108] update docs --- CHANGELOG.md | 2 +- docs/actions.md | 2 +- docs/rules.md | 8 ++++---- docs/updating-from-v1.md | 14 +++++++------- organize/cli.py | 5 +++-- tests/docs/test_docs.py | 1 - 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 662a75c0..966c961f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ Please backup all your important stuff before running and use the simulate optio - cleaner config file validation and stricter format - The config file format got a long due overhaul. Please see the - [migration documentation](docs/06-updating-from-v1.md) for what is new. + [migration documentation](docs/updating-from-v1.md) for what is new. - The `timezone` keyword for `lastmodified` and `created` was removed. The timezone is now the local timezone by default. - The `filesize` filter was renamed to `size` and can now be used to get directory sizes diff --git a/docs/actions.md b/docs/actions.md index 7f6e942d..7a24c372 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -1,7 +1,7 @@ # Actions This page shows the specifics of each action. For basic action usage and options have a -look at the [Config](01-config.md) section. +look at the [Rules](rules.md) section. ## confirm diff --git a/docs/rules.md b/docs/rules.md index fa3cb0ac..29d9ad53 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -1,7 +1,7 @@ # Rules A organize config file can be written in [YAML](https://learnxinyminutes.com/docs/yaml/) -or [JSON](https://learnxinyminutes.com/docs/json/). See [configuration](00-configuration.md) +or [JSON](https://learnxinyminutes.com/docs/json/). See [configuration](configuration.md) on how to locate your config file. The top level element must be a dict with a key "rules". @@ -46,11 +46,11 @@ The rule options in detail: - **name** (`str`): The rule name - **enabled** (`bool`): Whether the rule is enabled / disabled _(Default: `true`)_ - **targets** (`str`): `"dirs"` or `"files"` _(Default: `"files"`)_ -- **locations** (`str`|`list`) - A single location string or list of [locations](02-locations.md) +- **locations** (`str`|`list`) - A single location string or list of [locations](locations.md) - **subfolders** (`bool`): Whether to recurse into subfolders of all locations _(Default: `false`)_ - **filter_mode** (`str`): `"all"`, `"any"` or `"none"` of the filters must apply _(Default: `"all"`)_ -- **filters** (`list`): A list of [filters](03-filters.md) _(Default: `[]`)_ -- **actions** (`list`): A list of [actions](04-actions.md) +- **filters** (`list`): A list of [filters](filters.md) _(Default: `[]`)_ +- **actions** (`list`): A list of [actions](actions.md) ## Targeting directories diff --git a/docs/updating-from-v1.md b/docs/updating-from-v1.md index 733d03b8..cbe33710 100644 --- a/docs/updating-from-v1.md +++ b/docs/updating-from-v1.md @@ -18,11 +18,11 @@ Alternative for ## Config -- `folders` must be renamed to `locations`. New options: [Locations](02-locations.md). +- `folders` must be renamed to `locations`. New options: [Locations](locations.md). - the **glob syntax** (eg. `"~/Documents/**"`) has been removed. - the **exclamation mark exclude** (eg. `"! ~/Desktop"`) syntax has been removed. - They are replaced by the `max_depth`, `exclude_files`, `exclude_dirs`, `filter` and - `filter_dirs` settings. See [Locations](02-locations.md). + `filter_dirs` settings. See [Locations](locations.md). - the `subfolders` setting is removed and replaced by the `max_depth` setting of a specific location. - You can now name your rules via `name`. @@ -66,11 +66,11 @@ rules: ## Filters -- [`created`](03-filters.md#created) no longer accepts a timezone and uses the local timezone by default. -- [`lastmodified`](03-filters.md#lastmodified) no longer accepts a timezone and uses the local timezone by default. -- [`filename`](03-filters.md#name) is renamed to `name`. -- [`filesize`](03-filters.md#size) is renamed to `size`. +- [`created`](filters.md#created) no longer accepts a timezone and uses the local timezone by default. +- [`lastmodified`](filters.md#lastmodified) no longer accepts a timezone and uses the local timezone by default. +- [`filename`](filters.md#name) is renamed to `name`. +- [`filesize`](filters.md#size) is renamed to `size`. ## Actions -- [`copy`](04-actions.md#copy) arguments changed. +- [`copy`](actions.md#copy) arguments changed. diff --git a/organize/cli.py b/organize/cli.py index aee51803..8a56b47b 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -12,8 +12,9 @@ from . import console from .__version__ import __version__ -DOCS_URL = "https://organize.readthedocs.io" -DEFAULT_CONFIG = """# organize configuration file +DOCS_URL = "https://tfeldmann.github.io/organize/" # "https://organize.readthedocs.io" +DEFAULT_CONFIG = """\ +# organize configuration file # {docs} rules: diff --git a/tests/docs/test_docs.py b/tests/docs/test_docs.py index a41540df..cc382a5d 100644 --- a/tests/docs/test_docs.py +++ b/tests/docs/test_docs.py @@ -15,7 +15,6 @@ """ - import re import fs From 226ea19be8c5a67e677b9ed7a179ee77156acffe Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 1 Feb 2022 17:11:41 +0100 Subject: [PATCH 078/108] better error messages --- organize/actions/copy.py | 2 +- organize/actions/move.py | 1 + organize/core.py | 8 ++++- testconf.yaml | 66 ++-------------------------------------- 4 files changed, 12 insertions(+), 65 deletions(-) diff --git a/organize/actions/copy.py b/organize/actions/copy.py index be44ead0..bec8f2db 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -122,7 +122,7 @@ def pipeline(self, args: dict, simulate: bool): ) if not skip: if not simulate: - dst_fs.makedirs(dirname(dst_path)) + dst_fs.makedirs(dirname(dst_path), recreate=True) copy_action(src_fs, src_path, dst_fs, dst_path) self.print("Copied to %s" % resource_description(dst_fs, dst_path)) diff --git a/organize/actions/move.py b/organize/actions/move.py index b0ded4a1..0dd9ef12 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -126,6 +126,7 @@ def pipeline(self, args: dict, simulate: bool): ) if not skip: if not simulate: + dst_fs.makedirs(dirname(dst_path), recreate=True) move_action(src_fs, src_path, dst_fs, dst_path) self.print("Moved to %s" % resource_description(dst_fs, dst_path)) diff --git a/organize/core.py b/organize/core.py index 45a7a010..f72ee5c2 100644 --- a/organize/core.py +++ b/organize/core.py @@ -61,6 +61,9 @@ def config_cleanup(rules): if rule.get("enabled", True): result["rules"].append(rule) + if not result: + raise ValueError("No rules defined.") + # flatten all lists everywhere return flatten_all_lists_in_dict(dict(result)) @@ -274,8 +277,8 @@ def run(config, simulate: bool = True): def run_file(config_file: str, working_dir: str, simulate: bool): - console.info(config_file, working_dir) try: + console.info(config_file, working_dir) rules = load_from_file(config_file) rules = config_cleanup(rules) CONFIG_SCHEMA.validate(rules) @@ -292,3 +295,6 @@ def run_file(config_file: str, working_dir: str, simulate: bool): highlighted_console.print(err) except Exception as e: highlighted_console.print_exception() + except (EOFError, KeyboardInterrupt): + console.status.stop() + console.warn("Aborted") diff --git a/testconf.yaml b/testconf.yaml index b0a48f0c..4808af66 100644 --- a/testconf.yaml +++ b/testconf.yaml @@ -1,68 +1,8 @@ rules: - - name: "Files starting with L" + - locations: ~/Desktop enabled: false - targets: files - locations: - - path: ~/Downloads - max_depth: 3 - - path: ~/Documents - max_depth: 3 - - path: ~/Pictures - max_depth: 3 - ignore_errors: true - - filesystem: zip:///Users/thomasfeldmann/Downloads/105133-0001_stp.zip - path: "/Test" - ignore_errors: true - filters: - - name: - startswith: L - actions: - - confirm - - echo: "{name}" - - - locations: "ftps://demo:demo@demo.wftpserver.com" subfolders: true filters: - - size - - extension: jpg - actions: - - echo: "Found file! Size: {size.decimal}" - - copy: - dest: "~/Desktop/test/{relative_path}" - - - name: Zip syspath - enabled: false - targets: dirs - locations: - - path: ~/Desktop - exclude_dirs: ["Inbox"] - max_depth: null - filters: - - name: - startswith: "I" - actions: - - echo: "{path}" - - - name: "Folders" - enabled: false - targets: dirs - locations: - - path: ~/Desktop - filter_mode: nones - filters: - - not name: - startswith2: Inbox - - size - - name: - startswith: Projekt + - extension: pdf actions: - - echo: "{name} {size.decimal}" - - # - name: Find some folders - # targets: dirs - # locations: - # - ~/Desktop - # - path: ~/Desktop/Inbox - # max_depth: null - # actions: - # - echo: ~/Dir + - copy: here/{relative_path} From 5cf184a1601d13769143470ce8564cc7f25667af Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 1 Feb 2022 23:07:29 +0100 Subject: [PATCH 079/108] update docs --- README.md | 157 +++++++++++++++++------------------- docs/locations.md | 16 ++-- mkdocs.yml | 2 +- organize/actions/confirm.py | 12 ++- organize/actions/copy.py | 2 + organize/actions/echo.py | 2 +- organize/actions/move.py | 4 +- organize/actions/rename.py | 10 ++- organize/actions/utils.py | 8 +- tests/docs/test_docs.py | 17 ++-- 10 files changed, 117 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 75cffd4e..bd30ae35 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@

- [About](#about) +- [Features](#features) - [Getting started](#getting-started) - [Installation](#installation) - - [Creating your first rule](#creating-your-first-rule) + - [Create your first rule](#create-your-first-rule) - [Example rules](#example-rules) -- [Advanced usage](#advanced-usage) - [Command line interface](#command-line-interface) ## About @@ -43,6 +43,19 @@ Time to automate it once and benefit from it forever. **organize** is a command line, open-source alternative to apps like Hazel (macOS) or File Juggler (Windows). +## Features + +organize has too many features to list here. The highlights include: + +- Safe moving, renaming, copying of files with conflict resolution options +- Fast duplicate file detection +- Exif tags extraction +- Categorization via text extracted from PDF, DOCX and many more +- Supports working on FTP, WebDAV, S3 Buckets, SSH and many more +- Powerful template engine +- Inline python and shell commands as filters and actions for maximum flexibility! +- Everything can be simulated before touching your files. + ## Getting started ### Installation @@ -63,13 +76,13 @@ pip3 install -U "organize-tool[textract]" This command can also be used to update to the newest version. Now you can run `organize --help` to check if the installation was successful. -### Creating your first rule +### Create your first rule -In your shell, **run `organize config`** to edit the configuration: +In your shell, run `organize edit` to edit the configuration: ```yaml rules: - - folders: ~/Downloads + - locations: ~/Downloads subfolders: true filters: - extension: pdf @@ -77,29 +90,34 @@ rules: - echo: "Found PDF!" ``` -> If you have problems editing the configuration you can run `organize config --open-folder` to reveal the configuration folder in your file manager. You can then edit the `config.yaml` in your favourite editor. -> -> Alternatively you can run `organize config --path` to see the full path to -> your `config.yaml`) +> If you have problems editing the configuration you can run `organize reveal` to reveal the configuration folder in your file manager. You can then edit the `config.yaml` in your favourite editor. -**Save your config file and run `organize run`.** +save your config file and run: + +```sh +organize run +``` -You will see a list of all `.pdf` files you have in your downloads folder (+ subfolders). For now we only show the text `Found PDF!` for each file, but this will change soon... +You will see a list of all `.pdf` files you have in your downloads folder (+ subfolders). +For now we only show the text `Found PDF!` for each file, but this will change soon... (If it shows `Nothing to do` you simply don't have any pdfs in your downloads folder). -Run `organize config` again and add a `copy`-action to your rule: +Run `organize edit` again and add a `move`-action to your rule: -```yaml +```yml actions: - echo: "Found PDF!" - move: ~/Documents/PDFs/ ``` -**Now run `organize sim` to see what would happen without touching your files**. You will see that your pdf-files would be moved over to your `Documents/PDFs` folder. +Now run `organize sim` to see what would happen without touching your files. + +You will see that your pdf-files would be moved over to your `Documents/PDFs` folder. -Congratulations, you just automated your first task. You can now run `organize run` whenever you like and all your pdfs are a bit more organized. It's that easy. +Congratulations, you just automated your first task. You can now run `organize run` +whenever you like and all your pdfs are a bit more organized. It's that easy. -> There is so much more. You want to rename / copy files, run custom shell- or python scripts, match filenames with regular expressions or use placeholder variables? organize has you covered. Have a look at the advanced usage example below! +> There is so much more. You want to rename / copy files, run custom shell- or python scripts, match names with regular expressions or use placeholder variables? organize has you covered. Have a look at the advanced usage example below! ## Example rules @@ -109,12 +127,12 @@ Move all invoices, orders or purchase documents into your documents folder: ```yaml rules: - # sort my invoices and receipts - - folders: ~/Downloads + - name: "Sort my invoices and receipts" + locations: ~/Downloads subfolders: true filters: - extension: pdf - - filename: + - name: contains: - Invoice - Order @@ -124,96 +142,67 @@ rules: - move: ~/Documents/Shopping/ ``` -Move incomplete downloads older than 30 days into the trash: - -```yaml -rules: - # move incomplete downloads older > 30 days into the trash - - folders: ~/Downloads - filters: - - extension: - - download - - crdownload - - part - - lastmodified: - days: 30 - mode: older - actions: - - trash -``` - -Delete empty files from downloads and desktop: +Recursively delete all empty directories: ```yaml rules: - # delete empty files from downloads and desktop - - folders: - - ~/Downloads - - ~/Desktop - filters: - - filesize: 0 - actions: - - trash -``` - -Move screenshots into a "Screenshots" folder on your desktop: - -```yaml -rules: - # move screenshots into "Screenshots" folder - - folders: ~/Desktop + - name: "Recursively delete all empty directories" + locations: + - path: ~/Downloads + subfolders: true filters: - - filename: - startswith: "Screen Shot" + - empty actions: - - move: ~/Desktop/Screenshots/ + - delete ``` -Organize your font downloads: + -- `script.docx` will be moved to `~/Documents/DOCX/2018-01/script.docx` -- `demo.pdf` will be moved to `~/Documents/PDF/2016-12/demo.pdf` -- The files will be opened (`open` command in macOS) _from their new location_. -- Note the format syntax for `{created.month}` to make sure the month is prepended with a zero. +You'll find many more examples in the full documentation. ## Command line interface -``` +```sh Usage: organize [OPTIONS] COMMAND [ARGS]... organize diff --git a/docs/locations.md b/docs/locations.md index 65870fa5..8d6b2bec 100644 --- a/docs/locations.md +++ b/docs/locations.md @@ -150,9 +150,11 @@ dropbox://dropbox.com?access_token= ssh://[user[:password]@]host[:port] ``` -The ZIP, TAR, FTP and AppFS filesystems are builtin. -For all other filesystems you need to -[install the appropriate library](https://www.pyfilesystem.org/page/index-of-filesystems). +!!! note + + The ZIP, TAR, FTP and AppFS filesystems are builtin. + For all other filesystems you need to + [install the appropriate library](https://www.pyfilesystem.org/page/index-of-filesystems). **FTP Example** @@ -172,10 +174,10 @@ rules: filesystem: zip:///Users/thomas/Desktop/ftpfiles.zip ``` -**Note:** +!!! note -You should never include a password in a config file. Better pass them in via an -environment variable (`{env.FTP_PASSWORD}`) as you can see above. + You should never include a password in a config file. Better pass them in via an + environment variable (`{env.FTP_PASSWORD}`) as you can see above. ## Relative locations @@ -184,7 +186,7 @@ copied between projects. There is a command line option to change the working directory should you need it. -**huge-pic-warner.yaml** +**huge-pic-warner.yaml:** ```yaml rules: diff --git a/mkdocs.yml b/mkdocs.yml index b710ee99..c71a914c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,9 +30,9 @@ plugins: - organize markdown_extensions: + - admonition - toc: permalink: "#" - - mkdocs-click theme: name: readthedocs diff --git a/organize/actions/confirm.py b/organize/actions/confirm.py index cb7d0497..6fadc54e 100644 --- a/organize/actions/confirm.py +++ b/organize/actions/confirm.py @@ -1,4 +1,5 @@ from rich.prompt import Prompt +from schema import Optional, Or from organize import console from organize.utils import Template @@ -13,10 +14,17 @@ class Confirm(Action): name = "confirm" schema_support_instance_without_args = True + arg_schema = Or( + str, + { + Optional("msg"): str, + Optional("default"): bool, + }, + ) + def __init__(self, msg="Continue?", default=True): self.msg = Template.from_string(msg) self.default = default - self.prompt = Prompt(console=console) def pipeline(self, args: dict, simulate: bool): msg = self.msg.render(**args) @@ -26,7 +34,7 @@ def pipeline(self, args: dict, simulate: bool): default=self.default, ) if not result: - raise ValueError("Aborted") + raise StopIteration("Aborted") def __str__(self) -> str: return 'Confirm(msg="%s")' % self.msg diff --git a/organize/actions/copy.py b/organize/actions/copy.py index bec8f2db..119f5908 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -113,6 +113,8 @@ def pipeline(self, args: dict, simulate: bool): % (resource_description(dst_fs, dst_path), self.conflict_mode) ) dst_fs, dst_path, skip = resolve_overwrite_conflict( + src_fs=src_fs, + src_path=src_path, dst_fs=dst_fs, dst_path=dst_path, conflict_mode=self.conflict_mode, diff --git a/organize/actions/echo.py b/organize/actions/echo.py index 22d34bb3..2eacc4c4 100644 --- a/organize/actions/echo.py +++ b/organize/actions/echo.py @@ -10,7 +10,7 @@ class Echo(Action): variables. Args: - msg(str): The message to print. Accepts placeholder variables. + msg (str): The message to print. Accepts placeholder variables. """ name = "echo" diff --git a/organize/actions/move.py b/organize/actions/move.py index 0dd9ef12..08f2530c 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -100,7 +100,7 @@ def pipeline(self, args: dict, simulate: bool): dirname(dst_path), writeable=True, create=True, - simulate=True, + simulate=simulate, ) dst_path = basename(dst_path) @@ -117,6 +117,8 @@ def pipeline(self, args: dict, simulate: bool): % (resource_description(dst_fs, dst_path), self.conflict_mode) ) dst_fs, dst_path, skip = resolve_overwrite_conflict( + src_fs=src_fs, + src_path=src_path, dst_fs=dst_fs, dst_path=dst_path, conflict_mode=self.conflict_mode, diff --git a/organize/actions/rename.py b/organize/actions/rename.py index 132ed825..1df65d46 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -65,10 +65,10 @@ def pipeline(self, args: dict, simulate: bool): src_path = args["fs_path"] new_name = self.new_name.render(**args) - if os.path.sep in new_name: - ValueError( - "Rename only takes a name as argument. " - "To move files or folders use the move action." + if "/" in new_name: + raise ValueError( + "The new name cannot contain slashes. " + "To move files or folders use `move`." ) parents, full_name = path.split(src_path) @@ -91,6 +91,8 @@ def pipeline(self, args: dict, simulate: bool): % (resource_description(fs, dst_path), self.conflict_mode) ) fs, dst_path, skip = resolve_overwrite_conflict( + src_fs=fs, + src_path=src_path, dst_fs=fs, dst_path=dst_path, conflict_mode=self.conflict_mode, diff --git a/organize/actions/utils.py b/organize/actions/utils.py index 052fd14c..8a7f41fc 100644 --- a/organize/actions/utils.py +++ b/organize/actions/utils.py @@ -5,7 +5,7 @@ from fs.path import splitext from jinja2 import Template -from organize.utils import resource_description, next_free_name +from organize.utils import resource_description, next_free_name, is_same_resource from .trash import Trash @@ -27,6 +27,8 @@ class ResolverResult(NamedTuple): def resolve_overwrite_conflict( + src_fs: FS, + src_path: str, dst_fs: FS, dst_path: str, conflict_mode: str, @@ -34,6 +36,10 @@ def resolve_overwrite_conflict( simulate: bool, print: Callable, ) -> ResolverResult: + if is_same_resource(src_fs, src_path, dst_fs, dst_path): + print("Same resource: Skipped.") + return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=True) + if conflict_mode == "trash": Trash().pipeline({"fs": dst_fs, "fs_path": dst_path}, simulate=simulate) return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=False) diff --git a/tests/docs/test_docs.py b/tests/docs/test_docs.py index cc382a5d..38bc441f 100644 --- a/tests/docs/test_docs.py +++ b/tests/docs/test_docs.py @@ -1,18 +1,11 @@ """ - -Use +Tests all snippets in the docs and readme like this: ```yaml rules: - -``` - -for examples you want to test. Other yaml: - -```yml - ``` +To exclude, use shorthand `yml`. """ import re @@ -20,16 +13,16 @@ import fs from schema import SchemaError -from organize.filters import FILTERS from organize.actions import ACTIONS from organize.config import CONFIG_SCHEMA, load_from_string +from organize.filters import FILTERS RE_CONFIG = re.compile(r"```yaml\n(?Prules:(?:.*?\n)+?)```", re.MULTILINE) def test_examples_are_valid(): - docdir = fs.open_fs("docs") - for f in docdir.walk.files(filter=["*.md"]): + docdir = fs.open_fs(".") + for f in docdir.walk.files(filter=["*.md"], max_depth=2): text = docdir.readtext(f) for match in RE_CONFIG.findall(text): err = "" From a3e56200f8f06ddb289d894e03275839bfaf9b5e Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 1 Feb 2022 23:49:25 +0100 Subject: [PATCH 080/108] updated docs --- CHANGELOG.md | 8 ++-- docs/updating-from-v1.md | 93 ++++++++++++++------------------------ organize/filters/filter.py | 2 +- organize/utils.py | 15 +++--- testconf.yaml | 8 ---- 5 files changed, 43 insertions(+), 83 deletions(-) delete mode 100644 testconf.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 966c961f..89861afb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ Please backup all your important stuff before running and use the simulate optio - You can now target directories with your rules (copying, renaming, etc a whole folder) - Organize inside or between (S)FTP, S3 Buckets, Zip archives and many more. - - [Available filesystems](https://www.pyfilesystem.org/page/index-of-filesystems/) + [Available filesystems](https://www.pyfilesystem.org/page/index-of-filesystems/) - `max_depth` setting when recursing into subfolders - Respects your rule order - safer, less magic, less surprises. (v1 tried to be clever. v2 now works your config file from top to bottom) @@ -28,12 +28,10 @@ Please backup all your important stuff before running and use the simulate optio - Added filter `hash`. - Added action `symlink`. - Added action `confirm`. +- Many small fixes and improvements. ### changed -- cleaner config file validation and stricter format -- The config file format got a long due overhaul. Please see the - [migration documentation](docs/updating-from-v1.md) for what is new. - The `timezone` keyword for `lastmodified` and `created` was removed. The timezone is now the local timezone by default. - The `filesize` filter was renamed to `size` and can now be used to get directory sizes @@ -44,7 +42,7 @@ Please backup all your important stuff before running and use the simulate optio ### removed - Glob syntax is gone from folders (no longer needed) -- "!"-exclude syntax is gone (no longer needed) +- `"!"` folder exclude syntax is gone (no longer needed) ## v1.10.1 (2021-04-21) diff --git a/docs/updating-from-v1.md b/docs/updating-from-v1.md index cbe33710..f519a2ab 100644 --- a/docs/updating-from-v1.md +++ b/docs/updating-from-v1.md @@ -1,76 +1,49 @@ # Updating from organize v1.x -First of all, thank you for being a long time user of `organize`. +First of all, thank you for being a long time user of `organize`! -As this project is only maintained by the single person writing this article it is not -feasible to write an automatic config file migration or a compatibility layer. -Many people use organize for important documents and personal files, this is not -something I want to half-ass. +I tried to keep the amount of breaking changes small but could not avoid them +completely. Feel free to pin organize to v1.x, but then you're missing the party. -So if you want all the new goodies, you'll need to do some changes in your config. -Otherwise feel free to pin organize to the latest v1.x. +Please open a issue on Github if you need help migrating your config file! - +- `folders` must be renamed to `locations`. +- REMOVED: The glob syntax (`/Docs/**/*.png`) +- REMOVED: The exclamation mark exlucde syntax (`! ~/Desktop/exclude`) -## Config +With `locations`, there are now much better options in place. +Please change your `folders` definition to the `locations` definition: [Locations documentation](locations.md). + +- All keys (filter names, action names, option names) now must be lowercase. + +## Placeholders + +organize v2 uses the Jinja template engine. You may need to change some of your placeholders. + +- `{basedir}` is no longer available. +- Replace undocumented placeholders like this: -- `folders` must be renamed to `locations`. New options: [Locations](locations.md). - - the **glob syntax** (eg. `"~/Documents/**"`) has been removed. - - the **exclamation mark exclude** (eg. `"! ~/Desktop"`) syntax has been removed. - - They are replaced by the `max_depth`, `exclude_files`, `exclude_dirs`, `filter` and - `filter_dirs` settings. See [Locations](locations.md). -- the `subfolders` setting is removed and replaced by the `max_depth` setting - of a specific location. -- You can now name your rules via `name`. -- The `enabled` setting has been removed. # TODO: ? - -organize v1.x: - -```yml -rules: - # find some pdf files in various dirs and echo "Hello" for each one - - folders: - - "~/Desktop/**/*.pdf" - - "! ~/Desktop/donotmove/*" - subfolders: true - ... - - # move all pdfs into documents - - folders: - - "~/Downloads/*.pdf" - ... -``` - -becomes (organize v2.x) - -```yml -rules: - - name: find some pdf files in various dirs and echo "Hello" for each one - locations: - - path: ~/Desktop/ - max_depth: null - filter: "*.pdf" - exclude_dirs: donotmove - ... - - - name: move all pdfs into documents - locations: "~/Downloads" - filters: - - extension: pdf - ... -``` + ```py + {created.year}-{created.month:02}-{created.day:02} + ``` + + With this: + + ```py + {created.strftime('%Y-%m-%d')} + ``` ## Filters -- [`created`](filters.md#created) no longer accepts a timezone and uses the local timezone by default. -- [`lastmodified`](filters.md#lastmodified) no longer accepts a timezone and uses the local timezone by default. - [`filename`](filters.md#name) is renamed to `name`. - [`filesize`](filters.md#size) is renamed to `size`. +- [`created`](filters.md#created) no longer accepts a timezone and uses the local timezone by default. +- [`lastmodified`](filters.md#lastmodified) no longer accepts a timezone and uses the local timezone by default. ## Actions -- [`copy`](actions.md#copy) arguments changed. +- [`copy`](actions.md#copy) arguments changed to support conflict resolution options. +- [`move`](actions.md#move) arguments changed to support conflict resolution options. +- [`rename`](actions.md#rename) arguments changed to support conflict resolution options. diff --git a/organize/filters/filter.py b/organize/filters/filter.py index 5cfe1a92..29092201 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -64,7 +64,7 @@ def print(self, msg: str) -> None: pipeline_message(self.get_name(), line) def print_error(self, msg: str): - for line in msg.splitlines(msg): + for line in msg.splitlines(): pipeline_error(self.get_name(), line) def set_logic(self, inverted=False): diff --git a/organize/utils.py b/organize/utils.py index d95cd59b..342da4c4 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -1,6 +1,5 @@ -from collections.abc import Mapping from copy import deepcopy -from typing import Any, Hashable, List, Sequence +from typing import Any, List, Sequence, Callable import jinja2 from fs import open_fs, path as fspath @@ -10,7 +9,9 @@ from jinja2 import nativetypes -def raise_exceptions(x): +def finalize_placeholder(x): + if Callable(x): + return x() if isinstance(x, Exception): raise x return x @@ -20,14 +21,14 @@ def raise_exceptions(x): variable_start_string="{", variable_end_string="}", autoescape=False, - finalize=raise_exceptions, + finalize=finalize_placeholder, ) NativeTemplate = nativetypes.NativeEnvironment( variable_start_string="{", variable_end_string="}", autoescape=False, - finalize=raise_exceptions, + finalize=finalize_placeholder, ) @@ -130,10 +131,6 @@ def flattened_string_list(x, case_sensitive=True) -> Sequence[str]: return x -def first_key(dic: Mapping) -> Hashable: - return list(dic.keys())[0] - - def flatten_all_lists_in_dict(obj): """ >>> flatten_all_lists_in_dict({1: [[2], [3, {5: [5, 6]}]]}) diff --git a/testconf.yaml b/testconf.yaml deleted file mode 100644 index 4808af66..00000000 --- a/testconf.yaml +++ /dev/null @@ -1,8 +0,0 @@ -rules: - - locations: ~/Desktop - enabled: false - subfolders: true - filters: - - extension: pdf - actions: - - copy: here/{relative_path} From 7b559794175c8f24b01d3794f78e4cb9ba7fb8bc Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Tue, 1 Feb 2022 23:58:04 +0100 Subject: [PATCH 081/108] fix exif --- organize/cli.py | 12 ++++++------ organize/filters/exif.py | 2 +- organize/utils.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/organize/cli.py b/organize/cli.py index 8a56b47b..2f790183 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -18,12 +18,12 @@ # {docs} rules: - locations: - - - filters: - - - actions: - - + - locations: + - + filters: + - + actions: + - """.format( docs=DOCS_URL ) diff --git a/organize/filters/exif.py b/organize/filters/exif.py index 903b1168..071b7606 100644 --- a/organize/filters/exif.py +++ b/organize/filters/exif.py @@ -47,7 +47,7 @@ def category_dict(self, tags: Mapping[str, str]) -> ExifDict: def matches(self, exiftags: dict) -> bool: if not exiftags: return False - tags = {k.lower(): v.printable for k, v in exiftags.items()} + tags = {k.lower(): v for k, v in exiftags.items()} # no match if expected tag is not found normkey = lambda k: k.replace(".", " ").lower() diff --git a/organize/utils.py b/organize/utils.py index 342da4c4..417ad9b8 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -1,5 +1,5 @@ from copy import deepcopy -from typing import Any, List, Sequence, Callable +from typing import Any, List, Sequence import jinja2 from fs import open_fs, path as fspath @@ -10,7 +10,7 @@ def finalize_placeholder(x): - if Callable(x): + if callable(x): return x() if isinstance(x, Exception): raise x From 40488e4a6e21394087f72d8806eb9c43977cfd3f Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Wed, 2 Feb 2022 12:05:31 +0100 Subject: [PATCH 082/108] update docs --- CHANGELOG.md | 1 + README.md | 4 +++- pyproject.toml | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89861afb..b0bc275e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Please backup all your important stuff before running and use the simulate optio as well. - The `filename` filter was renamed to `name` and can now be used to get directory names as well. +- The `size` filter now returns multiple formats ### removed diff --git a/README.md b/README.md index bd30ae35..cc118c4d 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,9 @@ In your shell, run `organize edit` to edit the configuration: ```yaml rules: - - locations: ~/Downloads + - name: "Find PDFs" + locations: + - ~/Downloads subfolders: true filters: - extension: pdf diff --git a/pyproject.toml b/pyproject.toml index 82e7faa7..a8cb6d63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "organize-tool" -version = "1.10.1" +version = "2.0.0.beta" description = "The file management automation tool" packages = [{ include = "organize" }] authors = ["Thomas Feldmann "] From 54531c1850c7cb709ab09cb95df1af4530ed1f6c Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Wed, 2 Feb 2022 14:10:35 +0100 Subject: [PATCH 083/108] update tests --- .github/workflows/tests.yml | 34 +- organize/config.py | 27 +- organize/core.py | 37 +- organize/filters/created.py | 56 +- organize/filters/lastmodified.py | 69 +-- organize/filters/utils.py | 12 + organize/utils.py | 9 +- pyproject.toml | 7 + tests/actions/test_echo.py | 17 +- tests/actions/test_shell.py | 24 +- tests/actions/test_trash.py | 14 +- tests/conftest.py | 71 +-- tests/core/test_config.py | 497 ++++++++---------- tests/core/test_utils.py | 52 -- tests/core/test_utils_merge.py | 26 +- tests/filters/test_created.py | 23 +- tests/filters/test_extension.py | 10 +- tests/filters/test_filename.py | 99 ---- tests/filters/test_last_modified.py | 26 - tests/filters/test_lastmodified.py | 19 + tests/filters/test_name.py | 108 ++++ tests/filters/test_python.py | 9 +- tests/filters/test_regex.py | 3 +- .../{test_filesize.py => test_size.py} | 0 .../test_copy.py => todo/todo_copy.py} | 0 .../test_move.py => todo/todo_move.py} | 0 .../test_rename.py => todo/todo_rename.py} | 0 27 files changed, 544 insertions(+), 705 deletions(-) create mode 100644 organize/filters/utils.py delete mode 100644 tests/core/test_utils.py delete mode 100644 tests/filters/test_filename.py delete mode 100644 tests/filters/test_last_modified.py create mode 100644 tests/filters/test_lastmodified.py create mode 100644 tests/filters/test_name.py rename tests/filters/{test_filesize.py => test_size.py} (100%) rename tests/{actions/test_copy.py => todo/todo_copy.py} (100%) rename tests/{actions/test_move.py => todo/todo_move.py} (100%) rename tests/{actions/test_rename.py => todo/todo_rename.py} (100%) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index be070469..a69f68d3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,9 +2,14 @@ name: tests on: push: - branches: [master] + paths-ignore: + - "docs/**" + - "*.md" + branches: + - v2 pull_request: - branches: [master] + branches: + - v2 workflow_dispatch: jobs: @@ -13,14 +18,11 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: [3.6, 3.7, 3.8, 3.9] - + python-version: ["3.6.x", "3.7.x", "3.8.x", "3.9.x"] + fail-fast: false steps: - - name: Checkout sources - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v2 + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} @@ -28,25 +30,17 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install poetry + poetry run python -m install -U pip poetry install -E textract - - name: General info + - name: Version info run: | - poetry run python main.py list - poetry run python main.py config --path poetry run python main.py --version - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - poetry run flake8 --count --select=E9,F63,F7,F82 --show-source --statistics organize - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - poetry run flake8 --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics organize - - name: Test with pytest run: | poetry run pytest - name: Check with MyPy run: | - poetry run mypy -porganize + poetry run mypy organize diff --git a/organize/config.py b/organize/config.py index aac78351..fd529e0b 100644 --- a/organize/config.py +++ b/organize/config.py @@ -1,11 +1,14 @@ import textwrap +from collections import defaultdict import yaml -from schema import And, Optional, Or, Schema, Literal +from schema import And, Literal, Optional, Or, Schema from organize.actions import ACTIONS from organize.filters import FILTERS +from .utils import flatten_all_lists_in_dict + CONFIG_SCHEMA = Schema( { Literal("rules", description="All rules are defined here."): [ @@ -14,7 +17,7 @@ Optional("enabled"): bool, Optional("subfolders"): bool, Optional("filter_mode", description="The filter mode."): Or( - "all", "any", "none", error='Invalid filter mode' + "all", "any", "none", error="Invalid filter mode" ), Optional( "targets", @@ -69,3 +72,23 @@ def load_from_string(config): def load_from_file(path): with open(path, "r", encoding="utf-8") as f: return load_from_string(f.read()) + + +def cleanup(config: dict): + result = defaultdict(list) + + # delete every root key except "rules" + for rule in config.get("rules", []): + # delete disabled rules + if rule.get("enabled", True): + result["rules"].append(rule) + + if not result: + raise ValueError("No rules defined.") + + # flatten all lists everywhere + return flatten_all_lists_in_dict(dict(result)) + + +def validate(config: dict): + return CONFIG_SCHEMA.validate(config) diff --git a/organize/core.py b/organize/core.py index f72ee5c2..516937a6 100644 --- a/organize/core.py +++ b/organize/core.py @@ -1,6 +1,6 @@ import logging import os -from collections import Counter, defaultdict +from collections import Counter from datetime import datetime from pathlib import Path from typing import Iterable, NamedTuple @@ -12,21 +12,12 @@ from rich.console import Console from schema import SchemaError -from . import console +from . import config, console from .actions import ACTIONS from .actions.action import Action -from .config import CONFIG_SCHEMA, load_from_file from .filters import FILTERS from .filters.filter import Filter -from .utils import ( - Template, - deep_merge_inplace, - ensure_dict, - ensure_list, - flatten_all_lists_in_dict, - to_args, -) - +from .utils import Template, deep_merge_inplace, ensure_dict, ensure_list, to_args logger = logging.getLogger(__name__) highlighted_console = Console() @@ -52,22 +43,6 @@ class Location(NamedTuple): ] -def config_cleanup(rules): - result = defaultdict(list) - - # delete every root key except "rules" - for rule in rules.get("rules", []): - # delete disabled rules - if rule.get("enabled", True): - result["rules"].append(rule) - - if not result: - raise ValueError("No rules defined.") - - # flatten all lists everywhere - return flatten_all_lists_in_dict(dict(result)) - - def walker_args_from_location_options(options): # combine system_exclude and exclude into a single list excludes = options.get("system_exlude_files", DEFAULT_SYSTEM_EXCLUDE_FILES) @@ -279,9 +254,9 @@ def run(config, simulate: bool = True): def run_file(config_file: str, working_dir: str, simulate: bool): try: console.info(config_file, working_dir) - rules = load_from_file(config_file) - rules = config_cleanup(rules) - CONFIG_SCHEMA.validate(rules) + rules = config.load_from_file(config_file) + rules = config.cleanup(rules) + config.validate(rules) warnings = replace_with_instances(rules) for msg in warnings: console.warn(msg) diff --git a/organize/filters/created.py b/organize/filters/created.py index 5ca56a33..72783915 100644 --- a/organize/filters/created.py +++ b/organize/filters/created.py @@ -1,9 +1,11 @@ from datetime import datetime, timedelta +from typing import Union from fs.base import FS from schema import Optional, Or from .filter import Filter, FilterResult +from .utils import age_condition_applies class Created(Filter): @@ -18,12 +20,12 @@ class Created(Filter): minutes (float): specify number of minutes seconds (float): specify number of seconds mode (str): - either 'older' or 'newer'. 'older' matches all files created before the given - time, 'newer' matches all files created within the given time. + either 'older' or 'newer'. 'older' matches files / folders created before the given + time, 'newer' matches files / folders created within the given time. (default = 'older') Returns: - {created}: The datetime the file / dir was created. + {created}: The datetime the file / folder was created. """ name = "created" @@ -49,46 +51,48 @@ def __init__( minutes=0, seconds=0, mode="older", - ) -> None: - self._mode = mode.strip().lower() - if self._mode not in ("older", "newer"): - raise ValueError("Unknown option for 'mode': must be 'older' or 'newer'.") - self.should_be_older = self._mode == "older" - self.timedelta = timedelta( + ): + self.age = timedelta( weeks=52 * years + 4 * months + weeks, # quick and a bit dirty days=days, hours=hours, minutes=minutes, seconds=seconds, ) + self.mode = mode.strip().lower() + if self.mode not in ("older", "newer"): + raise ValueError("Unknown option for 'mode': must be 'older' or 'newer'.") + + def matches_created_time(self, created: Union[None, datetime]): + match = True + if self.age.total_seconds(): + if not created: + match = False + else: + match = age_condition_applies( + dt=created, + age=self.age, + mode=self.mode, + reference=datetime.now(), + ) + return match def pipeline(self, args: dict) -> FilterResult: fs = args["fs"] # type: FS fs_path = args["fs_path"] - file_created: Optional[datetime] - file_created = fs.getinfo(fs_path, namespaces=["details"]).created - if file_created: - file_created = file_created.astimezone() - - if self.timedelta.total_seconds(): - if not file_created: - match = False - else: - is_past = ( - file_created + self.timedelta - ).timestamp() < datetime.now().timestamp() - match = self.should_be_older == is_past - else: - match = True + created = fs.getinfo(fs_path, namespaces=["details"]).created + if created: + created = created.astimezone() + match = self.matches_created_time(created) return FilterResult( matches=match, - updates={self.get_name(): file_created}, + updates={self.get_name(): created}, ) def __str__(self): - return "[Created] All files %s than %s" % ( + return "[Created] All files / folders %s than %s" % ( self._mode, self.timedelta, ) diff --git a/organize/filters/lastmodified.py b/organize/filters/lastmodified.py index 3cb54751..dd749aa9 100644 --- a/organize/filters/lastmodified.py +++ b/organize/filters/lastmodified.py @@ -1,15 +1,18 @@ -from schema import Or, Optional from datetime import datetime, timedelta -from typing import Dict, Optional as tyOptional +from typing import Union + +from fs.base import FS +from schema import Optional, Or from .filter import Filter, FilterResult +from .utils import age_condition_applies class LastModified(Filter): """Matches files by last modified date - Args: + Args: years (int): specify number of years months (int): specify number of months weeks (float): specify number of weeks @@ -18,12 +21,12 @@ class LastModified(Filter): minutes (float): specify number of minutes seconds (float): specify number of seconds mode (str): - either 'older' or 'newer'. 'older' matches all files created before the given - time, 'newer' matches all files created within the given time. - (default = 'older') + either 'older' or 'newer'. 'older' matches files / folders last modified before + the given time, 'newer' matches files / folders last modified within the given + time. (default = 'older') Returns: - {lastmodified}: The datetime the file / dir was created. + {lastmodified}: The datetime the files / folders was lastmodified. """ name = "lastmodified" @@ -49,44 +52,48 @@ def __init__( minutes=0, seconds=0, mode="older", - ) -> None: - self._mode = mode.strip().lower() - if self._mode not in ("older", "newer"): - raise ValueError("Unknown option for 'mode': must be 'older' or 'newer'.") - self.should_be_older = self._mode == "older" - self.timedelta = timedelta( - weeks=years * 52 + months * 4 + weeks, # quick and a bit dirty + ): + self.age = timedelta( + weeks=52 * years + 4 * months + weeks, # quick and a bit dirty days=days, hours=hours, minutes=minutes, seconds=seconds, ) + self.mode = mode.strip().lower() + if self.mode not in ("older", "newer"): + raise ValueError("Unknown option for 'mode': must be 'older' or 'newer'.") - def pipeline(self, args: dict) -> FilterResult: - fs = args["fs"] - fs_path = args["fs_path"] - file_modified: datetime - file_modified = fs.getmodified(fs_path) - if file_modified: - file_modified = file_modified.astimezone() - if self.timedelta.total_seconds(): - if not file_modified: + def matches_lastmodified_time(self, lastmodified: Union[None, datetime]): + match = True + if self.age.total_seconds(): + if not lastmodified: match = False else: - is_past = ( - file_modified + self.timedelta - ).timestamp() < datetime.now().timestamp() - match = self.should_be_older == is_past - else: - match = True + match = age_condition_applies( + dt=lastmodified, + age=self.age, + mode=self.mode, + reference=datetime.now(), + ) + return match + + def pipeline(self, args: dict) -> FilterResult: + fs = args["fs"] # type: FS + fs_path = args["fs_path"] + + modified = fs.getmodified(fs_path) + if modified: + modified = modified.astimezone() + match = self.matches_lastmodified_time(modified) return FilterResult( matches=match, - updates={self.get_name(): file_modified}, + updates={self.get_name(): modified}, ) def __str__(self): - return "[LastModified] All files last modified %s than %s" % ( + return "[LastModified] All files / folders last modified %s than %s" % ( self._mode, self.timedelta, ) diff --git a/organize/filters/utils.py b/organize/filters/utils.py new file mode 100644 index 00000000..4c9c479e --- /dev/null +++ b/organize/filters/utils.py @@ -0,0 +1,12 @@ +from datetime import datetime, timedelta + + +def age_condition_applies(dt: datetime, age: timedelta, mode: str, reference: datetime): + """ + Returns whether `dt` is older / newer (`mode`) than `age` as measured on `reference` + """ + if mode not in ("older", "newer"): + raise ValueError(mode) + + is_past = (dt + age).timestamp() < reference.timestamp() + return (mode == "older") == is_past diff --git a/organize/utils.py b/organize/utils.py index 417ad9b8..681b0d1f 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -2,7 +2,8 @@ from typing import Any, List, Sequence import jinja2 -from fs import open_fs, path as fspath +from fs import open_fs +from fs import path as fspath from fs.base import FS from fs.memoryfs import MemoryFS from fs.osfs import OSFS @@ -146,13 +147,13 @@ def flatten_all_lists_in_dict(obj): return obj -def deep_merge(a: dict, b: dict) -> dict: +def deep_merge(a: dict, b: dict, *, add_keys=True) -> dict: result = deepcopy(a) for bk, bv in b.items(): av = result.get(bk) if isinstance(av, dict) and isinstance(bv, dict): - result[bk] = deep_merge(av, bv) - else: + result[bk] = deep_merge(av, bv, add_keys=add_keys) + elif (av is not None) or add_keys: result[bk] = deepcopy(bv) return result diff --git a/pyproject.toml b/pyproject.toml index a8cb6d63..9b555d12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,13 @@ ignore_missing_imports = true [tool.pytest.ini_options] addopts = "--doctest-modules" +testpaths = ["tests", "organize"] +norecursedirs = [ + "tests/todo", + "tests/integration", + "tests/filters", + "organize/filters", +] [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/actions/test_echo.py b/tests/actions/test_echo.py index a8533bb9..0751f3af 100644 --- a/tests/actions/test_echo.py +++ b/tests/actions/test_echo.py @@ -1,25 +1,24 @@ +from datetime import datetime from organize.actions import Echo -from pathlib import Path - from unittest.mock import patch def test_echo_basic(): echo = Echo("Hello World") with patch.object(echo, "print") as m: - echo.run(path=Path("~"), simulate=False) + echo.run(simulate=False) m.assert_called_with("Hello World") def test_echo_args(): - echo = Echo("This is the year {year}") + echo = Echo('Date formatting: {now.strftime("%Y-%m-%d")}') with patch.object(echo, "print") as m: - echo.run(path=Path("~"), simulate=False, year=2017) - m.assert_called_with("This is the year 2017") + echo.run(simulate=False, now=datetime(2019, 1, 5)) + m.assert_called_with("Date formatting: 2019-01-05") def test_echo_path(): - echo = Echo("{path.stem} for {year}") + echo = Echo("{year}") with patch.object(echo, "print") as m: - echo.run(simulate=False, path=Path("/this/isafile.txt"), year=2017) - m.assert_called_with("isafile for 2017") + echo.run(simulate=False, year=2017) + m.assert_called_with("2017") diff --git a/tests/actions/test_shell.py b/tests/actions/test_shell.py index 1063745b..51be3089 100644 --- a/tests/actions/test_shell.py +++ b/tests/actions/test_shell.py @@ -5,21 +5,15 @@ def test_shell_basic(): - with patch("subprocess.call") as m: - shell = Shell("echo 'Hello World'") - shell.run(path=Path.home(), simulate=False) - m.assert_called_with("echo 'Hello World'", shell=True) + shell = Shell("echo 'Hello World'") + result = shell.run(simulate=True) + assert not result + result = shell.run(simulate=False) + assert result["shell"] == {"output": "Hello World\n", "returncode": 0} -def test_shell_args(): - with patch("subprocess.call") as m: - shell = Shell("echo {year}") - shell.run(path=Path.home(), year=2017, simulate=False) - m.assert_called_with("echo 2017", shell=True) - -def test_shell_path(): - with patch("subprocess.call") as m: - shell = Shell("echo {path.stem} for {year}") - shell.run(path=Path("/") / "this" / "isafile.txt", year=2017, simulate=False) - m.assert_called_with("echo isafile for 2017", shell=True) +def test_shell_template_simulation(): + shell = Shell("echo '{msg}'", run_in_simulation=True) + result = shell.run(msg="Hello", simulate=True) + assert result["shell"] == {"output": "Hello\n", "returncode": 0} diff --git a/tests/actions/test_trash.py b/tests/actions/test_trash.py index 31dca3ba..258a1025 100644 --- a/tests/actions/test_trash.py +++ b/tests/actions/test_trash.py @@ -1,12 +1,10 @@ -import os +from unittest.mock import patch from organize.actions import Trash -from pathlib import Path -USER_DIR = os.path.expanduser("~") - -def test_trash(mock_trash): - trash = Trash() - trash.run(path=Path.home() / "this" / "file.tar.gz", simulate=False) - mock_trash.assert_called_with(os.path.join(USER_DIR, "this", "file.tar.gz")) +def test_trash(): + with patch("send2trash.send2trash") as mck: + trash = Trash() + trash.trash(path="~/Desktop/Test.zip", simulate=False) + mck.assert_called_with("~/Desktop/Test.zip") diff --git a/tests/conftest.py b/tests/conftest.py index ce285cd4..95b49ac8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,38 +1,31 @@ import os -from typing import Iterable, Tuple, Union from unittest.mock import patch import pytest -from pathlib import Path -TESTS_FOLDER = os.path.dirname(os.path.abspath(__file__)) +# def create_filesystem(tmp_path, files, config): +# # create files +# for f in files: +# try: +# name, content = f +# except Exception: +# name = f +# content = "" +# p = tmp_path / "files" / Path(name) +# p.parent.mkdir(parents=True, exist_ok=True) +# with p.open("w") as ptr: +# ptr.write(content) +# # create config +# with (tmp_path / "config.yaml").open("w") as f: +# f.write(config) +# # change working directory +# os.chdir(str(tmp_path)) -TESTS_FOLDER = os.path.dirname(os.path.abspath(__file__)) - -def create_filesystem(tmp_path, files, config): - # create files - for f in files: - try: - name, content = f - except Exception: - name = f - content = "" - p = tmp_path / "files" / Path(name) - p.parent.mkdir(parents=True, exist_ok=True) - with p.open("w") as ptr: - ptr.write(content) - # create config - with (tmp_path / "config.yaml").open("w") as f: - f.write(config) - # change working directory - os.chdir(str(tmp_path)) - - -def assertdir(path, *files): - os.chdir(str(path / "files")) - assert set(files) == set(str(x) for x in Path(".").glob("**/*") if x.is_file()) +# def assertdir(path, *files): +# os.chdir(str(path / "files")) +# assert set(files) == set(str(x) for x in Path(".").glob("**/*") if x.is_file()) @pytest.fixture @@ -69,27 +62,3 @@ def mock_copy(): def mock_remove(): with patch("os.remove") as mck: yield mck - - -@pytest.fixture -def mock_trash(): - with patch("send2trash.send2trash") as mck: - yield mck - - -@pytest.fixture -def mock_parent(): - with patch.object(Path, "parent") as mck: - yield mck - - -@pytest.fixture -def mock_mkdir(): - with patch.object(Path, "mkdir") as mck: - yield mck - - -@pytest.fixture -def mock_echo(): - with patch("organize.actions.Echo.print") as mck: - yield mck diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 1a7e4420..10785d70 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -1,94 +1,42 @@ import pytest -from organize.actions import Echo, Move, Shell, Trash, Rename -from organize.config import Config, Rule -from organize.filters import Extension, LastModified, FileContent, Filename +from organize import config, core +from schema import SchemaError + + +def validate_and_convert(string: str): + conf = config.load_from_string(string) + conf = config.cleanup(conf) + config.validate(conf) + core.replace_with_instances(conf) + return conf def test_basic(): - config = """ + STR = """ rules: - - folders: '~/Desktop' + - locations: '~/Desktop' filters: - extension: - jpg - png - extension: txt actions: - - move: {dest: '~/Desktop/New Folder', overwrite: true} - - echo: 'Moved {path}/{extension.upper}' - - folders: - - '~/test1' - - '/test2' - filters: + - move: + dest: '~/Desktop/New Folder' + - echo: 'Moved {path}/{extension.upper()}' + - locations: + - path: '~/test1' + ignore_errors: true actions: - shell: cmd: 'say {path.stem}' """ - conf = Config.from_string(config) - assert conf.rules == [ - Rule( - folders=["~/Desktop"], - filters=[Extension(".JPG", "PNG"), Extension("txt")], - actions=[ - Move(dest="~/Desktop/New Folder", overwrite=True), - Echo(msg="Moved {path}/{extension.upper}"), - ], - subfolders=False, - system_files=False, - ), - Rule( - folders=["~/test1", "/test2"], - filters=[], - actions=[Shell(cmd="say {path.stem}")], - subfolders=False, - system_files=False, - ), - ] - - -def test_case_insensitive(): - config = """ - rules: - - folders: '~/Desktop' - filters: - - extension: ['JPg', 'png'] - - Extension: txt - actions: - - moVe: {dest: '~/Desktop/New Folder', overwrite: true} - - EC_HO: 'Moved {path}/{extension.upper}' - - folders: - - '~/test1' - - /test2 - filters: - actions: - - SHELL: - cmd: 'say {path.stem}' - """ - conf = Config.from_string(config) - assert conf.rules == [ - Rule( - folders=["~/Desktop"], - filters=[Extension(".JPG", "PNG"), Extension("txt")], - actions=[ - Move(dest="~/Desktop/New Folder", overwrite=True), - Echo(msg="Moved {path}/{extension.upper}"), - ], - subfolders=False, - system_files=False, - ), - Rule( - folders=["~/test1", "/test2"], - filters=[], - actions=[Shell(cmd="say {path.stem}")], - subfolders=False, - system_files=False, - ), - ] + validate_and_convert(STR) def test_yaml_ref(): - config = """ + STR = """ media: &media - wav - png @@ -98,7 +46,7 @@ def test_yaml_ref(): - ~/Documents rules: - - folders: *all + - locations: *all filters: - extension: *media - extension: @@ -107,238 +55,215 @@ def test_yaml_ref(): - lastmodified: days: 10 actions: - - echo: - msg: 'Hello World' - - folders: + - echo: 'Hello World' + - locations: - *all - - /more/more - filters: + - path: /more/more + ignore_errors: true actions: - trash """ - conf = Config.from_string(config) - assert conf.rules == [ - Rule( - folders=["~/Desktop", "~/Documents"], - filters=[ - Extension(".wav", ".PNG"), - Extension(".wav", ".PNG", "jpg"), - LastModified(days=10), - ], - actions=[Echo(msg="Hello World")], - subfolders=False, - system_files=False, - ), - Rule( - folders=["~/Desktop", "~/Documents", "/more/more"], - filters=[], - actions=[Trash()], - subfolders=False, - system_files=False, - ), - ] + validate_and_convert(STR) def test_error_filter_dict(): - conf = Config.from_string( - """ + STR = """ rules: - - folders: '/' + - locations: '/' filters: - Extension: 'jpg' + extension: 'jpg' actions: - trash """ - ) - with pytest.raises(Config.FiltersNoListError): - _ = conf.rules + with pytest.raises(SchemaError): + validate_and_convert(STR) -def test_error_action_dict(): - conf = Config.from_string( - """ - rules: - - folders: '/' - filters: - - extension: 'jpg' - actions: - Trash - """ - ) - with pytest.raises(Config.ActionsNoListError): - _ = conf.rules +# def test_error_action_dict(): +# conf = Config.from_string( +# """ +# rules: +# - folders: '/' +# filters: +# - extension: 'jpg' +# actions: +# Trash +# """ +# ) +# with pytest.raises(Config.ActionsNoListError): +# _ = conf.rules -def test_empty_filters(): - conf = """ - rules: - - folders: '/' - filters: - actions: - - trash - - folders: '~/' - actions: - - trash - """ - assert Config.from_string(conf).rules == [ - Rule( - folders=["/"], - filters=[], - actions=[Trash()], - subfolders=False, - system_files=False, - ), - Rule( - folders=["~/"], - filters=[], - actions=[Trash()], - subfolders=False, - system_files=False, - ), - ] +# def test_empty_filters(): +# conf = """ +# rules: +# - folders: '/' +# filters: +# actions: +# - trash +# - folders: '~/' +# actions: +# - trash +# """ +# assert Config.from_string(conf).rules == [ +# Rule( +# folders=["/"], +# filters=[], +# actions=[Trash()], +# subfolders=False, +# system_files=False, +# ), +# Rule( +# folders=["~/"], +# filters=[], +# actions=[Trash()], +# subfolders=False, +# system_files=False, +# ), +# ] -@pytest.mark.skip -def test_flatten_filters_and_actions(): - config = """ - folder_aliases: - Downloads: &downloads ~/Downloads/ - Payables_due: &payables_due ~/PayablesDue/ - Payables_paid: &payables_paid ~/Accounting/Expenses/ - Receivables_due: &receivables_due ~/Receivables/ - Receivables_paid: &receivables_paid ~/Accounting/Income/ +# @pytest.mark.skip +# def test_flatten_filters_and_actions(): +# config = """ +# folder_aliases: +# Downloads: &downloads ~/Downloads/ +# Payables_due: &payables_due ~/PayablesDue/ +# Payables_paid: &payables_paid ~/Accounting/Expenses/ +# Receivables_due: &receivables_due ~/Receivables/ +# Receivables_paid: &receivables_paid ~/Accounting/Income/ - defaults: - filters: &default_filters - - extension: pdf - - filecontent: '(?P...)' - actions: &default_actions - - echo: 'Dated: {filecontent.date}' - - echo: 'Stem of filename: {filecontent.stem}' - post_actions: &default_sorting - - rename: '{python.timestamp}-{filecontent.stem}.{extension.lower}' - - move: '{path.parent}/{python.quarter}/' +# defaults: +# filters: &default_filters +# - extension: pdf +# - filecontent: '(?P...)' +# actions: &default_actions +# - echo: 'Dated: {filecontent.date}' +# - echo: 'Stem of filename: {filecontent.stem}' +# post_actions: &default_sorting +# - rename: '{python.timestamp}-{filecontent.stem}.{extension.lower}' +# - move: '{path.parent}/{python.quarter}/' - rules: - - folders: *downloads - filters: - - *default_filters - - filecontent: 'Due Date' # regex to id as payable - - filecontent: '(?P...)' # regex to extract supplier - actions: - - *default_actions - - move: *payables_due - - *default_sorting +# rules: +# - folders: *downloads +# filters: +# - *default_filters +# - filecontent: 'Due Date' # regex to id as payable +# - filecontent: '(?P...)' # regex to extract supplier +# actions: +# - *default_actions +# - move: *payables_due +# - *default_sorting - - folders: *downloads - filters: - - *default_filters - - filecontent: 'Account: 000000000' # regex to id as receivables due - - filecontent: '(?P...)' # regex to extract customer - actions: - - *default_actions - - move: *receivables_due - - *default_sorting +# - folders: *downloads +# filters: +# - *default_filters +# - filecontent: 'Account: 000000000' # regex to id as receivables due +# - filecontent: '(?P...)' # regex to extract customer +# actions: +# - *default_actions +# - move: *receivables_due +# - *default_sorting - - folders: *downloads - filters: - - *default_filters - - filecontent: 'PAID' # regex to id as receivables paid - - filecontent: '(?P...)' # regex to extract customer - - filecontent: '(?P...)' # regex to extract date paid - - filename: - startswith: 2020 - actions: - - *default_actions - - move: *receivables_paid - - *default_sorting - - rename: '{filecontent.paid}_{filecontent.stem}.{extension}' - """ - conf = Config.from_string(config) - assert conf.rules == [ - Rule( - folders=["~/Downloads/"], - filters=[ - # default_filters - Extension("pdf"), - FileContent(expr="(?P...)"), - # added filters - FileContent(expr="Due Date"), - FileContent(expr="(?P...)"), - ], - actions=[ - # default_actions - Echo(msg="Dated: {filecontent.date}"), - Echo(msg="Stem of filename: {filecontent.stem}"), - # added actions - Move(dest="~/PayablesDue/", overwrite=False), - # default_sorting - Rename( - name="{python.timestamp}-{filecontent.stem}.{extension.lower}", - overwrite=False, - ), - Move(dest="{path.parent}/{python.quarter}/", overwrite=False), - ], - subfolders=False, - system_files=False, - ), - Rule( - folders=["~/Downloads/"], - filters=[ - # default_filters - Extension("pdf"), - FileContent(expr="(?P...)"), - # added filters - FileContent(expr="Account: 000000000"), - FileContent(expr="(?P...)"), - ], - actions=[ - # default_actions - Echo(msg="Dated: {filecontent.date}"), - Echo(msg="Stem of filename: {filecontent.stem}"), - # added actions - Move(dest="~/Receivables/", overwrite=False), - # default_sorting - Rename( - name="{python.timestamp}-{filecontent.stem}.{extension.lower}", - overwrite=False, - ), - Move(dest="{path.parent}/{python.quarter}/", overwrite=False), - ], - subfolders=False, - system_files=False, - ), - Rule( - folders=["~/Downloads/"], - filters=[ - # default_filters - Extension("pdf"), - FileContent(expr="(?P...)"), - # added filters - FileContent(expr="PAID"), - FileContent(expr="(?P...)"), - FileContent(expr="(?P...)"), - Filename(startswith="2020"), - ], - actions=[ - # default_actions - Echo(msg="Dated: {filecontent.date}"), - Echo(msg="Stem of filename: {filecontent.stem}"), - # added actions - Move(dest="~/Accounting/Income/", overwrite=False), - # default_sorting - Rename( - name="{python.timestamp}-{filecontent.stem}.{extension.lower}", - overwrite=False, - ), - Move(dest="{path.parent}/{python.quarter}/", overwrite=False), - # added actions - Rename( - name="{filecontent.paid}_{filecontent.stem}.{extension}", - overwrite=False, - ), - ], - subfolders=False, - system_files=False, - ), - ] +# - folders: *downloads +# filters: +# - *default_filters +# - filecontent: 'PAID' # regex to id as receivables paid +# - filecontent: '(?P...)' # regex to extract customer +# - filecontent: '(?P...)' # regex to extract date paid +# - filename: +# startswith: 2020 +# actions: +# - *default_actions +# - move: *receivables_paid +# - *default_sorting +# - rename: '{filecontent.paid}_{filecontent.stem}.{extension}' +# """ +# conf = Config.from_string(config) +# assert conf.rules == [ +# Rule( +# folders=["~/Downloads/"], +# filters=[ +# # default_filters +# Extension("pdf"), +# FileContent(expr="(?P...)"), +# # added filters +# FileContent(expr="Due Date"), +# FileContent(expr="(?P...)"), +# ], +# actions=[ +# # default_actions +# Echo(msg="Dated: {filecontent.date}"), +# Echo(msg="Stem of filename: {filecontent.stem}"), +# # added actions +# Move(dest="~/PayablesDue/", overwrite=False), +# # default_sorting +# Rename( +# name="{python.timestamp}-{filecontent.stem}.{extension.lower}", +# overwrite=False, +# ), +# Move(dest="{path.parent}/{python.quarter}/", overwrite=False), +# ], +# subfolders=False, +# system_files=False, +# ), +# Rule( +# folders=["~/Downloads/"], +# filters=[ +# # default_filters +# Extension("pdf"), +# FileContent(expr="(?P...)"), +# # added filters +# FileContent(expr="Account: 000000000"), +# FileContent(expr="(?P...)"), +# ], +# actions=[ +# # default_actions +# Echo(msg="Dated: {filecontent.date}"), +# Echo(msg="Stem of filename: {filecontent.stem}"), +# # added actions +# Move(dest="~/Receivables/", overwrite=False), +# # default_sorting +# Rename( +# name="{python.timestamp}-{filecontent.stem}.{extension.lower}", +# overwrite=False, +# ), +# Move(dest="{path.parent}/{python.quarter}/", overwrite=False), +# ], +# subfolders=False, +# system_files=False, +# ), +# Rule( +# folders=["~/Downloads/"], +# filters=[ +# # default_filters +# Extension("pdf"), +# FileContent(expr="(?P...)"), +# # added filters +# FileContent(expr="PAID"), +# FileContent(expr="(?P...)"), +# FileContent(expr="(?P...)"), +# Filename(startswith="2020"), +# ], +# actions=[ +# # default_actions +# Echo(msg="Dated: {filecontent.date}"), +# Echo(msg="Stem of filename: {filecontent.stem}"), +# # added actions +# Move(dest="~/Accounting/Income/", overwrite=False), +# # default_sorting +# Rename( +# name="{python.timestamp}-{filecontent.stem}.{extension.lower}", +# overwrite=False, +# ), +# Move(dest="{path.parent}/{python.quarter}/", overwrite=False), +# # added actions +# Rename( +# name="{filecontent.paid}_{filecontent.stem}.{extension}", +# overwrite=False, +# ), +# ], +# subfolders=False, +# system_files=False, +# ), +# ] diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py deleted file mode 100644 index a3b70f68..00000000 --- a/tests/core/test_utils.py +++ /dev/null @@ -1,52 +0,0 @@ -def test_unused_filename_basic(mock_exists): - mock_exists.return_value = False - assert find_unused_filename(Path("somefile.jpg")) == Path("somefile 2.jpg") - - -def test_unused_filename_separator(mock_exists): - mock_exists.return_value = False - assert find_unused_filename(Path("somefile.jpg"), separator="_") == Path( - "somefile_2.jpg" - ) - - -def test_unused_filename_multiple(mock_exists): - mock_exists.side_effect = [True, True, False] - assert find_unused_filename(Path("somefile.jpg")) == Path("somefile 4.jpg") - - -def test_unused_filename_increase(mock_exists): - mock_exists.side_effect = [True, False] - assert find_unused_filename(Path("somefile 7.jpg")) == Path("somefile 9.jpg") - - -def test_unused_filename_increase_digit(mock_exists): - mock_exists.side_effect = [True, False] - assert find_unused_filename(Path("7.gif")) == Path("7 3.gif") - - -def test_increment_filename_version(): - assert ( - increment_filename_version(Path.home() / "f3" / "test_123.7z") - == Path.home() / "f3" / "test_123 2.7z" - ) - assert ( - increment_filename_version(Path.home() / "f3" / "test_123_2 10.7z") - == Path.home() / "f3" / "test_123_2 11.7z" - ) - - -def test_increment_filename_version_separator(): - assert increment_filename_version(Path("test_123.7z"), separator="_") == Path( - "test_124.7z" - ) - assert increment_filename_version(Path("test_123_2.7z"), separator="_") == Path( - "test_123_3.7z" - ) - - -def test_increment_filename_version_no_separator(): - assert increment_filename_version(Path("test.7z"), separator="") == Path("test2.7z") - assert increment_filename_version(Path("test 10.7z"), separator="") == Path( - "test 102.7z" - ) diff --git a/tests/core/test_utils_merge.py b/tests/core/test_utils_merge.py index 65e71b4f..1c7e3aab 100644 --- a/tests/core/test_utils_merge.py +++ b/tests/core/test_utils_merge.py @@ -35,28 +35,20 @@ def test_inserts_new_keys(): assert deep_merge(a, b)["c"] == 6 -@mark.skip def test_does_not_insert_new_keys(): """Will it avoid inserting new keys when required?""" a = {"a": 1, "b": {"b1": 2, "b2": 3}} b = {"a": 1, "b": {"b1": 4, "b3": 5}, "c": 6} - assert deep_merge(a, b, add_keys=False)["a"] == 1 - assert deep_merge(a, b, add_keys=False)["b"]["b2"] == 3 - assert deep_merge(a, b, add_keys=False)["b"]["b1"] == 4 - try: - assert deep_merge(a, b, add_keys=False)["b"]["b3"] == 5 - except KeyError: - pass - else: - raise Exception("New keys added when they should not be") - - try: - assert deep_merge(a, b, add_keys=False)["b"]["b3"] == 6 - except KeyError: - pass - else: - raise Exception("New keys added when they should not be") + assert deep_merge(a, b, add_keys=True) == { + "a": 1, + "b": {"b1": 4, "b2": 3, "b3": 5}, + "c": 6, + } + assert deep_merge(a, b, add_keys=False) == { + "a": 1, + "b": {"b1": 4, "b2": 3}, + } def test_inplace_merge(): diff --git a/tests/filters/test_created.py b/tests/filters/test_created.py index f49726ad..82af3f0a 100644 --- a/tests/filters/test_created.py +++ b/tests/filters/test_created.py @@ -1,26 +1,17 @@ -from unittest.mock import patch +from datetime import datetime, timedelta -import pendulum - -from pathlib import Path from organize.filters import Created def test_min(): - now = pendulum.now() + now = datetime.now() created = Created(days=10, hours=12, mode="older") - with patch.object(created, "_created") as mock_cr: - mock_cr.return_value = now - pendulum.duration(days=10, hours=0) - assert created.run(path=Path("~")) is None - mock_cr.return_value = now - pendulum.duration(days=10, hours=13) - assert created.run(path=Path("~")) + assert not created.matches_created_time(now - timedelta(days=10, hours=0)) + assert created.matches_created_time(now - timedelta(days=10, hours=13)) def test_max(): - now = pendulum.now() + now = datetime.now() created = Created(days=10, hours=12, mode="newer") - with patch.object(created, "_created") as mock_cr: - mock_cr.return_value = now - pendulum.duration(days=10, hours=0) - assert created.run(path=Path("~")) - mock_cr.return_value = now - pendulum.duration(days=10, hours=13) - assert created.run(path=Path("~")) is None + assert created.matches_created_time(now - timedelta(days=10, hours=0)) + assert not created.matches_created_time(now - timedelta(days=10, hours=13)) diff --git a/tests/filters/test_extension.py b/tests/filters/test_extension.py index ca7ed1eb..908a021a 100644 --- a/tests/filters/test_extension.py +++ b/tests/filters/test_extension.py @@ -20,14 +20,14 @@ def test_extension(): def test_extension_empty(): - fs = open_fs("mem://") - fs.touch("test.txt") - extension = Extension() - assert extension.run(fs=fs, fs_path="test.txt").matches + with open_fs("mem://") as mem: + mem.touch("test.txt") + extension = Extension() + assert extension.run(fs=mem, fs_path="test.txt").matches def test_extension_result(): - path = "~/somefile.TxT" + path = "somefile.TxT" extension = Extension("txt") assert extension.matches(path) result = extension.run(path=path)["extension"] diff --git a/tests/filters/test_filename.py b/tests/filters/test_filename.py deleted file mode 100644 index c7557e4e..00000000 --- a/tests/filters/test_filename.py +++ /dev/null @@ -1,99 +0,0 @@ -from organize.filters import Name - - -def test_filename_startswith(): - filename = Name(startswith="begin") - assert filename.matches("~/here/beginhere.pdf") - assert not filename.matches("~/here/.beginhere.pdf") - assert not filename.matches("~/here/herebegin.begin") - - -def test_filename_contains(): - filename = Name(contains="begin") - assert filename.matches("~/here/beginhere.pdf") - assert filename.matches("~/here/.beginhere.pdf") - assert filename.matches("~/here/herebegin.begin") - assert not filename.matches("~/here/other.begin") - - -def test_filename_endswith(): - filename = Name(endswith="end") - assert filename.matches("~/here/hereend.pdf") - assert not filename.matches("~/here/end.tar.gz") - assert not filename.matches("~/here/theendishere.txt") - - -def test_filename_multiple(): - filename = Name(startswith="begin", contains="con", endswith="end") - assert filename.matches("~/here/begin_somethgin_con_end.pdf") - assert not filename.matches("~/here/beginend.pdf") - assert not filename.matches("~/here/begincon.begin") - assert not filename.matches("~/here/conend.begin") - assert filename.matches("~/here/beginconend.begin") - - -def test_filename_case(): - filename = Name( - startswith="star", contains="con", endswith="end", case_sensitive=False - ) - assert filename.matches("~/STAR_conEnD.dpf") - assert not filename.matches("~/here/STAREND.pdf") - assert not filename.matches("~/here/STARCON.begin") - assert not filename.matches("~/here/CONEND.begin") - assert filename.matches("~/here/STARCONEND.begin") - - -def test_filename_list(): - filename = Name( - startswith="_", - contains=["1", "A", "3", "6"], - endswith=["5", "6"], - case_sensitive=False, - ) - assert filename.matches("~/_15.dpf") - assert filename.matches("~/_A5.dpf") - assert filename.matches("~/_A6.dpf") - assert filename.matches("~/_a6.dpf") - assert filename.matches("~/_35.dpf") - assert filename.matches("~/_36.dpf") - assert filename.matches("~/_somethinga56") - assert filename.matches("~/_6") - assert not filename.matches("~/") - assert not filename.matches("~/a_5") - - -def test_filename_list_case_sensitive(): - filename = Name( - startswith="_", - contains=["1", "A", "3", "7"], - endswith=["5", "6"], - case_sensitive=True, - ) - assert filename.matches("~/_15.dpf") - assert filename.matches("~/_A5.dpf") - assert filename.matches("~/_A6.dpf") - assert not filename.matches("~/_a6.dpf") - assert filename.matches("~/_35.dpf") - assert filename.matches("~/_36.dpf") - assert filename.matches("~/_somethingA56") - assert not filename.matches("~/_6") - assert not filename.matches("~/_a5.dpf") - assert not filename.matches("~/-A5.dpf") - assert not filename.matches("~/") - assert not filename.matches("~/_a5") - - -def test_filename_match(): - fn = Name("Invoice_*_{year:int}_{month}_{day}") - p = "~/Documents/Invoice_RE1001_2021_01_31.pdf" - assert fn.matches(p) - assert fn.run(p) == {"filename": {"year": 2021, "month": "01", "day": "31"}} - - -def test_filename_match_case_insensitive(): - case = Name("upper_{m1}_{m2}", case_sensitive=True) - icase = Name("upper_{m1}_{m2}", case_sensitive=False) - p = "~/Documents/UPPER_MiXed_lower.pdf" - assert icase.matches(p) - assert icase.run(path=p) == {"filename": {"m1": "MiXed", "m2": "lower"}} - assert not case.matches(p) diff --git a/tests/filters/test_last_modified.py b/tests/filters/test_last_modified.py deleted file mode 100644 index 7e8f105f..00000000 --- a/tests/filters/test_last_modified.py +++ /dev/null @@ -1,26 +0,0 @@ -from unittest.mock import patch - -import pendulum - -from pathlib import Path -from organize.filters import LastModified - - -def test_min(): - now = pendulum.now() - last_modified = LastModified(days=10, hours=12, mode="older") - with patch.object(last_modified, "_last_modified") as mock_lm: - mock_lm.return_value = now - pendulum.duration(days=10, hours=0) - assert not last_modified.run(path=Path("~")) - mock_lm.return_value = now - pendulum.duration(days=10, hours=13) - assert last_modified.run(path=Path("~")) - - -def test_max(): - now = pendulum.now() - last_modified = LastModified(days=10, hours=12, mode="newer") - with patch.object(last_modified, "_last_modified") as mock_lm: - mock_lm.return_value = now - pendulum.duration(days=10, hours=0) - assert last_modified.run(path=Path("~")) - mock_lm.return_value = now - pendulum.duration(days=10, hours=13) - assert not last_modified.run(path=Path("~")) diff --git a/tests/filters/test_lastmodified.py b/tests/filters/test_lastmodified.py new file mode 100644 index 00000000..1a4139a9 --- /dev/null +++ b/tests/filters/test_lastmodified.py @@ -0,0 +1,19 @@ +from datetime import datetime, timedelta + +from organize.filters import LastModified + + +def test_min(): + now = datetime.now() + lastmodified = LastModified(days=10, hours=12, mode="older") + assert not lastmodified.matches_lastmodified_time(now - timedelta(days=10, hours=0)) + assert lastmodified.matches_lastmodified_time(now - timedelta(days=10, hours=13)) + + +def test_max(): + now = datetime.now() + lastmodified = LastModified(days=10, hours=12, mode="newer") + assert lastmodified.matches_lastmodified_time(now - timedelta(days=10, hours=0)) + assert not lastmodified.matches_lastmodified_time( + now - timedelta(days=10, hours=13) + ) diff --git a/tests/filters/test_name.py b/tests/filters/test_name.py new file mode 100644 index 00000000..7c4c8253 --- /dev/null +++ b/tests/filters/test_name.py @@ -0,0 +1,108 @@ +import fs +from organize.filters import Name + + +def test_name_startswith(): + name = Name(startswith="begin") + assert name.matches("beginhere") + assert not name.matches(".beginhere") + assert not name.matches("herebegin") + + +def test_name_contains(): + name = Name(contains="begin") + assert name.matches("beginhere") + assert name.matches(".beginhere") + assert name.matches("herebegin") + assert not name.matches("other") + + +def test_name_endswith(): + name = Name(endswith="end") + assert name.matches("hereend") + assert name.matches("end") + assert not name.matches("theendishere") + + +def test_name_multiple(): + name = Name(startswith="begin", contains="con", endswith="end") + assert name.matches("begin_somethgin_con_end") + assert not name.matches("beginend") + assert not name.matches("begincon") + assert not name.matches("conend") + assert name.matches("beginconend") + + +def test_name_case(): + name = Name(startswith="star", contains="con", endswith="end", case_sensitive=False) + assert name.matches("STAR_conEnD") + assert not name.matches("STAREND") + assert not name.matches("STARCON") + assert not name.matches("CONEND") + assert name.matches("STARCONEND") + + +def test_name_list(): + name = Name( + startswith="_", + contains=["1", "A", "3", "6"], + endswith=["5", "6"], + case_sensitive=False, + ) + assert name.matches("_15") + assert name.matches("_A5") + assert name.matches("_A6") + assert name.matches("_a6") + assert name.matches("_35") + assert name.matches("_36") + assert name.matches("_somethinga56") + assert name.matches("_6") + assert not name.matches("") + assert not name.matches("a_5") + + +def test_name_list_case_sensitive(): + name = Name( + startswith="_", + contains=["1", "A", "3", "7"], + endswith=["5", "6"], + case_sensitive=True, + ) + assert name.matches("_15") + assert name.matches("_A5") + assert name.matches("_A6") + assert not name.matches("_a6") + assert name.matches("_35") + assert name.matches("_36") + assert name.matches("_somethingA56") + assert not name.matches("_6") + assert not name.matches("_a5") + assert not name.matches("-A5") + assert not name.matches("") + assert not name.matches("_a5") + + +def test_name_match(): + with fs.open_fs("mem://") as mem: + p = "Invoice_RE1001_2021_01_31" + fs_path = p + ".txt" + mem.touch(fs_path) + fn = Name("Invoice_*_{year:int}_{month}_{day}") + assert fn.matches(p) + result = fn.run(fs=mem, fs_path=fs_path) + assert result.matches + assert result.updates == {"name": {"year": 2021, "month": "01", "day": "31"}} + + +def test_name_match_case_insensitive(): + with fs.open_fs("mem://") as mem: + p = "UPPER_MiXed_lower" + fs_path = p + ".txt" + mem.touch(fs_path) + case = Name("upper_{m1}_{m2}", case_sensitive=True) + icase = Name("upper_{m1}_{m2}", case_sensitive=False) + assert icase.matches(p) + result = icase.run(fs=mem, fs_path=fs_path) + assert result.matches + assert result.updates == {"name": {"m1": "MiXed", "m2": "lower"}} + assert not case.matches(p) diff --git a/tests/filters/test_python.py b/tests/filters/test_python.py index a408ae8b..dfe68b3a 100644 --- a/tests/filters/test_python.py +++ b/tests/filters/test_python.py @@ -1,13 +1,12 @@ from organize.filters import Python -from pathlib import Path def test_basic(): p = Python( """ - print(path) - return 1 + return "some-string" """ ) - assert p.run(path=Path.home()) - assert p.run(path=Path.home()) == {"python": 1} + result = p.run() + assert result.matches + assert result.updates == {"python": "some-string"} diff --git a/tests/filters/test_regex.py b/tests/filters/test_regex.py index e3c02957..af00aaf3 100644 --- a/tests/filters/test_regex.py +++ b/tests/filters/test_regex.py @@ -1,6 +1,5 @@ from pathlib import Path from organize.filters import Regex -from organize.utils import DotDict TESTDATA = [ @@ -34,7 +33,7 @@ def test_regex_return(): def test_regex_umlaut(): regex = Regex(r"^Erträgnisaufstellung-(?P\d*)\.pdf") - doc = Path("~/Documents/Erträgnisaufstellung-1998.pdf") + doc = "Erträgnisaufstellung-1998.pdf" assert regex.matches(doc) dct = regex.run(path=doc) assert dct == {"regex": {"year": "1998"}} diff --git a/tests/filters/test_filesize.py b/tests/filters/test_size.py similarity index 100% rename from tests/filters/test_filesize.py rename to tests/filters/test_size.py diff --git a/tests/actions/test_copy.py b/tests/todo/todo_copy.py similarity index 100% rename from tests/actions/test_copy.py rename to tests/todo/todo_copy.py diff --git a/tests/actions/test_move.py b/tests/todo/todo_move.py similarity index 100% rename from tests/actions/test_move.py rename to tests/todo/todo_move.py diff --git a/tests/actions/test_rename.py b/tests/todo/todo_rename.py similarity index 100% rename from tests/actions/test_rename.py rename to tests/todo/todo_rename.py From ee48dd2dbd975c0245d3ef26e791d6c94f078402 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Wed, 2 Feb 2022 14:13:36 +0100 Subject: [PATCH 084/108] formatting --- tests/filters/test_created.py | 12 ++++++------ tests/filters/test_lastmodified.py | 14 ++++++-------- tests/integration/test_duplicate.py | 4 +--- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/tests/filters/test_created.py b/tests/filters/test_created.py index 82af3f0a..d3a3fc8b 100644 --- a/tests/filters/test_created.py +++ b/tests/filters/test_created.py @@ -5,13 +5,13 @@ def test_min(): now = datetime.now() - created = Created(days=10, hours=12, mode="older") - assert not created.matches_created_time(now - timedelta(days=10, hours=0)) - assert created.matches_created_time(now - timedelta(days=10, hours=13)) + ct = Created(days=10, hours=12, mode="older") + assert not ct.matches_created_time(now - timedelta(days=10, hours=0)) + assert ct.matches_created_time(now - timedelta(days=10, hours=13)) def test_max(): now = datetime.now() - created = Created(days=10, hours=12, mode="newer") - assert created.matches_created_time(now - timedelta(days=10, hours=0)) - assert not created.matches_created_time(now - timedelta(days=10, hours=13)) + ct = Created(days=10, hours=12, mode="newer") + assert ct.matches_created_time(now - timedelta(days=10, hours=0)) + assert not ct.matches_created_time(now - timedelta(days=10, hours=13)) diff --git a/tests/filters/test_lastmodified.py b/tests/filters/test_lastmodified.py index 1a4139a9..67cb4499 100644 --- a/tests/filters/test_lastmodified.py +++ b/tests/filters/test_lastmodified.py @@ -5,15 +5,13 @@ def test_min(): now = datetime.now() - lastmodified = LastModified(days=10, hours=12, mode="older") - assert not lastmodified.matches_lastmodified_time(now - timedelta(days=10, hours=0)) - assert lastmodified.matches_lastmodified_time(now - timedelta(days=10, hours=13)) + lm = LastModified(days=10, hours=12, mode="older") + assert not lm.matches_lastmodified_time(now - timedelta(days=10, hours=0)) + assert lm.matches_lastmodified_time(now - timedelta(days=10, hours=13)) def test_max(): now = datetime.now() - lastmodified = LastModified(days=10, hours=12, mode="newer") - assert lastmodified.matches_lastmodified_time(now - timedelta(days=10, hours=0)) - assert not lastmodified.matches_lastmodified_time( - now - timedelta(days=10, hours=13) - ) + lm = LastModified(days=10, hours=12, mode="newer") + assert lm.matches_lastmodified_time(now - timedelta(days=10, hours=0)) + assert not lm.matches_lastmodified_time(now - timedelta(days=10, hours=13)) diff --git a/tests/integration/test_duplicate.py b/tests/integration/test_duplicate.py index a0633c57..9567e361 100644 --- a/tests/integration/test_duplicate.py +++ b/tests/integration/test_duplicate.py @@ -29,9 +29,7 @@ def test_duplicate_smallfiles(tmp_path): """, ) main(["run", "--config-file=%s" % (tmp_path / "config.yaml")]) - assertdir( - tmp_path, "unique.txt", "unique_too.txt", "a.txt", "large_unique.txt" - ) + assertdir(tmp_path, "unique.txt", "unique_too.txt", "a.txt", "large_unique.txt") def test_duplicate_largefiles(tmp_path): From 9dfe8d86c41a796c9d6a3e642954e11fbfb75af9 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Wed, 2 Feb 2022 16:21:21 +0100 Subject: [PATCH 085/108] support running completely in mem for tests --- organize/cli.py | 18 ++-- organize/config.py | 7 +- organize/console.py | 21 ++--- organize/core.py | 72 +++++++++++----- tests/conftest.py | 95 ++++++++-------------- tests/integration/test_codepost_usecase.py | 21 ++++- 6 files changed, 129 insertions(+), 105 deletions(-) diff --git a/organize/cli.py b/organize/cli.py index 2f790183..b04b7e2e 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -7,7 +7,7 @@ import sys import click -from fs import appfs, osfs +from fs import appfs, osfs, open_fs from . import console from .__version__ import __version__ @@ -71,6 +71,14 @@ def list_commands(self, ctx): ) +def run_local(config_path: str, working_dir: str, simulate: bool): + from . import core + + console.info(config_path=config_path, working_dir=working_dir) + config = open_fs(".").readtext(config_path) + core.run(fs=open_fs(working_dir), rules=config, simulate=simulate) + + @click.group( help=__doc__, cls=NaturalOrderGroup, @@ -87,14 +95,12 @@ def cli(): @CLI_CONFIG_FILE_OPTION def run(config, working_dir, config_file): """Organizes your files according to your rules.""" - from .core import run_file - if config_file: config = config_file console.deprecated( "The --config-file option can now be omitted. See organize --help." ) - run_file(config_file=config, working_dir=working_dir, simulate=False) + run_local(config_path=config, working_dir=working_dir, simulate=False) @cli.command() @@ -103,14 +109,12 @@ def run(config, working_dir, config_file): @CLI_CONFIG_FILE_OPTION def sim(config, working_dir, config_file): """Simulates a run (does not touch your files).""" - from .core import run_file - if config_file: config = config_file console.deprecated( "The --config-file option can now be omitted. See organize --help." ) - run_file(config_file=config, working_dir=working_dir, simulate=True) + run_local(config_path=config, working_dir=working_dir, simulate=True) @cli.command() diff --git a/organize/config.py b/organize/config.py index fd529e0b..08e7652e 100644 --- a/organize/config.py +++ b/organize/config.py @@ -64,16 +64,11 @@ def default_yaml_cnst(loader, tag_suffix, node): yaml.add_multi_constructor("", default_yaml_cnst, Loader=yaml.SafeLoader) -def load_from_string(config): +def load_from_string(config: str): dedented_config = textwrap.dedent(config) return yaml.load(dedented_config, Loader=yaml.SafeLoader) -def load_from_file(path): - with open(path, "r", encoding="utf-8") as f: - return load_from_string(f.read()) - - def cleanup(config: dict): result = defaultdict(list) diff --git a/organize/console.py b/organize/console.py index 4b27bd3d..e94dc69a 100644 --- a/organize/console.py +++ b/organize/console.py @@ -1,3 +1,4 @@ +from fs.base import FS from fs.path import basename, dirname, forcedir, relpath from rich.console import Console from rich.panel import Panel @@ -95,11 +96,11 @@ def _highlight_path(path, base_style, main_style, relative=False): ) -def info(rule_file, working_dir): +def info(config_path, working_dir): console.print("organize {}".format(__version__)) - console.print('Config file: "{}"'.format(rule_file)) + console.print('Config: "{}"'.format(config_path)) if working_dir != ".": - console.print("Working dir: {}".format(working_dir)) + console.print('Working dir: "{}"'.format(working_dir)) def warn(msg, title="Warning"): @@ -131,25 +132,25 @@ def rule(rule): with_newline.reset() -def location(fs, path): +def location(fs: FS, fs_path: str): result = Text() - if fs.hassyspath(path): - syspath = fs.getsyspath(path) + if fs.hassyspath(fs_path): + syspath = fs.getsyspath(fs_path) result = _highlight_path(syspath.rstrip("/"), "location.base", "location.main") else: result = Text.assemble( (str(fs), "location.fs"), " ", - _highlight_path(path.rstrip("/"), "location.base", "location.main"), + _highlight_path(fs_path.rstrip("/"), "location.base", "location.main"), ) with_newline.print(result) -def path(fs, path): - icon = ICON_DIR if fs.isdir(path) else ICON_FILE +def path(fs: FS, fs_path: str): + icon = ICON_DIR if fs.isdir(fs_path) else ICON_FILE msg = Text.assemble( INDENT, - _highlight_path(path, "path.base", "path.main", relative=True), + _highlight_path(fs_path, "path.base", "path.main", relative=True), " ", (icon, "path.icon"), ) diff --git a/organize/core.py b/organize/core.py index 516937a6..79e92490 100644 --- a/organize/core.py +++ b/organize/core.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Iterable, NamedTuple -import fs +from fs import open_fs, path as fspath from fs.base import FS from fs.errors import NoSysPath from fs.walk import Walker @@ -62,7 +62,24 @@ def walker_args_from_location_options(options): } -def instantiate_location(loc, default_max_depth=0) -> Location: +def expand_location(url: str): + userhome = os.path.expanduser("~") + + # fill environment vars + url = os.path.expandvars(url) + url = Template.from_string(url).render(env=os.environ) + + # expand user + url = os.path.expanduser(url) + if url.startswith("zip://"): + url = url.replace("zip://~", "zip://" + userhome) + elif url.startswith("tar://"): + url = url.replace("tar://~", "tar://" + userhome) + + return url + + +def instantiate_location(fs: FS, loc, default_max_depth=0) -> Location: if isinstance(loc, str): loc = {"path": loc} @@ -83,11 +100,16 @@ def instantiate_location(loc, default_max_depth=0) -> Location: base_fs = loc["path"] path = "/" - return Location( - walker=walker, - base_fs=fs.open_fs(Template.from_string(base_fs).render(env=os.environ)), - path=Template.from_string(path).render(env=os.environ), - ) + base_fs = expand_location(base_fs) + path = expand_location(path) + + if "://" in base_fs or fspath.isabs(base_fs): + base_fs = open_fs(base_fs) + else: + path = base_fs + base_fs = fs + + return Location(walker=walker, base_fs=base_fs, path=path) def instantiate_filter(filter_config): @@ -118,7 +140,7 @@ def syspath_or_exception(fs, path): return e -def replace_with_instances(config): +def replace_with_instances(fs: FS, config): warnings = [] for rule in config["rules"]: @@ -127,7 +149,11 @@ def replace_with_instances(config): for loc in ensure_list(rule["locations"]): try: - instance = instantiate_location(loc, default_max_depth=default_depth) + instance = instantiate_location( + fs=fs, + loc=loc, + default_max_depth=default_depth, + ) _locations.append(instance) except Exception as e: if isinstance(loc, dict) and loc.get("ignore_errors", False): @@ -202,14 +228,14 @@ def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bo return True -def run(config, simulate: bool = True): +def run_rules(rules: dict, simulate: bool = True): count = Counter(done=0, fail=0) # type: Counter if simulate: console.simulation_banner() console.spinner(simulate=simulate) - for rule_nr, rule in enumerate(config["rules"], start=1): + for rule_nr, rule in enumerate(rules["rules"], start=1): target = rule.get("targets", "files") console.rule(rule.get("name", "Rule %s" % rule_nr)) filter_mode = rule.get("filter_mode", "all") @@ -219,7 +245,7 @@ def run(config, simulate: bool = True): walk = walker.files if target == "files" else walker.dirs for path in walk(fs=base_fs, path=base_path): console.path(base_fs, path) - relative_path = fs.path.relativefrom(base_path, path) + relative_path = fspath.relativefrom(base_path, path) args = { "fs": base_fs, "fs_path": path, @@ -251,17 +277,23 @@ def run(config, simulate: bool = True): return count -def run_file(config_file: str, working_dir: str, simulate: bool): +def run(fs: FS, rules: str, simulate: bool): + # TODO! os.chdir(working_dir) + # console.info(config_path) + try: - console.info(config_file, working_dir) - rules = config.load_from_file(config_file) - rules = config.cleanup(rules) - config.validate(rules) - warnings = replace_with_instances(rules) + # load and validate + conf = config.load_from_string(rules) + conf = config.cleanup(conf) + config.validate(conf) + + # instantiate + warnings = replace_with_instances(fs, conf) for msg in warnings: console.warn(msg) - os.chdir(working_dir) - count = run(rules, simulate=simulate) + + # run + count = run_rules(rules=conf, simulate=simulate) console.summary(count) except SchemaError as e: console.error("Invalid config file!") diff --git a/tests/conftest.py b/tests/conftest.py index 95b49ac8..c324c62c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,64 +1,41 @@ -import os -from unittest.mock import patch - -import pytest - - -# def create_filesystem(tmp_path, files, config): -# # create files -# for f in files: -# try: -# name, content = f -# except Exception: -# name = f -# content = "" -# p = tmp_path / "files" / Path(name) -# p.parent.mkdir(parents=True, exist_ok=True) -# with p.open("w") as ptr: -# ptr.write(content) -# # create config -# with (tmp_path / "config.yaml").open("w") as f: -# f.write(config) -# # change working directory -# os.chdir(str(tmp_path)) +from typing import IO +from fs.base import FS +from fs.path import join + + +def make_files(fs: FS, layout: dict, path="/"): + """ + layout = { + "folder": { + "subfolder": { + "test.txt": "", + "other.pdf": b"binary", + }, + }, + "file.txt": "Hello world\nAnother line", + } + """ + fs.makedirs(path, recreate=True) + for k, v in layout.items(): + respath = join(path, k) + + # folders are dicts + if isinstance(v, dict): + make_files(fs=fs, layout=v, path=respath) + + # everything else is a file + elif v is None: + fs.touch(respath) + elif isinstance(v, bytes): + fs.writebytes(respath, v) + elif isinstance(v, str): + fs.writetext(respath, v) + elif isinstance(v, IO): + fs.writefile(respath, v) + else: + raise ValueError("Unknown file data %s" % v) # def assertdir(path, *files): # os.chdir(str(path / "files")) # assert set(files) == set(str(x) for x in Path(".").glob("**/*") if x.is_file()) - - -@pytest.fixture -def mock_exists(): - with patch.object(Path, "exists") as mck: - yield mck - - -@pytest.fixture -def mock_samefile(): - with patch.object(Path, "samefile") as mck: - yield mck - - -@pytest.fixture -def mock_rename(): - with patch.object(Path, "rename") as mck: - yield mck - - -@pytest.fixture -def mock_move(): - with patch("shutil.move") as mck: - yield mck - - -@pytest.fixture -def mock_copy(): - with patch("shutil.copy2") as mck: - yield mck - - -@pytest.fixture -def mock_remove(): - with patch("os.remove") as mck: - yield mck diff --git a/tests/integration/test_codepost_usecase.py b/tests/integration/test_codepost_usecase.py index 4aa40f0b..fed45f44 100644 --- a/tests/integration/test_codepost_usecase.py +++ b/tests/integration/test_codepost_usecase.py @@ -1,8 +1,23 @@ -from conftest import assertdir, create_filesystem -from organize.cli import main +import fs +from conftest import make_files -def test_codepost_usecase(tmp_path): +def test_init(): + with fs.open_fs("mem://") as mem: + layout = { + "folder": { + "subfolder": { + "test.txt": "", + "other.pdf": b"binary", + }, + }, + "file.txt": "Hello world\nAnother line", + } + make_files(mem, layout) + mem.tree() + + +def codepost_usecase(tmp_path): create_filesystem( tmp_path, files=[ From d80966f981fd8d125ef10ad0bbadab627bcdbffa Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Wed, 2 Feb 2022 21:59:44 +0100 Subject: [PATCH 086/108] move integration tests to todo --- organize/core.py | 106 ++++++++---------- organize/utils.py | 61 +++++++++- pyproject.toml | 3 +- tests/conftest.py | 15 +-- tests/integration/test_codepost_usecase.py | 76 ++++++------- tests/integration/test_delete.py | 36 ++++++ tests/todo/integration/__init__.py | 0 .../todo/integration/test_codepost_usecase.py | 51 +++++++++ tests/todo/integration/test_delete.py | 36 ++++++ .../{ => todo}/integration/test_dict_merge.py | 0 .../{ => todo}/integration/test_duplicate.py | 0 tests/{ => todo}/integration/test_exif.py | 0 .../{ => todo}/integration/test_extension.py | 0 .../integration/test_file_content.py | 0 tests/{ => todo}/integration/test_filesize.py | 0 .../integration/test_globstrings.py | 0 .../integration/test_integration.py | 0 .../integration/test_python_filter.py | 0 tests/{ => todo}/integration/test_regex.py | 0 tests/{ => todo}/integration/test_rename.py | 0 .../{ => todo}/integration/test_startswith.py | 0 tests/{ => todo}/integration/test_unicode.py | 0 22 files changed, 272 insertions(+), 112 deletions(-) create mode 100644 tests/integration/test_delete.py create mode 100644 tests/todo/integration/__init__.py create mode 100644 tests/todo/integration/test_codepost_usecase.py create mode 100644 tests/todo/integration/test_delete.py rename tests/{ => todo}/integration/test_dict_merge.py (100%) rename tests/{ => todo}/integration/test_duplicate.py (100%) rename tests/{ => todo}/integration/test_exif.py (100%) rename tests/{ => todo}/integration/test_extension.py (100%) rename tests/{ => todo}/integration/test_file_content.py (100%) rename tests/{ => todo}/integration/test_filesize.py (100%) rename tests/{ => todo}/integration/test_globstrings.py (100%) rename tests/{ => todo}/integration/test_integration.py (100%) rename tests/{ => todo}/integration/test_python_filter.py (100%) rename tests/{ => todo}/integration/test_regex.py (100%) rename tests/{ => todo}/integration/test_rename.py (100%) rename tests/{ => todo}/integration/test_startswith.py (100%) rename tests/{ => todo}/integration/test_unicode.py (100%) diff --git a/organize/core.py b/organize/core.py index 79e92490..787e5207 100644 --- a/organize/core.py +++ b/organize/core.py @@ -1,11 +1,9 @@ import logging -import os from collections import Counter -from datetime import datetime from pathlib import Path -from typing import Iterable, NamedTuple +from typing import Iterable, NamedTuple, Union -from fs import open_fs, path as fspath +from fs import path as fspath from fs.base import FS from fs.errors import NoSysPath from fs.walk import Walker @@ -17,7 +15,14 @@ from .actions.action import Action from .filters import FILTERS from .filters.filter import Filter -from .utils import Template, deep_merge_inplace, ensure_dict, ensure_list, to_args +from .utils import ( + basic_args, + deep_merge_inplace, + ensure_dict, + ensure_list, + open_workdir_related_fs, + to_args, +) logger = logging.getLogger(__name__) highlighted_console = Console() @@ -25,8 +30,8 @@ class Location(NamedTuple): walker: Walker - base_fs: FS - path: str + fs: FS + fs_path: str DEFAULT_SYSTEM_EXCLUDE_FILES = [ @@ -43,7 +48,7 @@ class Location(NamedTuple): ] -def walker_args_from_location_options(options): +def convert_location_options_to_walker_args(options: dict): # combine system_exclude and exclude into a single list excludes = options.get("system_exlude_files", DEFAULT_SYSTEM_EXCLUDE_FILES) excludes.extend(options.get("exclude_files", [])) @@ -62,24 +67,11 @@ def walker_args_from_location_options(options): } -def expand_location(url: str): - userhome = os.path.expanduser("~") - - # fill environment vars - url = os.path.expandvars(url) - url = Template.from_string(url).render(env=os.environ) - - # expand user - url = os.path.expanduser(url) - if url.startswith("zip://"): - url = url.replace("zip://~", "zip://" + userhome) - elif url.startswith("tar://"): - url = url.replace("tar://~", "tar://" + userhome) - - return url - - -def instantiate_location(fs: FS, loc, default_max_depth=0) -> Location: +def instantiate_location( + work_fs: FS, + loc: Union[str, dict], + default_max_depth=0, +) -> Location: if isinstance(loc, str): loc = {"path": loc} @@ -88,28 +80,17 @@ def instantiate_location(fs: FS, loc, default_max_depth=0) -> Location: loc["max_depth"] = default_max_depth if "walker" not in loc: - args = walker_args_from_location_options(loc) + args = convert_location_options_to_walker_args(loc) walker = Walker(**args) else: walker = loc["walker"] - if "filesystem" in loc: - base_fs = loc["filesystem"] - path = loc.get("path", "/") - else: - base_fs = loc["path"] - path = "/" - - base_fs = expand_location(base_fs) - path = expand_location(path) - - if "://" in base_fs or fspath.isabs(base_fs): - base_fs = open_fs(base_fs) - else: - path = base_fs - base_fs = fs - - return Location(walker=walker, base_fs=base_fs, path=path) + walker_fs, path = open_workdir_related_fs( + working_dir=work_fs, + path=loc.get("path", "/"), + filesystem=loc.get("filesystem"), + ) + return Location(walker=walker, fs=walker_fs, fs_path=path) def instantiate_filter(filter_config): @@ -140,7 +121,7 @@ def syspath_or_exception(fs, path): return e -def replace_with_instances(fs: FS, config): +def replace_with_instances(work_fs: FS, config): warnings = [] for rule in config["rules"]: @@ -150,7 +131,7 @@ def replace_with_instances(fs: FS, config): for loc in ensure_list(rule["locations"]): try: instance = instantiate_location( - fs=fs, + work_fs=work_fs, loc=loc, default_max_depth=default_depth, ) @@ -246,20 +227,24 @@ def run_rules(rules: dict, simulate: bool = True): for path in walk(fs=base_fs, path=base_path): console.path(base_fs, path) relative_path = fspath.relativefrom(base_path, path) - args = { - "fs": base_fs, - "fs_path": path, - "relative_path": relative_path, - "env": os.environ, - "now": datetime.now(), - "utcnow": datetime.utcnow(), - "path": syspath_or_exception(base_fs, path), - } + + # assemble the available args + args = basic_args() + args.update( + fs=base_fs, + fs_path=path, + relative_path=relative_path, + path=syspath_or_exception(base_fs, path), + ) + + # run resource through the filter pipeline match = filter_pipeline( filters=rule["filters"], args=args, filter_mode=filter_mode, ) + + # run resource through the action pipeline if match: is_success = action_pipeline( actions=rule["actions"], @@ -277,18 +262,17 @@ def run_rules(rules: dict, simulate: bool = True): return count -def run(fs: FS, rules: str, simulate: bool): - # TODO! os.chdir(working_dir) - # console.info(config_path) - +def run(work_fs: FS, conf: Union[str, dict], simulate: bool): try: # load and validate - conf = config.load_from_string(rules) + if isinstance(conf, str): + conf = config.load_from_string(conf) conf = config.cleanup(conf) config.validate(conf) # instantiate - warnings = replace_with_instances(fs, conf) + warnings = replace_with_instances(work_fs, conf) + print(conf) for msg in warnings: console.warn(msg) diff --git a/organize/utils.py b/organize/utils.py index 681b0d1f..fca6fff9 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -1,4 +1,6 @@ +import os from copy import deepcopy +from datetime import datetime from typing import Any, List, Sequence import jinja2 @@ -11,8 +13,9 @@ def finalize_placeholder(x): - if callable(x): - return x() + # This is used to make the `path` arg available in the filters and actions. + # If a template uses `path` where no syspath is available this makes it possible + # to raise an exception. if isinstance(x, Exception): raise x return x @@ -33,6 +36,15 @@ def finalize_placeholder(x): ) +def basic_args(): + """The basic args which are guaranteed to be available.""" + return { + "env": os.environ, + "now": datetime.now(), + "utcnow": datetime.utcnow(), + } + + class SimulationFS(MemoryFS): def __init__(self, fs_url, *args, **kwargs): super().__init__(*args, **kwargs) @@ -53,11 +65,56 @@ def open_fs_or_sim(fs_url, *args, simulate=False, **kwargs): return open_fs(fs_url, *args, **kwargs) +def expand_user(fs_url: str): + fs_url = os.path.expanduser(fs_url) + if fs_url.startswith("zip://~"): + fs_url = fs_url.replace("zip://~", "zip://" + os.path.expanduser("~")) + elif fs_url.startswith("tar://~"): + fs_url = fs_url.replace("tar://~", "tar://" + os.path.expanduser("~")) + return fs_url + + +def expand_with_args(fs_url: str, args=None): + if not args: + args = basic_args() + + fs_url = expand_user(fs_url) + + # fill environment vars + fs_url = os.path.expandvars(fs_url) + fs_url = Template.from_string(fs_url).render() + + return fs_url + + +def open_workdir_related_fs(working_dir: FS, path: str, filesystem=""): + """ + path can be a fs_url or a absolute or relative path. + filesystem is optional and can be a fs_url. + + - if the path is relative we try to stay in working_dir. + - if it is absolute, we create a OSFS + - if a filesystem is given, we use that. + """ + path = expand_user(path) + filesystem = expand_user(filesystem) if filesystem else None + + if not filesystem: + if fspath.isabs(path) or "://" in path: + return (open_fs(path), "/") + else: + return (working_dir, path) + else: + (open_fs(filesystem), path) + + def is_same_resource(fs1, path1, fs2, path2): from fs.errors import NoSysPath, NoURL from fs.tarfs import ReadTarFS, WriteTarFS from fs.zipfs import ReadZipFS, WriteZipFS + if fs1 == fs2 and path1 == path2: + return True try: return fs1.getsyspath(path1) == fs2.getsyspath(path2) except NoSysPath: diff --git a/pyproject.toml b/pyproject.toml index 9b555d12..db1a1e3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,9 +78,10 @@ addopts = "--doctest-modules" testpaths = ["tests", "organize"] norecursedirs = [ "tests/todo", - "tests/integration", + # "tests/integration", "tests/filters", "organize/filters", + ".configs", ] [build-system] diff --git a/tests/conftest.py b/tests/conftest.py index c324c62c..db6861c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ -from typing import IO from fs.base import FS -from fs.path import join +from fs.path import join, basename def make_files(fs: FS, layout: dict, path="/"): @@ -30,12 +29,14 @@ def make_files(fs: FS, layout: dict, path="/"): fs.writebytes(respath, v) elif isinstance(v, str): fs.writetext(respath, v) - elif isinstance(v, IO): - fs.writefile(respath, v) else: raise ValueError("Unknown file data %s" % v) -# def assertdir(path, *files): -# os.chdir(str(path / "files")) -# assert set(files) == set(str(x) for x in Path(".").glob("**/*") if x.is_file()) +def read_files(fs: FS, path="/"): + result = dict() + for x in fs.walk.files(path, max_depth=0): + result[basename(x)] = fs.readtext(x) + for x in fs.walk.dirs(path, max_depth=0): + result[basename(x)] = read_files(fs, path=join(path, x)) + return result diff --git a/tests/integration/test_codepost_usecase.py b/tests/integration/test_codepost_usecase.py index fed45f44..17cadc92 100644 --- a/tests/integration/test_codepost_usecase.py +++ b/tests/integration/test_codepost_usecase.py @@ -1,35 +1,23 @@ import fs -from conftest import make_files +from conftest import make_files, read_files +from organize import actions, config, core -def test_init(): - with fs.open_fs("mem://") as mem: - layout = { - "folder": { - "subfolder": { - "test.txt": "", - "other.pdf": b"binary", - }, - }, - "file.txt": "Hello world\nAnother line", - } - make_files(mem, layout) - mem.tree() - -def codepost_usecase(tmp_path): - create_filesystem( - tmp_path, - files=[ - "Devonte-Betts.txt", - "Alaina-Cornish.txt", - "Dimitri-Bean.txt", - "Lowri-Frey.txt", - "Someunknown-User.txt", - ], - config=r""" +def test_codepost_usecase(): + files = { + "files": { + "Devonte-Betts.txt": "", + "Alaina-Cornish.txt": "", + "Dimitri-Bean.txt": "", + "Lowri-Frey.txt": "", + "Someunknown-User.txt": "", + } + } + conf = config.load_from_string( + r""" rules: - - folders: files + - locations: files filters: - extension: txt - regex: (?P\w+)-(?P\w+)\..* @@ -40,18 +28,24 @@ def codepost_usecase(tmp_path): "Bean": "dbean@aol.com", "Frey": "l-frey@frey.org", } - if regex.lastname in emails: - return {"mail": emails[regex.lastname]} - actions: - - rename: '{python.mail}.txt' - """, - ) - main(["run", "--config-file=%s" % (tmp_path / "config.yaml")]) - assertdir( - tmp_path, - "dbetts@mail.de.txt", - "acornish@google.com.txt", - "dbean@aol.com.txt", - "l-frey@frey.org.txt", - "Someunknown-User.txt", # no email found -> keep file + if regex["lastname"] in emails: + return {"mail": emails[regex["lastname"]]} + """ ) + with fs.open_fs("mem://") as mem: + conf["actions"] = actions.Move("{python.mail}.txt", filesystem=mem) + make_files(mem, files) + print(conf) + core.run(mem, conf, simulate=False) + result = read_files(mem) + mem.tree() + + assert result == { + "files": { + "dbetts@mail.de.txt": "", + "acornish@google.com.txt": "", + "dbean@aol.com.txt": "", + "l-frey@frey.org.txt": "", + "Someunknown-User.txt": "", + } + } diff --git a/tests/integration/test_delete.py b/tests/integration/test_delete.py new file mode 100644 index 00000000..4faab10a --- /dev/null +++ b/tests/integration/test_delete.py @@ -0,0 +1,36 @@ +import fs +from conftest import make_files, read_files, organize + + +def test_delete(): + config = """ + rules: + - locations: "files" + subfolders: true + actions: + - delete + - locations: "files" + targets: dirs + subfolders: true + actions: + - delete + """ + files = { + "files": { + "folder": { + "subfolder": { + "test.txt": "", + "other.pdf": b"binary", + }, + "file.txt": "Hello world\nAnother line", + }, + } + } + with fs.open_fs("mem://") as mem: + make_files(mem, files) + organize(mem, config, simulate=False) + result = read_files(mem) + + assert result == { + "files": {}, + } diff --git a/tests/todo/integration/__init__.py b/tests/todo/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/todo/integration/test_codepost_usecase.py b/tests/todo/integration/test_codepost_usecase.py new file mode 100644 index 00000000..17cadc92 --- /dev/null +++ b/tests/todo/integration/test_codepost_usecase.py @@ -0,0 +1,51 @@ +import fs +from conftest import make_files, read_files + +from organize import actions, config, core + + +def test_codepost_usecase(): + files = { + "files": { + "Devonte-Betts.txt": "", + "Alaina-Cornish.txt": "", + "Dimitri-Bean.txt": "", + "Lowri-Frey.txt": "", + "Someunknown-User.txt": "", + } + } + conf = config.load_from_string( + r""" + rules: + - locations: files + filters: + - extension: txt + - regex: (?P\w+)-(?P\w+)\..* + - python: | + emails = { + "Betts": "dbetts@mail.de", + "Cornish": "acornish@google.com", + "Bean": "dbean@aol.com", + "Frey": "l-frey@frey.org", + } + if regex["lastname"] in emails: + return {"mail": emails[regex["lastname"]]} + """ + ) + with fs.open_fs("mem://") as mem: + conf["actions"] = actions.Move("{python.mail}.txt", filesystem=mem) + make_files(mem, files) + print(conf) + core.run(mem, conf, simulate=False) + result = read_files(mem) + mem.tree() + + assert result == { + "files": { + "dbetts@mail.de.txt": "", + "acornish@google.com.txt": "", + "dbean@aol.com.txt": "", + "l-frey@frey.org.txt": "", + "Someunknown-User.txt": "", + } + } diff --git a/tests/todo/integration/test_delete.py b/tests/todo/integration/test_delete.py new file mode 100644 index 00000000..4faab10a --- /dev/null +++ b/tests/todo/integration/test_delete.py @@ -0,0 +1,36 @@ +import fs +from conftest import make_files, read_files, organize + + +def test_delete(): + config = """ + rules: + - locations: "files" + subfolders: true + actions: + - delete + - locations: "files" + targets: dirs + subfolders: true + actions: + - delete + """ + files = { + "files": { + "folder": { + "subfolder": { + "test.txt": "", + "other.pdf": b"binary", + }, + "file.txt": "Hello world\nAnother line", + }, + } + } + with fs.open_fs("mem://") as mem: + make_files(mem, files) + organize(mem, config, simulate=False) + result = read_files(mem) + + assert result == { + "files": {}, + } diff --git a/tests/integration/test_dict_merge.py b/tests/todo/integration/test_dict_merge.py similarity index 100% rename from tests/integration/test_dict_merge.py rename to tests/todo/integration/test_dict_merge.py diff --git a/tests/integration/test_duplicate.py b/tests/todo/integration/test_duplicate.py similarity index 100% rename from tests/integration/test_duplicate.py rename to tests/todo/integration/test_duplicate.py diff --git a/tests/integration/test_exif.py b/tests/todo/integration/test_exif.py similarity index 100% rename from tests/integration/test_exif.py rename to tests/todo/integration/test_exif.py diff --git a/tests/integration/test_extension.py b/tests/todo/integration/test_extension.py similarity index 100% rename from tests/integration/test_extension.py rename to tests/todo/integration/test_extension.py diff --git a/tests/integration/test_file_content.py b/tests/todo/integration/test_file_content.py similarity index 100% rename from tests/integration/test_file_content.py rename to tests/todo/integration/test_file_content.py diff --git a/tests/integration/test_filesize.py b/tests/todo/integration/test_filesize.py similarity index 100% rename from tests/integration/test_filesize.py rename to tests/todo/integration/test_filesize.py diff --git a/tests/integration/test_globstrings.py b/tests/todo/integration/test_globstrings.py similarity index 100% rename from tests/integration/test_globstrings.py rename to tests/todo/integration/test_globstrings.py diff --git a/tests/integration/test_integration.py b/tests/todo/integration/test_integration.py similarity index 100% rename from tests/integration/test_integration.py rename to tests/todo/integration/test_integration.py diff --git a/tests/integration/test_python_filter.py b/tests/todo/integration/test_python_filter.py similarity index 100% rename from tests/integration/test_python_filter.py rename to tests/todo/integration/test_python_filter.py diff --git a/tests/integration/test_regex.py b/tests/todo/integration/test_regex.py similarity index 100% rename from tests/integration/test_regex.py rename to tests/todo/integration/test_regex.py diff --git a/tests/integration/test_rename.py b/tests/todo/integration/test_rename.py similarity index 100% rename from tests/integration/test_rename.py rename to tests/todo/integration/test_rename.py diff --git a/tests/integration/test_startswith.py b/tests/todo/integration/test_startswith.py similarity index 100% rename from tests/integration/test_startswith.py rename to tests/todo/integration/test_startswith.py diff --git a/tests/integration/test_unicode.py b/tests/todo/integration/test_unicode.py similarity index 100% rename from tests/integration/test_unicode.py rename to tests/todo/integration/test_unicode.py From 51655bc14b3252570db1e8c53cd6e82476804d13 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Wed, 2 Feb 2022 23:44:40 +0100 Subject: [PATCH 087/108] update tests --- organize/actions/copy.py | 2 +- organize/actions/move.py | 2 +- organize/config.py | 2 +- organize/core.py | 54 +++++++++---------- organize/utils.py | 37 ++++++++----- tests/core/test_config.py | 1 + tests/integration/test_codepost_usecase.py | 41 +++++++++----- tests/integration/test_delete.py | 30 ++++++----- .../test_deep_merge.py} | 0 tests/utils/test_is_same_resource.py | 28 ++++++++++ 10 files changed, 125 insertions(+), 72 deletions(-) rename tests/{core/test_utils_merge.py => utils/test_deep_merge.py} (100%) create mode 100644 tests/utils/test_is_same_resource.py diff --git a/organize/actions/copy.py b/organize/actions/copy.py index 119f5908..f3e56535 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -49,7 +49,7 @@ class Copy(Action): "dest": str, Optional("on_conflict"): Or(*CONFLICT_OPTIONS), Optional("rename_template"): str, - Optional("filesystem"): str, + Optional("filesystem"): object, }, ) diff --git a/organize/actions/move.py b/organize/actions/move.py index 08f2530c..89bb5054 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -53,7 +53,7 @@ class Move(Action): "dest": str, Optional("on_conflict"): Or(*CONFLICT_OPTIONS), Optional("rename_template"): str, - Optional("filesystem"): str, + Optional("filesystem"): object, }, ) diff --git a/organize/config.py b/organize/config.py index 08e7652e..a0165b7f 100644 --- a/organize/config.py +++ b/organize/config.py @@ -39,7 +39,7 @@ Optional("ignore_errors"): bool, Optional("filter"): [str], Optional("filter_dirs"): [str], - Optional("filesystem"): str, + Optional("filesystem"): object, }, ), ], diff --git a/organize/core.py b/organize/core.py index 787e5207..4c776813 100644 --- a/organize/core.py +++ b/organize/core.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Iterable, NamedTuple, Union -from fs import path as fspath +from fs import path as fspath, open_fs from fs.base import FS from fs.errors import NoSysPath from fs.walk import Walker @@ -20,7 +20,7 @@ deep_merge_inplace, ensure_dict, ensure_list, - open_workdir_related_fs, + fs_path_from_options, to_args, ) @@ -48,7 +48,7 @@ class Location(NamedTuple): ] -def convert_location_options_to_walker_args(options: dict): +def convert_options_to_walker_args(options: dict): # combine system_exclude and exclude into a single list excludes = options.get("system_exlude_files", DEFAULT_SYSTEM_EXCLUDE_FILES) excludes.extend(options.get("exclude_files", [])) @@ -67,30 +67,25 @@ def convert_location_options_to_walker_args(options: dict): } -def instantiate_location( - work_fs: FS, - loc: Union[str, dict], - default_max_depth=0, -) -> Location: - if isinstance(loc, str): - loc = {"path": loc} +def instantiate_location(options: Union[str, dict], default_max_depth=0) -> Location: + if isinstance(options, str): + options = {"path": options} # set default max depth from rule - if not "max_depth" in loc: - loc["max_depth"] = default_max_depth + if not "max_depth" in options: + options["max_depth"] = default_max_depth - if "walker" not in loc: - args = convert_location_options_to_walker_args(loc) + if "walker" not in options: + args = convert_options_to_walker_args(options) walker = Walker(**args) else: - walker = loc["walker"] + walker = options["walker"] - walker_fs, path = open_workdir_related_fs( - working_dir=work_fs, - path=loc.get("path", "/"), - filesystem=loc.get("filesystem"), + fs, fs_path = fs_path_from_options( + path=options.get("path", "/"), + filesystem=options.get("filesystem"), ) - return Location(walker=walker, fs=walker_fs, fs_path=path) + return Location(walker=walker, fs=fs, fs_path=fs_path) def instantiate_filter(filter_config): @@ -121,26 +116,25 @@ def syspath_or_exception(fs, path): return e -def replace_with_instances(work_fs: FS, config): +def replace_with_instances(config: dict): warnings = [] for rule in config["rules"]: - _locations = [] default_depth = None if rule.get("subfolders", False) else 0 - for loc in ensure_list(rule["locations"]): + _locations = [] + for options in ensure_list(rule["locations"]): try: instance = instantiate_location( - work_fs=work_fs, - loc=loc, + options=options, default_max_depth=default_depth, ) _locations.append(instance) except Exception as e: - if isinstance(loc, dict) and loc.get("ignore_errors", False): + if isinstance(options, dict) and options.get("ignore_errors", False): warnings.append(str(e)) else: - raise ValueError("Invalid location %s" % loc) from e + raise ValueError("Invalid location %s" % options) from e # filters are optional _filters = [] @@ -262,17 +256,17 @@ def run_rules(rules: dict, simulate: bool = True): return count -def run(work_fs: FS, conf: Union[str, dict], simulate: bool): +def run(conf: Union[str, dict], simulate: bool): try: # load and validate if isinstance(conf, str): conf = config.load_from_string(conf) + conf = config.cleanup(conf) config.validate(conf) # instantiate - warnings = replace_with_instances(work_fs, conf) - print(conf) + warnings = replace_with_instances(conf) for msg in warnings: console.warn(msg) diff --git a/organize/utils.py b/organize/utils.py index fca6fff9..17b90188 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -1,7 +1,7 @@ import os from copy import deepcopy from datetime import datetime -from typing import Any, List, Sequence +from typing import Any, List, Sequence, Union import jinja2 from fs import open_fs @@ -87,34 +87,45 @@ def expand_with_args(fs_url: str, args=None): return fs_url -def open_workdir_related_fs(working_dir: FS, path: str, filesystem=""): +def fs_path_from_options(path: str, filesystem: Union[FS, str] = ""): """ - path can be a fs_url or a absolute or relative path. - filesystem is optional and can be a fs_url. + path can be a fs_url a normal fs_path + filesystem is optional and may be a fs_url. - - if the path is relative we try to stay in working_dir. - - if it is absolute, we create a OSFS + - user tilde is expanded - if a filesystem is given, we use that. + - otherwise we treat the path as a filesystem. """ path = expand_user(path) - filesystem = expand_user(filesystem) if filesystem else None if not filesystem: - if fspath.isabs(path) or "://" in path: - return (open_fs(path), "/") - else: - return (working_dir, path) + return (open_fs(path), "/") else: - (open_fs(filesystem), path) + if isinstance(filesystem, str): + filesystem = expand_user(filesystem) if filesystem else None + return (open_fs(filesystem), path) + return (filesystem.opendir(path), "/") -def is_same_resource(fs1, path1, fs2, path2): +def is_same_resource(fs1: FS, path1: str, fs2: FS, path2: str): from fs.errors import NoSysPath, NoURL from fs.tarfs import ReadTarFS, WriteTarFS from fs.zipfs import ReadZipFS, WriteZipFS + from fs.wrapfs import WrapFS + from fs.path import abspath + + def unwrap(fs, path): + if isinstance(fs, WrapFS): + fs, path = fs.delegate_path(path) + return fs, abspath(path) + + # completely unwrap WrapFS instances + fs1, path1 = unwrap(fs1, path1) + fs2, path2 = unwrap(fs2, path2) if fs1 == fs2 and path1 == path2: return True + try: return fs1.getsyspath(path1) == fs2.getsyspath(path2) except NoSysPath: diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 10785d70..5cbe069f 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -1,3 +1,4 @@ +from fs import open_fs import pytest from organize import config, core diff --git a/tests/integration/test_codepost_usecase.py b/tests/integration/test_codepost_usecase.py index 17cadc92..cc0c502f 100644 --- a/tests/integration/test_codepost_usecase.py +++ b/tests/integration/test_codepost_usecase.py @@ -14,11 +14,12 @@ def test_codepost_usecase(): "Someunknown-User.txt": "", } } - conf = config.load_from_string( - r""" - rules: - - locations: files - filters: + + with fs.open_fs("mem://") as mem: + make_files(mem, files) + + filters = config.load_from_string( + """ - extension: txt - regex: (?P\w+)-(?P\w+)\..* - python: | @@ -30,13 +31,29 @@ def test_codepost_usecase(): } if regex["lastname"] in emails: return {"mail": emails[regex["lastname"]]} - """ - ) - with fs.open_fs("mem://") as mem: - conf["actions"] = actions.Move("{python.mail}.txt", filesystem=mem) - make_files(mem, files) - print(conf) - core.run(mem, conf, simulate=False) + """ + ) + conf = { + "rules": [ + { + "locations": [ + {"path": "files", "filesystem": mem}, + ], + "filters": filters, + "actions": [ + {"move": {"dest": "files/{python.mail}.txt", "filesystem": mem}} + ], + }, + { + "locations": [ + {"path": "files", "filesystem": mem}, + ], + "filters": [{"extension": "txt"}], + "actions": [{"move": {"dest": "files/", "filesystem": mem}}], + }, + ] + } + core.run(conf, simulate=False) result = read_files(mem) mem.tree() diff --git a/tests/integration/test_delete.py b/tests/integration/test_delete.py index 4faab10a..f0b00d89 100644 --- a/tests/integration/test_delete.py +++ b/tests/integration/test_delete.py @@ -1,20 +1,9 @@ import fs -from conftest import make_files, read_files, organize +from conftest import make_files, read_files +from organize import core def test_delete(): - config = """ - rules: - - locations: "files" - subfolders: true - actions: - - delete - - locations: "files" - targets: dirs - subfolders: true - actions: - - delete - """ files = { "files": { "folder": { @@ -27,8 +16,21 @@ def test_delete(): } } with fs.open_fs("mem://") as mem: + config = { + "rules": [ + { + "locations": [{"path": "files", "filesystem": mem}], + "actions": ["delete"], + }, + { + "locations": [{"path": "files", "filesystem": mem}], + "targets": "dirs", + "actions": ["delete"], + }, + ] + } make_files(mem, files) - organize(mem, config, simulate=False) + core.run(config, simulate=False) result = read_files(mem) assert result == { diff --git a/tests/core/test_utils_merge.py b/tests/utils/test_deep_merge.py similarity index 100% rename from tests/core/test_utils_merge.py rename to tests/utils/test_deep_merge.py diff --git a/tests/utils/test_is_same_resource.py b/tests/utils/test_is_same_resource.py new file mode 100644 index 00000000..31d39cfd --- /dev/null +++ b/tests/utils/test_is_same_resource.py @@ -0,0 +1,28 @@ +from fs import open_fs +from organize.utils import is_same_resource + + +def test_mem(): + a = open_fs("mem://") + a.touch("file1") + b = a.makedir("b") + b.touch("file2") + c = a + + assert is_same_resource(a, "b/file2", a, "b/file2") + assert is_same_resource(a, "b/file2", b, "file2") + assert is_same_resource(a, "file1", c, "file1") + + +def test_osfs(): + a = open_fs("~/Desktop") + b = open_fs("~/") + c = b.opendir("Desktop") + + assert is_same_resource(a, "file.txt", a, "file.txt") + assert is_same_resource(a, "file.txt", b, "Desktop/file.txt") + assert is_same_resource(a, "file.txt", c, "file.txt") + + +def test_zipfs(): + b = open_fs("~/") From ee650457c009068843886a8793339c93b59e5938 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 3 Feb 2022 00:24:57 +0100 Subject: [PATCH 088/108] update tests --- organize/utils.py | 21 ++++++++++++++++----- tests/integration/test_codepost_usecase.py | 15 ++++++++------- tests/utils/test_is_same_resource.py | 19 +++++++++++++++++-- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/organize/utils.py b/organize/utils.py index 17b90188..46d571a2 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -112,31 +112,42 @@ def is_same_resource(fs1: FS, path1: str, fs2: FS, path2: str): from fs.tarfs import ReadTarFS, WriteTarFS from fs.zipfs import ReadZipFS, WriteZipFS from fs.wrapfs import WrapFS - from fs.path import abspath + from fs.path import normpath, join def unwrap(fs, path): + base = "/" if isinstance(fs, WrapFS): - fs, path = fs.delegate_path(path) - return fs, abspath(path) + fs, base = fs.delegate_path("/") + return fs, normpath(join(base, path)) # to support ".." in path # completely unwrap WrapFS instances fs1, path1 = unwrap(fs1, path1) fs2, path2 = unwrap(fs2, path2) + # obvious check if fs1 == fs2 and path1 == path2: return True + # check all fs with syspath support try: return fs1.getsyspath(path1) == fs2.getsyspath(path2) except NoSysPath: pass + + # check zip and tar + Tar = (WriteTarFS, ReadTarFS) + Zip = (WriteZipFS, ReadZipFS) + if (isinstance(fs1, Tar) and isinstance(fs2, Tar)) or ( + isinstance(fs1, Zip) and isinstance(fs2, Zip) + ): + return path1 == path2 and fs1._file == fs2._file + + # check all fs with url support if isinstance(fs1, fs2.__class__): try: return fs1.geturl(path1) == fs2.geturl(path2) except NoURL: pass - if isinstance(fs1, (WriteZipFS, ReadZipFS, WriteTarFS, ReadTarFS)): - return path1 == path2 and fs1._file == fs2._file return False diff --git a/tests/integration/test_codepost_usecase.py b/tests/integration/test_codepost_usecase.py index cc0c502f..9104b2da 100644 --- a/tests/integration/test_codepost_usecase.py +++ b/tests/integration/test_codepost_usecase.py @@ -44,13 +44,14 @@ def test_codepost_usecase(): {"move": {"dest": "files/{python.mail}.txt", "filesystem": mem}} ], }, - { - "locations": [ - {"path": "files", "filesystem": mem}, - ], - "filters": [{"extension": "txt"}], - "actions": [{"move": {"dest": "files/", "filesystem": mem}}], - }, + # TODO: Test for moving files onto themselves + # { + # "locations": [ + # {"path": "files", "filesystem": mem}, + # ], + # "filters": [{"extension": "txt"}], + # "actions": [{"move": {"dest": "files/", "filesystem": mem}}], + # }, ] } core.run(conf, simulate=False) diff --git a/tests/utils/test_is_same_resource.py b/tests/utils/test_is_same_resource.py index 31d39cfd..13ad75c1 100644 --- a/tests/utils/test_is_same_resource.py +++ b/tests/utils/test_is_same_resource.py @@ -1,4 +1,5 @@ from fs import open_fs +from fs.memoryfs import MemoryFS from organize.utils import is_same_resource @@ -14,6 +15,14 @@ def test_mem(): assert is_same_resource(a, "file1", c, "file1") +def test_mem2(): + mem = MemoryFS() + fs1, path1 = mem.makedir("files"), "test.txt" + fs2, path2 = mem, "files/test.txt" + + assert is_same_resource(fs1, path1, fs2, path2) + + def test_osfs(): a = open_fs("~/Desktop") b = open_fs("~/") @@ -24,5 +33,11 @@ def test_osfs(): assert is_same_resource(a, "file.txt", c, "file.txt") -def test_zipfs(): - b = open_fs("~/") +def test_inter(): + a = open_fs("temp://") + b = open_fs(a.getsyspath("/")) + a_dir = a.makedir("a") + + assert is_same_resource(a, "test.txt", b, "test.txt") + assert is_same_resource(b, "a/subfile.txt", a_dir, "subfile.txt") + assert is_same_resource(a, "test.txt", a_dir, "../test.txt") From 3b509228acb30510f148f4d6751986f1061bad73 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 3 Feb 2022 01:38:38 +0100 Subject: [PATCH 089/108] update --- organize/cli.py | 20 ++++++++++--- organize/core.py | 44 ++++++++++++---------------- organize/utils.py | 13 +++++--- tests/actions/test_move.py | 43 +++++++++++++++++++++++++++ tests/utils/test_is_same_resource.py | 2 +- 5 files changed, 87 insertions(+), 35 deletions(-) create mode 100644 tests/actions/test_move.py diff --git a/organize/cli.py b/organize/cli.py index b04b7e2e..fc05e54d 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -73,10 +73,22 @@ def list_commands(self, ctx): def run_local(config_path: str, working_dir: str, simulate: bool): from . import core - - console.info(config_path=config_path, working_dir=working_dir) - config = open_fs(".").readtext(config_path) - core.run(fs=open_fs(working_dir), rules=config, simulate=simulate) + from schema import SchemaError + + try: + console.info(config_path=config_path, working_dir=working_dir) + config = open_fs(".").readtext(config_path) + core.run(fs=open_fs(working_dir), rules=config, simulate=simulate) + except SchemaError as e: + console.error("Invalid config file!") + for err in e.autos: + if err and len(err) < 200: + core.highlighted_console.print(err) + except Exception as e: + core.highlighted_console.print_exception() + except (EOFError, KeyboardInterrupt): + console.status.stop() + console.warn("Aborted") @click.group( diff --git a/organize/core.py b/organize/core.py index 4c776813..c4fd0e1d 100644 --- a/organize/core.py +++ b/organize/core.py @@ -257,29 +257,21 @@ def run_rules(rules: dict, simulate: bool = True): def run(conf: Union[str, dict], simulate: bool): - try: - # load and validate - if isinstance(conf, str): - conf = config.load_from_string(conf) - - conf = config.cleanup(conf) - config.validate(conf) - - # instantiate - warnings = replace_with_instances(conf) - for msg in warnings: - console.warn(msg) - - # run - count = run_rules(rules=conf, simulate=simulate) - console.summary(count) - except SchemaError as e: - console.error("Invalid config file!") - for err in e.autos: - if err and len(err) < 200: - highlighted_console.print(err) - except Exception as e: - highlighted_console.print_exception() - except (EOFError, KeyboardInterrupt): - console.status.stop() - console.warn("Aborted") + # load and validate + if isinstance(conf, str): + conf = config.load_from_string(conf) + + conf = config.cleanup(conf) + config.validate(conf) + + # instantiate + warnings = replace_with_instances(conf) + for msg in warnings: + console.warn(msg) + + # run + count = run_rules(rules=conf, simulate=simulate) + console.summary(count) + print(count) + if count["fail"]: + raise ValueError("Some actions failed.") diff --git a/organize/utils.py b/organize/utils.py index 46d571a2..1abca9b3 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -112,13 +112,18 @@ def is_same_resource(fs1: FS, path1: str, fs2: FS, path2: str): from fs.tarfs import ReadTarFS, WriteTarFS from fs.zipfs import ReadZipFS, WriteZipFS from fs.wrapfs import WrapFS - from fs.path import normpath, join + from fs.path import abspath + + # def unwrap(fs, path): + # base = "/" + # if isinstance(fs, WrapFS): + # fs, base = fs.delegate_path("/") + # return fs, normpath(join(base, path)) # to support ".." in path def unwrap(fs, path): - base = "/" if isinstance(fs, WrapFS): - fs, base = fs.delegate_path("/") - return fs, normpath(join(base, path)) # to support ".." in path + fs, path = fs.delegate_path(path) + return fs, abspath(path) # completely unwrap WrapFS instances fs1, path1 = unwrap(fs1, path1) diff --git a/tests/actions/test_move.py b/tests/actions/test_move.py new file mode 100644 index 00000000..0abe50ea --- /dev/null +++ b/tests/actions/test_move.py @@ -0,0 +1,43 @@ +import fs +from conftest import make_files, read_files +from organize import core + + +def test_move_on_itself(): + files = { + "files": { + "test.txt": "", + "file.txt": "Hello world\nAnother line", + "another.txt": "", + "folder": { + "x.txt": "", + }, + } + } + with fs.open_fs("mem://") as mem: + config = { + "rules": [ + { + "locations": [ + {"path": "files", "filesystem": mem}, + ], + "actions": [ + {"copy": {"dest": "files/", "filesystem": mem}}, + ], + }, + ] + } + make_files(mem, files) + core.run(config, simulate=False) + result = read_files(mem) + + assert result == { + "files": { + "test.txt": "", + "file.txt": "Hello world\nAnother line", + "another.txt": "", + "folder": { + "x.txt": "", + }, + } + } diff --git a/tests/utils/test_is_same_resource.py b/tests/utils/test_is_same_resource.py index 13ad75c1..56befafa 100644 --- a/tests/utils/test_is_same_resource.py +++ b/tests/utils/test_is_same_resource.py @@ -40,4 +40,4 @@ def test_inter(): assert is_same_resource(a, "test.txt", b, "test.txt") assert is_same_resource(b, "a/subfile.txt", a_dir, "subfile.txt") - assert is_same_resource(a, "test.txt", a_dir, "../test.txt") + # assert is_same_resource(a, "test.txt", a_dir, "../test.txt") From c4d05ff6c6aba2f04d81d28ee713cc311442b73a Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 3 Feb 2022 01:46:57 +0100 Subject: [PATCH 090/108] update tests --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a69f68d3..f7a89d0a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,7 +18,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.6.x", "3.7.x", "3.8.x", "3.9.x"] + python-version: ["3.6", "3.7", "3.8", "3.9"] fail-fast: false steps: - uses: actions/checkout@v2 @@ -28,8 +28,8 @@ jobs: - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install poetry + python3 -m pip install --upgrade pip + python3 -m pip install poetry poetry run python -m install -U pip poetry install -E textract From 03eff434b9af8b74891a4cf86df39224c06b0b75 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 3 Feb 2022 01:47:46 +0100 Subject: [PATCH 091/108] add setuptools --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f7a89d0a..0b8b1c7c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,7 +28,7 @@ jobs: - name: Install dependencies run: | - python3 -m pip install --upgrade pip + python3 -m pip install --upgrade pip setuptools python3 -m pip install poetry poetry run python -m install -U pip poetry install -E textract From 067c8c66051eaee64f50963b215788baf2bffe75 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 3 Feb 2022 01:56:58 +0100 Subject: [PATCH 092/108] update tests --- .github/workflows/tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0b8b1c7c..7ca3f8ed 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,6 @@ jobs: build: runs-on: ubuntu-latest strategy: - max-parallel: 4 matrix: python-version: ["3.6", "3.7", "3.8", "3.9"] fail-fast: false From 3eece780bdc065070c11e9eff0e227aef4bc461b Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 3 Feb 2022 01:58:54 +0100 Subject: [PATCH 093/108] update --- .github/workflows/tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7ca3f8ed..84ff05fd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,8 +29,8 @@ jobs: run: | python3 -m pip install --upgrade pip setuptools python3 -m pip install poetry - poetry run python -m install -U pip - poetry install -E textract + poetry run python -m install -U pip setuptools + poetry install - name: Version info run: | From 8f48972e491d3874e4222123bc8c3f6528564366 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Thu, 3 Feb 2022 02:00:24 +0100 Subject: [PATCH 094/108] update --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 84ff05fd..398feefd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,7 +29,7 @@ jobs: run: | python3 -m pip install --upgrade pip setuptools python3 -m pip install poetry - poetry run python -m install -U pip setuptools + poetry run python -m pip install -U pip setuptools poetry install - name: Version info From 83181e979738f2ca15cd74087631a9ceb76ce3a5 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Fri, 4 Feb 2022 14:27:09 +0100 Subject: [PATCH 095/108] fix copy --- .github/workflows/tests.yml | 2 +- README.md | 8 +- c.yaml | 7 + organize/__init__.py | 70 ++++---- organize/actions/action.py | 11 +- organize/actions/copy.py | 87 ++++----- organize/actions/copymove_utils.py | 169 ++++++++++++++++++ organize/actions/echo.py | 2 +- organize/actions/move.py | 8 +- organize/actions/rename.py | 8 +- organize/actions/utils.py | 82 --------- organize/cli.py | 3 +- organize/core.py | 28 +-- organize/filters/duplicate.py | 11 +- organize/filters/filter.py | 3 +- organize/utils.py | 64 ++----- tests/actions/test_copy.py | 67 +++++++ tests/conftest.py | 41 ++++- tests/integration/test_codepost_usecase.py | 10 +- tests/integration/test_delete.py | 10 +- tests/integration/test_dict_merge.py | 32 ++++ tests/integration/test_duplicate.py | 75 ++++++++ .../todo/integration/test_codepost_usecase.py | 51 ------ tests/todo/integration/test_delete.py | 36 ---- tests/todo/integration/test_dict_merge.py | 26 --- tests/todo/integration/test_duplicate.py | 58 ------ tests/utils/test_deep_merge.py | 1 - tests/utils/test_is_same_resource.py | 14 ++ 28 files changed, 554 insertions(+), 430 deletions(-) create mode 100644 c.yaml create mode 100644 organize/actions/copymove_utils.py delete mode 100644 organize/actions/utils.py create mode 100644 tests/actions/test_copy.py create mode 100644 tests/integration/test_dict_merge.py create mode 100644 tests/integration/test_duplicate.py delete mode 100644 tests/todo/integration/test_codepost_usecase.py delete mode 100644 tests/todo/integration/test_delete.py delete mode 100644 tests/todo/integration/test_dict_merge.py delete mode 100644 tests/todo/integration/test_duplicate.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 398feefd..199279f2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,7 +30,7 @@ jobs: python3 -m pip install --upgrade pip setuptools python3 -m pip install poetry poetry run python -m pip install -U pip setuptools - poetry install + poetry install -E textract - name: Version info run: | diff --git a/README.md b/README.md index cc118c4d..72b0b1b1 100644 --- a/README.md +++ b/README.md @@ -45,15 +45,15 @@ or File Juggler (Windows). ## Features -organize has too many features to list here. The highlights include: +Some highlights include: -- Safe moving, renaming, copying of files with conflict resolution options +- Safe moving, renaming, copying of files and folders with conflict resolution options - Fast duplicate file detection - Exif tags extraction - Categorization via text extracted from PDF, DOCX and many more -- Supports working on FTP, WebDAV, S3 Buckets, SSH and many more +- Supports remote file locations like FTP, WebDAV, S3 Buckets, SSH and many more - Powerful template engine -- Inline python and shell commands as filters and actions for maximum flexibility! +- Inline python and shell commands as filters and actions for maximum flexibility - Everything can be simulated before touching your files. ## Getting started diff --git a/c.yaml b/c.yaml new file mode 100644 index 00000000..75cc0ca8 --- /dev/null +++ b/c.yaml @@ -0,0 +1,7 @@ +rules: + - locations: ~/Desktop + filters: + - extension + actions: + - copy: ~/Desktop/sorted/{extension}/ + - copy: ~/Desktop/sorted/{extension}/2/ diff --git a/organize/__init__.py b/organize/__init__.py index adeefca2..b9df3107 100644 --- a/organize/__init__.py +++ b/organize/__init__.py @@ -1,32 +1,38 @@ -# import logging -# import logging.config -# -# # configure logging -# LOGGING_CONFIG = """ -# version: 1 -# disable_existing_loggers: false -# formatters: -# simple: -# format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' -# handlers: -# console: -# class: logging.StreamHandler -# level: DEBUG -# formatter: simple -# stream: ext://sys.stdout -# file: -# class: logging.handlers.TimedRotatingFileHandler -# level: DEBUG -# filename: {filename} -# formatter: simple -# when: midnight -# backupCount: 30 -# root: -# level: DEBUG -# handlers: [file] -# exifread: -# level: INFO -# """.format( -# filename=str(LOG_PATH) -# ) -# logging.config.dictConfig(yaml.safe_load(LOGGING_CONFIG)) +import logging +import logging.config +import yaml + +from fs import appfs + +with appfs.UserLogFS("organize") as log_fs: + LOG_PATH = log_fs.getsyspath("organize.log") + +# configure logging +LOGGING_CONFIG = """ +version: 1 +disable_existing_loggers: false +formatters: + simple: + format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' +handlers: + console: + class: logging.StreamHandler + level: DEBUG + formatter: simple + stream: ext://sys.stdout + file: + class: logging.handlers.TimedRotatingFileHandler + level: DEBUG + filename: {filename} + formatter: simple + when: midnight + backupCount: 30 +root: + level: DEBUG + handlers: [file] +exifread: + level: INFO +""".format( + filename=str(LOG_PATH) +) +logging.config.dictConfig(yaml.safe_load(LOGGING_CONFIG)) diff --git a/organize/actions/action.py b/organize/actions/action.py index ca1db8e3..775aa870 100644 --- a/organize/actions/action.py +++ b/organize/actions/action.py @@ -1,10 +1,14 @@ -from typing import Any, Dict, Union +import logging +from typing import Any, Dict from typing import Optional as tyOptional +from typing import Union from schema import Optional, Or, Schema from organize.console import pipeline_error, pipeline_message +logger = logging.getLogger(__name__) + class Error(Exception): pass @@ -48,9 +52,10 @@ def run(self, simulate: bool, **kwargs) -> tyOptional[Dict[str, Any]]: def pipeline(self, args: dict, simulate: bool) -> tyOptional[Dict[str, Any]]: raise NotImplementedError - def print(self, msg) -> None: + def print(self, *msg) -> None: """print a message for the user""" - for line in msg.splitlines(): + text = " ".join(str(x) for x in msg) + for line in text.splitlines(): pipeline_message(source=self.get_name(), msg=line) def print_error(self, msg: str): diff --git a/organize/actions/copy.py b/organize/actions/copy.py index f3e56535..72880965 100644 --- a/organize/actions/copy.py +++ b/organize/actions/copy.py @@ -1,17 +1,16 @@ -import logging -from typing import Callable +from typing import Callable, Union +from fs import open_fs +from fs import errors from fs.base import FS from fs.copy import copy_dir, copy_file -from fs.path import basename, dirname, join +from fs.path import dirname from schema import Optional, Or -from organize.utils import Template, open_fs_or_sim, resource_description +from organize.utils import Template, safe_description, SimulationFS from .action import Action -from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict - -logger = logging.getLogger(__name__) +from .copymove_utils import CONFLICT_OPTIONS, check_conflict, dst_from_options class Copy(Action): @@ -58,7 +57,7 @@ def __init__( dest: str, on_conflict="rename_new", rename_template="{name} {counter}{extension}", - filesystem=None, + filesystem: Union[str, FS] = "", ) -> None: if on_conflict not in CONFLICT_OPTIONS: raise ValueError( @@ -74,59 +73,45 @@ def pipeline(self, args: dict, simulate: bool): src_fs = args["fs"] # type: FS src_path = args["fs_path"] - dst_path = self.dest.render(**args) - # if the destination ends with a slash we assume the name should not change - if dst_path.endswith(("\\", "/")): - dst_path = join(dst_path, basename(src_path)) - - if self.filesystem: - dst_fs_ = self.filesystem - # render if we have a template - if isinstance(dst_fs_, str): - dst_fs_ = Template.from_string(dst_fs_).render(**args) - dst_fs = open_fs_or_sim( - dst_fs_, - writeable=True, - create=True, - simulate=simulate, - ) - dst_path = dst_path - else: - dst_fs = open_fs_or_sim( - dirname(dst_path), - writeable=True, - create=True, - simulate=simulate, - ) - dst_path = basename(dst_path) - + # should we copy a dir or a file? copy_action: Callable[[FS, str, FS, str], None] if src_fs.isdir(src_path): copy_action = copy_dir elif src_fs.isfile(src_path): copy_action = copy_file - skip = False - if dst_fs.exists(dst_path): - self.print( - '%s already exists (conflict mode is "%s").' - % (resource_description(dst_fs, dst_path), self.conflict_mode) - ) - dst_fs, dst_path, skip = resolve_overwrite_conflict( - src_fs=src_fs, - src_path=src_path, - dst_fs=dst_fs, - dst_path=dst_path, - conflict_mode=self.conflict_mode, - rename_template=self.rename_template, - simulate=simulate, - print=self.print, - ) + dst_fs, dst_path = dst_from_options( + src_path=src_path, + dest=self.dest, + filesystem=self.filesystem, + args=args, + ) + + # check for conflicts + skip, dst_path = check_conflict( + src_fs=src_fs, + src_path=src_path, + dst_fs=dst_fs, + dst_path=dst_path, + conflict_mode=self.conflict_mode, + rename_template=self.rename_template, + simulate=simulate, + print=self.print, + ) + + try: + dst_fs = open_fs(dst_fs, create=False, writeable=True) + except errors.CreateFailed: + if not simulate: + dst_fs = open_fs(dst_fs, create=True, writeable=True) + else: + dst_fs = SimulationFS(dst_fs) + if not skip: + self.print("Copy to %s" % safe_description(dst_fs, dst_path)) if not simulate: dst_fs.makedirs(dirname(dst_path), recreate=True) copy_action(src_fs, src_path, dst_fs, dst_path) - self.print("Copied to %s" % resource_description(dst_fs, dst_path)) # the next action should work with the newly created copy return { diff --git a/organize/actions/copymove_utils.py b/organize/actions/copymove_utils.py new file mode 100644 index 00000000..a5236238 --- /dev/null +++ b/organize/actions/copymove_utils.py @@ -0,0 +1,169 @@ +from typing import Callable, Union + +import jinja2 +from fs import errors, open_fs +from fs.base import FS +from fs.move import move_dir, move_file +from fs.path import basename, dirname, join, splitext +from jinja2 import Template + +from organize.utils import expand_args, is_same_resource + +from .trash import Trash + +CONFLICT_OPTIONS = ( + "skip", + "overwrite", + "trash", + "rename_new", + "rename_existing", + # "keep_newer", + # "keep_older", +) + + +def next_free_name(fs: FS, template: jinja2.Template, name: str, extension: str) -> str: + """ + Increments {counter} in the template until the given resource does not exist. + + Args: + fs (FS): the filesystem to work on + template (jinja2.Template): + A jinja2 template with placeholders for {name}, {extension} and {counter} + name (str): The wanted filename + extension (str): the wanted extension + + Raises: + ValueError if no free name can be found with the given template.so + + Returns: + (str) A filename according to the given template that does not exist on **fs**. + """ + counter = 1 + prev_candidate = "" + while True: + candidate = template.render(name=name, extension=extension, counter=counter) + if not fs.exists(candidate): + return candidate + if prev_candidate == candidate: + raise ValueError( + "Could not find a free filename for the given template. " + 'Maybe you forgot the "{counter}" placeholder?' + ) + prev_candidate = candidate + counter += 1 + + +def resolve_overwrite_conflict( + src_fs: FS, + src_path: str, + dst_fs: FS, + dst_path: str, + conflict_mode: str, + rename_template: Template, + simulate: bool, + print: Callable, +) -> Union[None, str]: + """ + Returns: + - A new path if applicable + - None if this action should be skipped. + """ + if is_same_resource(src_fs, src_path, dst_fs, dst_path): + print("Same resource: Skipped.") + return + + if conflict_mode == "trash": + Trash().run(fs=dst_fs, fs_path=dst_path, simulate=simulate) + return dst_path + + elif conflict_mode == "skip": + print("Skipped.") + return + + elif conflict_mode == "overwrite": + print("Overwrite %s." % dst_fs.desc(dst_path)) + return dst_path + + elif conflict_mode == "rename_new": + stem, ext = splitext(dst_path) + name = next_free_name( + fs=dst_fs, + name=stem, + extension=ext, + template=rename_template, + ) + return name + + elif conflict_mode == "rename_existing": + stem, ext = splitext(dst_path) + name = next_free_name( + fs=dst_fs, + name=stem, + extension=ext, + template=rename_template, + ) + print('Renaming existing to: "%s"' % name) + if not simulate: + if dst_fs.isdir(dst_path): + move_dir(dst_fs, dst_path, dst_fs, name) + elif dst_fs.isfile(dst_path): + move_file(dst_fs, dst_path, dst_fs, name) + return dst_path + + raise ValueError("Unknown conflict_mode %s" % conflict_mode) + + +def dst_from_options(src_path, dest, filesystem, args: dict): + # append the original resource name if destination is a dir (ends with "/") + dst_path = expand_args(dest, args) + if dst_path.endswith(("\\", "/")): + dst_path = join(dst_path, basename(src_path)) + + if filesystem: + if isinstance(filesystem, str): + dst_fs = expand_args(filesystem, args) + else: + dst_fs = filesystem + else: + dst_fs = dirname(dst_path) + dst_path = basename(dst_path) + return dst_fs, dst_path + + +def check_conflict( + src_fs: FS, + src_path: str, + dst_fs: FS, + dst_path: str, + conflict_mode: str, + rename_template: Template, + simulate: bool, + print: Callable, +): + skip = False + try: + check_fs = open_fs(dst_fs, create=False, writeable=True) + if check_fs.exists(dst_path): + print( + '%s already exists! (conflict mode is "%s").' + % (dst_fs.desc(dst_path), conflict_mode) + ) + new_path = resolve_overwrite_conflict( + src_fs=src_fs, + src_path=src_path, + dst_fs=check_fs, + dst_path=dst_path, + conflict_mode=conflict_mode, + rename_template=rename_template, + simulate=simulate, + print=print, + ) + if new_path is not None: + dst_path = new_path + else: + skip = True + except errors.CreateFailed: + pass + + return skip, dst_path diff --git a/organize/actions/echo.py b/organize/actions/echo.py index 2eacc4c4..81eb0d01 100644 --- a/organize/actions/echo.py +++ b/organize/actions/echo.py @@ -19,7 +19,7 @@ class Echo(Action): def get_schema(cls): return {cls.name: str} - def __init__(self, msg) -> None: + def __init__(self, msg): self.msg = Template.from_string(msg) def pipeline(self, args: dict, simulate: bool) -> None: diff --git a/organize/actions/move.py b/organize/actions/move.py index 89bb5054..962496db 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -6,10 +6,10 @@ from fs.path import basename, dirname, join from schema import Optional, Or -from organize.utils import Template, open_fs_or_sim, resource_description +from organize.utils import Template, open_fs_or_sim, safe_description from .action import Action -from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict +from .copymove_utils import CONFLICT_OPTIONS, resolve_overwrite_conflict logger = logging.getLogger(__name__) @@ -114,7 +114,7 @@ def pipeline(self, args: dict, simulate: bool): if dst_fs.exists(dst_path): self.print( '%s already exists (conflict mode is "%s").' - % (resource_description(dst_fs, dst_path), self.conflict_mode) + % (safe_description(dst_fs, dst_path), self.conflict_mode) ) dst_fs, dst_path, skip = resolve_overwrite_conflict( src_fs=src_fs, @@ -130,7 +130,7 @@ def pipeline(self, args: dict, simulate: bool): if not simulate: dst_fs.makedirs(dirname(dst_path), recreate=True) move_action(src_fs, src_path, dst_fs, dst_path) - self.print("Moved to %s" % resource_description(dst_fs, dst_path)) + self.print("Moved to %s" % dst_fs.desc(dst_path)) # the next action should work with the newly created copy return { diff --git a/organize/actions/rename.py b/organize/actions/rename.py index 1df65d46..c530c2ab 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -7,10 +7,10 @@ from fs.move import move_dir, move_file from schema import Optional, Or -from organize.utils import Template, resource_description +from organize.utils import Template, safe_description from .action import Action -from .utils import CONFLICT_OPTIONS, resolve_overwrite_conflict +from .copymove_utils import CONFLICT_OPTIONS, resolve_overwrite_conflict logger = logging.getLogger(__name__) @@ -88,7 +88,7 @@ def pipeline(self, args: dict, simulate: bool): if fs.exists(dst_path): self.print( '%s already exists (conflict mode is "%s").' - % (resource_description(fs, dst_path), self.conflict_mode) + % (safe_description(fs, dst_path), self.conflict_mode) ) fs, dst_path, skip = resolve_overwrite_conflict( src_fs=fs, @@ -103,7 +103,7 @@ def pipeline(self, args: dict, simulate: bool): if not skip: if not simulate: move_action(fs, src_path, fs, dst_path) - self.print("Renamed to %s" % resource_description(fs, dst_path)) + self.print("Renamed to %s" % safe_description(fs, dst_path)) # the next action should work with the newly created copy return { diff --git a/organize/actions/utils.py b/organize/actions/utils.py deleted file mode 100644 index 8a7f41fc..00000000 --- a/organize/actions/utils.py +++ /dev/null @@ -1,82 +0,0 @@ -from typing import Callable, Tuple, Union, NamedTuple - -from fs.base import FS -from fs.move import move_dir, move_file -from fs.path import splitext -from jinja2 import Template - -from organize.utils import resource_description, next_free_name, is_same_resource - -from .trash import Trash - -CONFLICT_OPTIONS = ( - "skip", - "overwrite", - "trash", - "rename_new", - "rename_existing", - # "keep_newer", - # "keep_older", -) - - -class ResolverResult(NamedTuple): - dst_fs: FS - dst_path: str - skip: bool - - -def resolve_overwrite_conflict( - src_fs: FS, - src_path: str, - dst_fs: FS, - dst_path: str, - conflict_mode: str, - rename_template: Template, - simulate: bool, - print: Callable, -) -> ResolverResult: - if is_same_resource(src_fs, src_path, dst_fs, dst_path): - print("Same resource: Skipped.") - return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=True) - - if conflict_mode == "trash": - Trash().pipeline({"fs": dst_fs, "fs_path": dst_path}, simulate=simulate) - return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=False) - - elif conflict_mode == "skip": - print("Skipped.") - return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=True) - - elif conflict_mode == "overwrite": - print("Overwrite %s." % resource_description(dst_fs, dst_path)) - return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=False) - - elif conflict_mode == "rename_new": - stem, ext = splitext(dst_path) - name = next_free_name( - fs=dst_fs, - name=stem, - extension=ext, - template=rename_template, - ) - return ResolverResult(dst_fs=dst_fs, dst_path=name, skip=False) - - elif conflict_mode == "rename_existing": - stem, ext = splitext(dst_path) - name = next_free_name( - fs=dst_fs, - name=stem, - extension=ext, - template=rename_template, - ) - print('Renaming existing to: "%s"' % name) - if not simulate: - if dst_fs.isdir(dst_path): - move_dir(dst_fs, dst_path, dst_fs, name) - elif dst_fs.isfile(dst_path): - move_file(dst_fs, dst_path, dst_fs, name) - - return ResolverResult(dst_fs=dst_fs, dst_path=dst_path, skip=False) - - raise ValueError("Unknown conflict_mode %s" % conflict_mode) diff --git a/organize/cli.py b/organize/cli.py index fc05e54d..641a7568 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -78,7 +78,8 @@ def run_local(config_path: str, working_dir: str, simulate: bool): try: console.info(config_path=config_path, working_dir=working_dir) config = open_fs(".").readtext(config_path) - core.run(fs=open_fs(working_dir), rules=config, simulate=simulate) + os.chdir(working_dir) + core.run(rules=config, simulate=simulate) except SchemaError as e: console.error("Invalid config file!") for err in e.autos: diff --git a/organize/core.py b/organize/core.py index c4fd0e1d..2707cd4b 100644 --- a/organize/core.py +++ b/organize/core.py @@ -68,6 +68,8 @@ def convert_options_to_walker_args(options: dict): def instantiate_location(options: Union[str, dict], default_max_depth=0) -> Location: + if isinstance(options, Location): + return options if isinstance(options, str): options = {"path": options} @@ -89,6 +91,8 @@ def instantiate_location(options: Union[str, dict], default_max_depth=0) -> Loca def instantiate_filter(filter_config): + if isinstance(filter_config, Filter): + return filter_config spec = ensure_dict(filter_config) name, value = next(iter(spec.items())) parts = name.split(maxsplit=1) @@ -103,6 +107,8 @@ def instantiate_filter(filter_config): def instantiate_action(action_config): + if isinstance(action_config, Action): + return action_config spec = ensure_dict(action_config) name, value = next(iter(spec.items())) args, kwargs = to_args(value) @@ -197,7 +203,7 @@ def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bo if updates is not None: deep_merge_inplace(args, updates) except Exception as e: # pylint: disable=broad-except - # logger.exception(e) + logger.exception(e) action.print_error(str(e)) return False return True @@ -256,22 +262,24 @@ def run_rules(rules: dict, simulate: bool = True): return count -def run(conf: Union[str, dict], simulate: bool): +def run(rules: Union[str, dict], simulate: bool, validate=True): # load and validate - if isinstance(conf, str): - conf = config.load_from_string(conf) + if isinstance(rules, str): + rules = config.load_from_string(rules) + + rules = config.cleanup(rules) - conf = config.cleanup(conf) - config.validate(conf) + if validate: + config.validate(rules) # instantiate - warnings = replace_with_instances(conf) + warnings = replace_with_instances(rules) for msg in warnings: console.warn(msg) # run - count = run_rules(rules=conf, simulate=simulate) + count = run_rules(rules=rules, simulate=simulate) console.summary(count) - print(count) + if count["fail"]: - raise ValueError("Some actions failed.") + raise RuntimeWarning("Some actions failed.") diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index 5fae723a..5babd3d0 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -68,7 +68,7 @@ def __init__(self, select_original_by="location"): # we keep track of the files we already computed the hashes for so we only do # that once. - self.handled_files = set() # type: Set[File] + self.seen_files = set() # type: Set[File] self.first_chunk_known = set() # type: Set[File] self.hash_known = set() # type: Set[File] @@ -77,13 +77,13 @@ def matches(self, fs: FS, path: str) -> Union[bool, Dict[str, str]]: # the exact same path has already been handled. This happens if multiple # locations emit this file in a single rule or if we follow symlinks. # We skip these. - if file_ in self.handled_files or any( + if file_ in self.seen_files or any( is_same_resource(file_.fs, file_.path, x.fs, x.path) - for x in self.handled_files + for x in self.seen_files ): return False - self.handled_files.add(file_) + self.seen_files.add(file_) # check for files with equal size file_size = getsize(file_) @@ -120,9 +120,10 @@ def matches(self, fs: FS, path: str) -> Union[bool, Dict[str, str]]: # check full hash collisions with the current file hash_ = full_hash(file_) + self.hash_known.add(file_) original = self.file_for_hash.get(hash_) if original: - return {"duplicate": original} + return {"filesystem": original.fs, "path": original.path} return False diff --git a/organize/filters/filter.py b/organize/filters/filter.py index 29092201..dfa3d58b 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -60,7 +60,8 @@ def pipeline(self, args: dict) -> FilterResult: def print(self, msg: str) -> None: """print a message for the user""" - for line in msg.splitlines(): + text = " ".join(str(x) for x in msg) + for line in text.splitlines(): pipeline_message(self.get_name(), line) def print_error(self, msg: str): diff --git a/organize/utils.py b/organize/utils.py index 1abca9b3..510af25e 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -1,3 +1,4 @@ +import logging import os from copy import deepcopy from datetime import datetime @@ -8,7 +9,6 @@ from fs import path as fspath from fs.base import FS from fs.memoryfs import MemoryFS -from fs.osfs import OSFS from jinja2 import nativetypes @@ -74,17 +74,20 @@ def expand_user(fs_url: str): return fs_url -def expand_with_args(fs_url: str, args=None): +def expand_args(template: Union[str, jinja2.environment.Template], args=None): if not args: args = basic_args() - fs_url = expand_user(fs_url) + if isinstance(template, str): + text = Template.from_string(template).render(**args) + else: + text = template.render(**args) - # fill environment vars - fs_url = os.path.expandvars(fs_url) - fs_url = Template.from_string(fs_url).render() + # expand user and fill environment vars + text = expand_user(text) + text = os.path.expandvars(text) - return fs_url + return text def fs_path_from_options(path: str, filesystem: Union[FS, str] = ""): @@ -104,7 +107,7 @@ def fs_path_from_options(path: str, filesystem: Union[FS, str] = ""): if isinstance(filesystem, str): filesystem = expand_user(filesystem) if filesystem else None return (open_fs(filesystem), path) - return (filesystem.opendir(path), "/") + return (filesystem, path) def is_same_resource(fs1: FS, path1: str, fs2: FS, path2: str): @@ -156,14 +159,13 @@ def unwrap(fs, path): return False -def resource_description(fs, path): - if isinstance(fs, SimulationFS): - return "%s%s" % (str(fs), fspath.abspath(path)) - elif isinstance(fs, OSFS): +def safe_description(fs: FS, path): + try: + if isinstance(fs, SimulationFS): + return "%s%s" % (str(fs), fspath.abspath(path)) return fs.getsyspath(path) - elif path == "/": - return str(fs) - return "{} on {}".format(path, fs) + except: + return "{} on {}".format(path, fs) def ensure_list(inp): @@ -249,35 +251,3 @@ def deep_merge_inplace(base: dict, updates: dict) -> None: deep_merge_inplace(av, bv) else: base[bk] = bv - - -def next_free_name(fs: FS, template: jinja2.Template, name: str, extension: str) -> str: - """ - Increments {counter} in the template until the given resource does not exist. - - Args: - fs (FS): the filesystem to work on - template (jinja2.Template): - A jinja2 template with placeholders for {name}, {extension} and {counter} - name (str): The wanted filename - extension (str): the wanted extension - - Raises: - ValueError if no free name can be found with the given template. - - Returns: - (str) A filename according to the given template that does not exist on **fs**. - """ - counter = 1 - prev_candidate = "" - while True: - candidate = template.render(name=name, extension=extension, counter=counter) - if not fs.exists(candidate): - return candidate - if prev_candidate == candidate: - raise ValueError( - "Could not find a free filename for the given template. " - 'Maybe you forgot the "{counter}" placeholder?' - ) - prev_candidate = candidate - counter += 1 diff --git a/tests/actions/test_copy.py b/tests/actions/test_copy.py new file mode 100644 index 00000000..a892a43f --- /dev/null +++ b/tests/actions/test_copy.py @@ -0,0 +1,67 @@ +from copy import deepcopy +import fs +from conftest import make_files, read_files +from organize import core + +files = { + "files": { + "test.txt": "", + "file.txt": "Hello world\nAnother line", + "another.txt": "", + "folder": { + "x.txt": "", + }, + } +} + + +def test_copy_on_itself(): + with fs.open_fs("mem://") as mem: + config = { + "rules": [ + { + "locations": [ + {"path": "files", "filesystem": mem}, + ], + "actions": [ + {"copy": {"dest": "files/", "filesystem": mem}}, + ], + }, + ] + } + make_files(mem, files) + core.run(config, simulate=False) + result = read_files(mem) + assert result == files + + +def test_does_not_create_folder_in_simulation(): + with fs.open_fs("mem://") as mem: + config = { + "rules": [ + { + "locations": [ + {"path": "files", "filesystem": mem}, + ], + "actions": [ + {"copy": {"dest": "files/new-subfolder/", "filesystem": mem}}, + {"copy": {"dest": "files/copyhere/", "filesystem": mem}}, + ], + }, + ] + } + make_files(mem, files) + core.run(config, simulate=True) + result = read_files(mem) + assert result == files + + core.run(config, simulate=False, validate=False) + result = read_files(mem) + + expected = deepcopy(files) + expected["files"]["new-subfolder"] = deepcopy(files["files"]) + expected["files"]["new-subfolder"].pop("folder") + expected["files"]["copyhere"] = deepcopy(files["files"]) + expected["files"]["copyhere"].pop("folder") + + assert result == expected diff --git a/tests/conftest.py b/tests/conftest.py index db6861c2..f23b4337 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,16 @@ +from unittest.mock import patch + +import pytest from fs.base import FS -from fs.path import join, basename +from fs.path import basename, join + +from organize import config + + +@pytest.fixture +def mock_echo(): + with patch("organize.actions.Echo.print") as mck: + yield mck def make_files(fs: FS, layout: dict, path="/"): @@ -40,3 +51,31 @@ def read_files(fs: FS, path="/"): for x in fs.walk.dirs(path, max_depth=0): result[basename(x)] = read_files(fs, path=join(path, x)) return result + + +def rules_shortcut(fs: FS, filters, actions, location="files", max_depth=0): + if isinstance(filters, str): + filters = config.load_from_string(filters) + if isinstance(actions, str): + actions = config.load_from_string(actions) + + # for action in actions: + # for opts in action.values(): + # if "filesystem" in opts and opts["filesystem"] == "mem": + # opts["filesystem"] = fs + + return { + "rules": [ + { + "locations": [ + { + "path": location, + "filesystem": fs, + "max_depth": max_depth, + } + ], + "actions": actions, + "filters": filters, + } + ] + } diff --git a/tests/integration/test_codepost_usecase.py b/tests/integration/test_codepost_usecase.py index 9104b2da..325ec9a0 100644 --- a/tests/integration/test_codepost_usecase.py +++ b/tests/integration/test_codepost_usecase.py @@ -1,7 +1,7 @@ import fs from conftest import make_files, read_files -from organize import actions, config, core +from organize import config, core def test_codepost_usecase(): @@ -44,14 +44,6 @@ def test_codepost_usecase(): {"move": {"dest": "files/{python.mail}.txt", "filesystem": mem}} ], }, - # TODO: Test for moving files onto themselves - # { - # "locations": [ - # {"path": "files", "filesystem": mem}, - # ], - # "filters": [{"extension": "txt"}], - # "actions": [{"move": {"dest": "files/", "filesystem": mem}}], - # }, ] } core.run(conf, simulate=False) diff --git a/tests/integration/test_delete.py b/tests/integration/test_delete.py index f0b00d89..c2d15707 100644 --- a/tests/integration/test_delete.py +++ b/tests/integration/test_delete.py @@ -9,7 +9,7 @@ def test_delete(): "folder": { "subfolder": { "test.txt": "", - "other.pdf": b"binary", + "other.pdf": "binary", }, "file.txt": "Hello world\nAnother line", }, @@ -30,9 +30,15 @@ def test_delete(): ] } make_files(mem, files) - core.run(config, simulate=False) + + # simulate + core.run(config, simulate=True) result = read_files(mem) + assert result == files + # run + core.run(config, simulate=False, validate=False) + result = read_files(mem) assert result == { "files": {}, } diff --git a/tests/integration/test_dict_merge.py b/tests/integration/test_dict_merge.py new file mode 100644 index 00000000..dbe4f3e3 --- /dev/null +++ b/tests/integration/test_dict_merge.py @@ -0,0 +1,32 @@ +from unittest.mock import call + +import fs +from conftest import make_files, rules_shortcut +from organize import core + + +def test_multiple_regex_placeholders(mock_echo): + files = { + "files": {"test-123.jpg": "", "other-456.pdf": ""}, + } + with fs.open_fs("mem://") as mem: + rules = rules_shortcut( + fs=mem, + filters=r""" + - regex: (?P\w+)-(?P\d+).* + - regex: (?P.+?)\.\w{3} + - extension + """, + actions=""" + - echo: '{regex.word} {regex.number} {regex.all} {extension}' + """, + ) + make_files(mem, files) + core.run(rules, simulate=False, validate=False) + mock_echo.assert_has_calls( + ( + call("test 123 test-123 jpg"), + call("other 456 other-456 pdf"), + ), + any_order=True, + ) diff --git a/tests/integration/test_duplicate.py b/tests/integration/test_duplicate.py new file mode 100644 index 00000000..174e59e1 --- /dev/null +++ b/tests/integration/test_duplicate.py @@ -0,0 +1,75 @@ +import fs +from conftest import make_files, rules_shortcut, read_files +from organize import core + +CONTENT_SMALL = "COPY CONTENT" +CONTENT_LARGE = "XYZ" * 3000 + + +def test_duplicate_smallfiles(): + files = { + "files": { + "unique.txt": "I'm unique.", + "unique_too.txt": "I'm unique: too.", + "a.txt": CONTENT_SMALL, + "copy2.txt": CONTENT_SMALL, + "other": { + "copy.txt": CONTENT_SMALL, + "copy.jpg": CONTENT_SMALL, + "large.txt": CONTENT_LARGE, + }, + "large_unique.txt": CONTENT_LARGE, + }, + } + + with fs.open_fs("mem://") as mem: + make_files(mem, files) + rules = rules_shortcut( + mem, + filters="- duplicate", + actions="- echo: '{fs_path} is duplicate of {duplicate}'\n- delete", + max_depth=None, + ) + core.run(rules, simulate=False, validate=False) + result = read_files(mem) + mem.tree() + assert result == { + "files": { + "unique.txt": "I'm unique.", + "unique_too.txt": "I'm unique: too.", + "a.txt": CONTENT_SMALL, + "other": { + "large.txt": CONTENT_LARGE, + }, + }, + } + + +# main(["run", "--config-file=%s" % (tmp_path / "config.yaml")]) +# assertdir(tmp_path,) + + +# def test_duplicate_largefiles(tmp_path): +# create_filesystem( +# tmp_path, +# files=[ +# ("unique.txt", CONTENT_LARGE + "1"), +# ("unique_too.txt", CONTENT_LARGE + "2"), +# ("a.txt", CONTENT_LARGE), +# ("copy2.txt", CONTENT_LARGE), +# ("other/copy.txt", CONTENT_LARGE), +# ("other/copy.jpg", CONTENT_LARGE), +# ("other/large.txt", CONTENT_LARGE), +# ], +# config=""" +# rules: +# - folders: files +# subfolders: true +# filters: +# - duplicate +# actions: +# - trash +# """, +# ) +# main(["run", "--config-file=%s" % (tmp_path / "config.yaml")]) +# assertdir(tmp_path, "unique.txt", "unique_too.txt", "a.txt") diff --git a/tests/todo/integration/test_codepost_usecase.py b/tests/todo/integration/test_codepost_usecase.py deleted file mode 100644 index 17cadc92..00000000 --- a/tests/todo/integration/test_codepost_usecase.py +++ /dev/null @@ -1,51 +0,0 @@ -import fs -from conftest import make_files, read_files - -from organize import actions, config, core - - -def test_codepost_usecase(): - files = { - "files": { - "Devonte-Betts.txt": "", - "Alaina-Cornish.txt": "", - "Dimitri-Bean.txt": "", - "Lowri-Frey.txt": "", - "Someunknown-User.txt": "", - } - } - conf = config.load_from_string( - r""" - rules: - - locations: files - filters: - - extension: txt - - regex: (?P\w+)-(?P\w+)\..* - - python: | - emails = { - "Betts": "dbetts@mail.de", - "Cornish": "acornish@google.com", - "Bean": "dbean@aol.com", - "Frey": "l-frey@frey.org", - } - if regex["lastname"] in emails: - return {"mail": emails[regex["lastname"]]} - """ - ) - with fs.open_fs("mem://") as mem: - conf["actions"] = actions.Move("{python.mail}.txt", filesystem=mem) - make_files(mem, files) - print(conf) - core.run(mem, conf, simulate=False) - result = read_files(mem) - mem.tree() - - assert result == { - "files": { - "dbetts@mail.de.txt": "", - "acornish@google.com.txt": "", - "dbean@aol.com.txt": "", - "l-frey@frey.org.txt": "", - "Someunknown-User.txt": "", - } - } diff --git a/tests/todo/integration/test_delete.py b/tests/todo/integration/test_delete.py deleted file mode 100644 index 4faab10a..00000000 --- a/tests/todo/integration/test_delete.py +++ /dev/null @@ -1,36 +0,0 @@ -import fs -from conftest import make_files, read_files, organize - - -def test_delete(): - config = """ - rules: - - locations: "files" - subfolders: true - actions: - - delete - - locations: "files" - targets: dirs - subfolders: true - actions: - - delete - """ - files = { - "files": { - "folder": { - "subfolder": { - "test.txt": "", - "other.pdf": b"binary", - }, - "file.txt": "Hello world\nAnother line", - }, - } - } - with fs.open_fs("mem://") as mem: - make_files(mem, files) - organize(mem, config, simulate=False) - result = read_files(mem) - - assert result == { - "files": {}, - } diff --git a/tests/todo/integration/test_dict_merge.py b/tests/todo/integration/test_dict_merge.py deleted file mode 100644 index 839e8043..00000000 --- a/tests/todo/integration/test_dict_merge.py +++ /dev/null @@ -1,26 +0,0 @@ -from unittest.mock import call - -from conftest import create_filesystem -from organize.cli import main - - -def test_multiple_regex_placeholders(tmp_path, mock_echo): - create_filesystem( - tmp_path, - files=["test-123.jpg", "other-456.pdf"], - config=r""" - rules: - - folders: files - filters: - - regex: (?P\w+)-(?P\d+).* - - regex: (?P.+?)\.\w{3} - - extension - actions: - - echo: '{regex.word} {regex.number} {regex.all} {extension}' - """, - ) - main(["run", "--config-file=%s" % (tmp_path / "config.yaml")]) - mock_echo.assert_has_calls( - (call("test 123 test-123 jpg"), call("other 456 other-456 pdf"),), - any_order=True, - ) diff --git a/tests/todo/integration/test_duplicate.py b/tests/todo/integration/test_duplicate.py deleted file mode 100644 index 9567e361..00000000 --- a/tests/todo/integration/test_duplicate.py +++ /dev/null @@ -1,58 +0,0 @@ -from conftest import create_filesystem, assertdir -from organize.cli import main - -CONTENT_SMALL = "COPY CONTENT" -CONTENT_LARGE = "XYZ" * 3000 - - -def test_duplicate_smallfiles(tmp_path): - create_filesystem( - tmp_path, - files=[ - ("unique.txt", "I'm unique."), - ("unique_too.txt", "I'm unique, too."), - ("a.txt", CONTENT_SMALL), - ("copy2.txt", CONTENT_SMALL), - ("other/copy.txt", CONTENT_SMALL), - ("other/copy.jpg", CONTENT_SMALL), - ("large_unique.txt", CONTENT_LARGE), - ("other/large.txt", CONTENT_LARGE), - ], - config=""" - rules: - - folders: files - subfolders: true - filters: - - duplicate - actions: - - trash - """, - ) - main(["run", "--config-file=%s" % (tmp_path / "config.yaml")]) - assertdir(tmp_path, "unique.txt", "unique_too.txt", "a.txt", "large_unique.txt") - - -def test_duplicate_largefiles(tmp_path): - create_filesystem( - tmp_path, - files=[ - ("unique.txt", CONTENT_LARGE + "1"), - ("unique_too.txt", CONTENT_LARGE + "2"), - ("a.txt", CONTENT_LARGE), - ("copy2.txt", CONTENT_LARGE), - ("other/copy.txt", CONTENT_LARGE), - ("other/copy.jpg", CONTENT_LARGE), - ("other/large.txt", CONTENT_LARGE), - ], - config=""" - rules: - - folders: files - subfolders: true - filters: - - duplicate - actions: - - trash - """, - ) - main(["run", "--config-file=%s" % (tmp_path / "config.yaml")]) - assertdir(tmp_path, "unique.txt", "unique_too.txt", "a.txt") diff --git a/tests/utils/test_deep_merge.py b/tests/utils/test_deep_merge.py index 1c7e3aab..b57bf522 100644 --- a/tests/utils/test_deep_merge.py +++ b/tests/utils/test_deep_merge.py @@ -1,4 +1,3 @@ -from pytest import mark from organize.utils import deep_merge, deep_merge_inplace diff --git a/tests/utils/test_is_same_resource.py b/tests/utils/test_is_same_resource.py index 56befafa..0b9d5c05 100644 --- a/tests/utils/test_is_same_resource.py +++ b/tests/utils/test_is_same_resource.py @@ -41,3 +41,17 @@ def test_inter(): assert is_same_resource(a, "test.txt", b, "test.txt") assert is_same_resource(b, "a/subfile.txt", a_dir, "subfile.txt") # assert is_same_resource(a, "test.txt", a_dir, "../test.txt") + + +def test_nested(): + for protocol in ("mem://", "temp://"): + with open_fs(protocol) as mem: + x = mem.makedir("sub1") + x = x.makedir("sub2") + x = x.makedir("sub3") + x.touch("file") + + y = mem.opendir("sub1") + + assert is_same_resource(mem, "sub1/sub2/sub3/file", x, "file") + assert is_same_resource(y, "sub2/sub3/file", x, "file") From c640814cfd9632d3734a2c5bc5a33e59274250f5 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 6 Feb 2022 12:14:26 +0100 Subject: [PATCH 096/108] add duplicate detection methods --- organize/actions/delete.py | 7 +-- organize/actions/move.py | 79 ++++++++++++++------------------ organize/console.py | 18 ++++++++ organize/core.py | 43 +++++++++++++----- organize/filters/duplicate.py | 86 ++++++++++++++++++++++++++++------- organize/filters/regex.py | 5 +- pyproject.toml | 2 +- 7 files changed, 162 insertions(+), 78 deletions(-) diff --git a/organize/actions/delete.py b/organize/actions/delete.py index 88f5798f..c2c90598 100644 --- a/organize/actions/delete.py +++ b/organize/actions/delete.py @@ -1,6 +1,7 @@ import logging from fs.base import FS from .action import Action +from organize.utils import safe_description logger = logging.getLogger(__name__) @@ -23,10 +24,10 @@ def get_schema(cls): def pipeline(self, args: dict, simulate: bool): fs = args["fs"] # type: FS fs_path = args["fs_path"] # type: str - relative_path = args["relative_path"] - self.print('Deleting "%s"' % relative_path) + desc = safe_description(fs=fs, path=fs_path) + self.print('Deleting "%s"' % desc) if not simulate: - logger.info("Deleting %s.", relative_path) + logger.info("Deleting %s.", desc) if fs.isdir(fs_path): fs.removetree(fs_path) elif fs.isfile(fs_path): diff --git a/organize/actions/move.py b/organize/actions/move.py index 962496db..9c8409c8 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -1,15 +1,17 @@ import logging from typing import Callable +from fs import open_fs +from fs import errors from fs.base import FS from fs.move import move_dir, move_file -from fs.path import basename, dirname, join +from fs.path import dirname from schema import Optional, Or -from organize.utils import Template, open_fs_or_sim, safe_description +from organize.utils import Template, safe_description, SimulationFS from .action import Action -from .copymove_utils import CONFLICT_OPTIONS, resolve_overwrite_conflict +from .copymove_utils import CONFLICT_OPTIONS, check_conflict, dst_from_options logger = logging.getLogger(__name__) @@ -78,59 +80,44 @@ def pipeline(self, args: dict, simulate: bool): src_fs = args["fs"] # type: FS src_path = args["fs_path"] - dst_path = self.dest.render(**args) - # if the destination ends with a slash we assume the name should not change - if dst_path.endswith(("\\", "/")): - dst_path = join(dst_path, basename(src_path)) - - if self.filesystem: - dst_fs_ = self.filesystem - # render if we have a template - if isinstance(dst_fs_, str): - dst_fs_ = Template.from_string(dst_fs_).render(**args) - dst_fs = open_fs_or_sim( - dst_fs_, - writeable=True, - create=True, - simulate=simulate, - ) - dst_path = dst_path - else: - dst_fs = open_fs_or_sim( - dirname(dst_path), - writeable=True, - create=True, - simulate=simulate, - ) - dst_path = basename(dst_path) - move_action: Callable[[FS, str, FS, str], None] if src_fs.isdir(src_path): move_action = move_dir elif src_fs.isfile(src_path): move_action = move_file - skip = False - if dst_fs.exists(dst_path): - self.print( - '%s already exists (conflict mode is "%s").' - % (safe_description(dst_fs, dst_path), self.conflict_mode) - ) - dst_fs, dst_path, skip = resolve_overwrite_conflict( - src_fs=src_fs, - src_path=src_path, - dst_fs=dst_fs, - dst_path=dst_path, - conflict_mode=self.conflict_mode, - rename_template=self.rename_template, - simulate=simulate, - print=self.print, - ) + dst_fs, dst_path = dst_from_options( + src_path=src_path, + dest=self.dest, + filesystem=self.filesystem, + args=args, + ) + + # check for conflicts + skip, dst_path = check_conflict( + src_fs=src_fs, + src_path=src_path, + dst_fs=dst_fs, + dst_path=dst_path, + conflict_mode=self.conflict_mode, + rename_template=self.rename_template, + simulate=simulate, + print=self.print, + ) + + try: + dst_fs = open_fs(dst_fs, create=False, writeable=True) + except errors.CreateFailed: + if not simulate: + dst_fs = open_fs(dst_fs, create=True, writeable=True) + else: + dst_fs = SimulationFS(dst_fs) + if not skip: + self.print("Move to %s" % safe_description(dst_fs, dst_path)) if not simulate: dst_fs.makedirs(dirname(dst_path), recreate=True) move_action(src_fs, src_path, dst_fs, dst_path) - self.print("Moved to %s" % dst_fs.desc(dst_path)) # the next action should work with the newly created copy return { diff --git a/organize/console.py b/organize/console.py index e94dc69a..ce2bc02b 100644 --- a/organize/console.py +++ b/organize/console.py @@ -6,6 +6,7 @@ from rich.theme import Theme from rich.status import Status from rich.prompt import Confirm as RichConfirm, Prompt as RichPrompt +from .utils import safe_description from organize.__version__ import __version__ @@ -157,6 +158,23 @@ def path(fs: FS, fs_path: str): with_path.set_prefix(msg) +def path_changed_during_pipeline( + fs: FS, fs_path: str, new_fs: FS, new_path: str, reason="deferred from" +): + icon = ICON_DIR if new_fs.isdir(new_path) else ICON_FILE + msg = Text.assemble( + INDENT, + _highlight_path( + safe_description(new_fs, new_path), "path.base", "path.main", relative=True + ), + (" <- %s " % reason, "yellow"), + _highlight_path(fs_path, "path.base", "path.main", relative=True), + " ", + (icon, "path.icon"), + ) + with_path.set_prefix(msg) + + def _pipeline_base(source: str): return Text.assemble( INDENT * 2, diff --git a/organize/core.py b/organize/core.py index 2707cd4b..f990fb65 100644 --- a/organize/core.py +++ b/organize/core.py @@ -3,12 +3,11 @@ from pathlib import Path from typing import Iterable, NamedTuple, Union -from fs import path as fspath, open_fs +from fs import path as fspath from fs.base import FS from fs.errors import NoSysPath from fs.walk import Walker from rich.console import Console -from schema import SchemaError from . import config, console from .actions import ACTIONS @@ -173,6 +172,12 @@ def filter_pipeline(filters: Iterable[Filter], args: dict, filter_mode: str) -> results = [] for filter_ in filters: try: + # update dynamic path args + args["path"] = syspath_or_exception(args["fs"], args["fs_path"]) + args["relative_path"] = fspath.frombase( + args["fs_base_path"], args["fs_path"] + ) + match, updates = filter_.pipeline(args) result = match ^ filter_.inverted # we cannot exit early on "any". @@ -196,8 +201,12 @@ def filter_pipeline(filters: Iterable[Filter], args: dict, filter_mode: str) -> def action_pipeline(actions: Iterable[Action], args: dict, simulate: bool) -> bool: for action in actions: try: - # update path + # update dynamic path args args["path"] = syspath_or_exception(args["fs"], args["fs_path"]) + args["relative_path"] = fspath.frombase( + args["fs_base_path"], args["fs_path"] + ) + updates = action.pipeline(args, simulate=simulate) # jobs may return a dict with updates that should be merged into args if updates is not None: @@ -221,20 +230,21 @@ def run_rules(rules: dict, simulate: bool = True): console.rule(rule.get("name", "Rule %s" % rule_nr)) filter_mode = rule.get("filter_mode", "all") - for walker, base_fs, base_path in rule["locations"]: - console.location(base_fs, base_path) + for walker, walker_fs, walker_path in rule["locations"]: + console.location(walker_fs, walker_path) walk = walker.files if target == "files" else walker.dirs - for path in walk(fs=base_fs, path=base_path): - console.path(base_fs, path) - relative_path = fspath.relativefrom(base_path, path) + for path in walk(fs=walker_fs, path=walker_path): + if walker_fs.islink(path): + continue + # tell the user which resource we're handling + console.path(walker_fs, path) # assemble the available args args = basic_args() args.update( - fs=base_fs, + fs=walker_fs, fs_path=path, - relative_path=relative_path, - path=syspath_or_exception(base_fs, path), + fs_base_path=walker_path, ) # run resource through the filter pipeline @@ -244,6 +254,17 @@ def run_rules(rules: dict, simulate: bool = True): filter_mode=filter_mode, ) + # if the currently handled resource changed we adjust the prefix message + if args.get("resource_changed"): + console.path_changed_during_pipeline( + fs=walker_fs, + fs_path=path, + new_fs=args["fs"], + new_path=args["fs_path"], + reason=args.get("resource_changed"), + ) + args.pop("resource_changed", None) + # run resource through the action pipeline if match: is_success = action_pipeline( diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index 5babd3d0..06b4f533 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -9,20 +9,36 @@ """ import hashlib from collections import defaultdict +from typing import Dict, NamedTuple, Set, Union + from fs.base import FS -from typing import Dict, Set, Union, NamedTuple +from fs.path import basename + from organize.utils import is_same_resource from .filter import Filter, FilterResult HASH_ALGORITHM = "sha1" -ORDER_BY = ("location", "created", "lastmodified", "name") -ORDER_BY_REGEX = r"(-?)\s*?({})".format("|".join(ORDER_BY)) +DETECTION_METHODS = ("first_seen", "name", "created", "lastmodified") +DETECTION_METHOD_REGEX = r"(-?)\s*?({})".format("|".join(DETECTION_METHODS)) class File(NamedTuple): fs: FS path: str + base_path: str + + @property + def lastmodified(self): + return self.fs.getmodified(self.path) + + @property + def created(self): + return self.fs.getinfo(self.path, namespaces=["details"]).created + + @property + def name(self): + return basename(self.path) def getsize(f: File): @@ -40,9 +56,21 @@ def first_chunk_hash(f: File): return hash_object.hexdigest() -def original_duplicate(a: File, b: File, ordering, reverse): - if ordering == "location": - return (a, b) if not reverse else (b, a) +def detect_original(known: File, new: File, method: str, reverse: bool): + """Returns a tuple (original file, duplicate)""" + + if method == "first_seen": + return (known, new) if not reverse else (new, known) + elif method == "name": + return tuple(sorted((known, new), key=lambda x: x.name, reverse=reverse)) + elif method == "created": + return tuple(sorted((known, new), key=lambda x: x.created, reverse=reverse)) + elif method == "lastmodified": + return tuple( + sorted((known, new), key=lambda x: x.lastmodified, reverse=reverse) + ) + else: + raise ValueError("Unknown original detection method: %s" % method) class Duplicate(Filter): @@ -53,14 +81,19 @@ class Duplicate(Filter): **Returns:** - - `{duplicate}` -- full path of the duplicate source + `{duplicate.original}` - The path to the original """ name = "duplicate" schema_support_instance_without_args = True - def __init__(self, select_original_by="location"): - self.select_original_by = select_original_by + def __init__(self, detect_original_by="first_seen"): + if detect_original_by.startswith("-"): + self.detect_original_by = detect_original_by[1:] + self.select_orignal_reverse = True + else: + self.detect_original_by = detect_original_by + self.select_orignal_reverse = False self.files_for_size = defaultdict(list) self.files_for_chunk = defaultdict(list) @@ -72,8 +105,8 @@ def __init__(self, select_original_by="location"): self.first_chunk_known = set() # type: Set[File] self.hash_known = set() # type: Set[File] - def matches(self, fs: FS, path: str) -> Union[bool, Dict[str, str]]: - file_ = File(fs=fs, path=path) + def matches(self, fs: FS, path: str, base_path: str) -> Union[bool, Dict[str, str]]: + file_ = File(fs=fs, path=path, base_path=base_path) # the exact same path has already been handled. This happens if multiple # locations emit this file in a single rule or if we follow symlinks. # We skip these. @@ -121,21 +154,42 @@ def matches(self, fs: FS, path: str) -> Union[bool, Dict[str, str]]: # check full hash collisions with the current file hash_ = full_hash(file_) self.hash_known.add(file_) - original = self.file_for_hash.get(hash_) - if original: - return {"filesystem": original.fs, "path": original.path} + known = self.file_for_hash.get(hash_) + if known: + original, duplicate = detect_original( + known=known, + new=file_, + method=self.detect_original_by, + reverse=self.select_orignal_reverse, + ) + if known != original: + self.file_for_hash[hash_] = original + + resource_changed_reason = "duplicate of" if known != original else None + from organize.core import syspath_or_exception + + return { + "fs": duplicate.fs, + "fs_path": duplicate.path, + "fs_base_path": duplicate.base_path, + "resource_changed": resource_changed_reason, + self.get_name(): { + "original": syspath_or_exception(original.fs, original.path) + }, + } return False def pipeline(self, args): fs = args["fs"] fs_path = args["fs_path"] + fs_base_path = args["fs_base_path"] if fs.isdir(fs_path): raise EnvironmentError("Dirs are not supported") - result = self.matches(fs=fs, path=fs_path) + result = self.matches(fs=fs, path=fs_path, base_path=fs_base_path) if result is False: return FilterResult(matches=False, updates={}) - return FilterResult(matches=True, updates={self.get_name(): result}) + return FilterResult(matches=True, updates=result) def __str__(self) -> str: return "Duplicate()" diff --git a/organize/filters/regex.py b/organize/filters/regex.py index f78af17b..37d0c40b 100644 --- a/organize/filters/regex.py +++ b/organize/filters/regex.py @@ -1,4 +1,7 @@ import re + +from fs.path import basename + from .filter import Filter, FilterResult @@ -29,7 +32,7 @@ def matches(self, path: str): return self.expr.search(path) def pipeline(self, args: dict) -> FilterResult: - match = self.matches(args["relative_path"]) + match = self.matches(basename(args["fs_path"])) return FilterResult( matches=bool(match), updates={ diff --git a/pyproject.toml b/pyproject.toml index db1a1e3b..0aa62748 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers = [ ] [tool.poetry.scripts] -organize = "organize.cli:main" +organize = "organize.cli:cli" [tool.poetry.dependencies] python = "^3.6.2" From 80400930602fe437ba42c34ec34ac94eb27898a5 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 6 Feb 2022 12:39:20 +0100 Subject: [PATCH 097/108] fix cli config_path arg --- organize/cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/organize/cli.py b/organize/cli.py index 641a7568..96bceb0c 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -8,6 +8,7 @@ import click from fs import appfs, osfs, open_fs +from fs.path import split from . import console from .__version__ import __version__ @@ -77,7 +78,8 @@ def run_local(config_path: str, working_dir: str, simulate: bool): try: console.info(config_path=config_path, working_dir=working_dir) - config = open_fs(".").readtext(config_path) + config_dir, config_name = split(config_path) + config = open_fs(config_dir).readtext(config_name) os.chdir(working_dir) core.run(rules=config, simulate=simulate) except SchemaError as e: From 088ed4b80ecc9cf29ca750e367d14ed7d162003d Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 6 Feb 2022 12:48:00 +0100 Subject: [PATCH 098/108] update logo url --- README.md | 4 ++-- manage.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 72b0b1b1..583506dd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- organize logo + organize logo

@@ -10,7 +10,7 @@ - + diff --git a/manage.py b/manage.py index c16d0dcc..92a5384c 100644 --- a/manage.py +++ b/manage.py @@ -140,7 +140,7 @@ def publish(args): auth=(input("Benutzer: "), getpass.getpass(prompt="API token: ")), json={ "tag_name": f"v{version}", - "target_commitish": "master", + "target_commitish": "main", "name": f"v{version}", "body": changes, "draft": False, From 87ded2ad6edf82044b0fd6314f2d22434565e75b Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 6 Feb 2022 15:38:20 +0100 Subject: [PATCH 099/108] fix rename --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 2 +- docs/actions.md | 17 +++++++++ docs/filters.md | 16 +++++++- organize/__version__.py | 2 +- organize/actions/copymove_utils.py | 10 ++--- organize/actions/move.py | 7 +--- organize/actions/python.py | 4 +- organize/actions/rename.py | 38 ++++++++----------- organize/config.py | 4 +- organize/filters/duplicate.py | 21 ++++++++++- organize/filters/filter.py | 2 +- organize/filters/regex.py | 3 +- organize/utils.py | 14 ++++--- pyproject.toml | 2 +- test.py | 6 +++ tests/integration/test_codepost_usecase.py | 2 +- tests/integration/test_rename.py | 43 ++++++++++++++++++++++ 18 files changed, 143 insertions(+), 52 deletions(-) create mode 100644 test.py create mode 100644 tests/integration/test_rename.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 199279f2..4792d487 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,4 +42,4 @@ jobs: - name: Check with MyPy run: | - poetry run mypy organize + poetry run mypy organize main.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b0bc275e..46d50445 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## v2 - In Progress +## v2.0.0 - WIP This is a huge update with lots of improvements. Please backup all your important stuff before running and use the simulate option! diff --git a/docs/actions.md b/docs/actions.md index 7a24c372..7f76a871 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -301,6 +301,23 @@ rules: print('Extension: %s' % regex.extension) ``` +Running in simulation and yaml aliases: + +```yaml +my_python_script: &script | + print("Hello World!") + print(path) + +rules: + - name: "Run in simulation and yaml alias" + locations: + - ~/Desktop/ + actions: + - python: + code: *script + run_in_simulation: yes +``` + You have access to all the python magic -- do a google search for each filename starting with an underscore: diff --git a/docs/filters.md b/docs/filters.md index 7ce70fa2..39326d2e 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -85,10 +85,24 @@ rules: locations: - ~/Desktop - ~/Downloads + subfolders: true filters: - duplicate actions: - - echo: "{path} is a duplicate of {duplicate}" + - echo: "{path} is a duplicate of {duplicate.original}" +``` + +```yaml +rules: + - name: "Check for duplicated files between Desktop and a Zip file, select original by creation date" + locations: + - ~/Desktop + - zip://~/Desktop/backup.zip + filters: + - duplicate: + detect_original_by: "created" + actions: + - echo: "Duplicate found!" ``` ## empty diff --git a/organize/__version__.py b/organize/__version__.py index a0865bba..8c0d5d5b 100644 --- a/organize/__version__.py +++ b/organize/__version__.py @@ -1 +1 @@ -__version__ = "1.10.1" +__version__ = "2.0.0" diff --git a/organize/actions/copymove_utils.py b/organize/actions/copymove_utils.py index a5236238..3ff404a2 100644 --- a/organize/actions/copymove_utils.py +++ b/organize/actions/copymove_utils.py @@ -7,7 +7,7 @@ from fs.path import basename, dirname, join, splitext from jinja2 import Template -from organize.utils import expand_args, is_same_resource +from organize.utils import expand_args, is_same_resource, safe_description from .trash import Trash @@ -71,7 +71,7 @@ def resolve_overwrite_conflict( """ if is_same_resource(src_fs, src_path, dst_fs, dst_path): print("Same resource: Skipped.") - return + return None if conflict_mode == "trash": Trash().run(fs=dst_fs, fs_path=dst_path, simulate=simulate) @@ -79,10 +79,10 @@ def resolve_overwrite_conflict( elif conflict_mode == "skip": print("Skipped.") - return + return None elif conflict_mode == "overwrite": - print("Overwrite %s." % dst_fs.desc(dst_path)) + print("Overwrite %s." % safe_description(dst_fs, dst_path)) return dst_path elif conflict_mode == "rename_new": @@ -147,7 +147,7 @@ def check_conflict( if check_fs.exists(dst_path): print( '%s already exists! (conflict mode is "%s").' - % (dst_fs.desc(dst_path), conflict_mode) + % (safe_description(dst_fs, dst_path), conflict_mode) ) new_path = resolve_overwrite_conflict( src_fs=src_fs, diff --git a/organize/actions/move.py b/organize/actions/move.py index 9c8409c8..514aeb11 100644 --- a/organize/actions/move.py +++ b/organize/actions/move.py @@ -1,5 +1,4 @@ -import logging -from typing import Callable +from typing import Callable, Union from fs import open_fs from fs import errors @@ -13,8 +12,6 @@ from .action import Action from .copymove_utils import CONFLICT_OPTIONS, check_conflict, dst_from_options -logger = logging.getLogger(__name__) - class Move(Action): @@ -64,7 +61,7 @@ def __init__( dest: str, on_conflict="rename_new", rename_template="{name} {counter}{extension}", - filesystem=None, + filesystem: Union[str, FS] = "", ) -> None: if on_conflict not in CONFLICT_OPTIONS: raise ValueError( diff --git a/organize/actions/python.py b/organize/actions/python.py index 741b6943..f9598fe3 100644 --- a/organize/actions/python.py +++ b/organize/actions/python.py @@ -49,10 +49,10 @@ def create_method(self, name: str, argnames: Iterable[str], code: str) -> None: def pipeline(self, args: dict, simulate: bool) -> tyOptional[Dict[str, Any]]: if simulate and not self.run_in_simulation: - self.print("[yellow]Code not run in simulation.[/]") + self.print("** Code not run in simulation. **") return None - logger.info('Executing python:\n"""\n%s\n""", args=%s', self.code, args) + logger.info('Executing python:\n"""\n%s\n"""', self.code) self.create_method(name="usercode", argnames=args.keys(), code=self.code) self.print("Running python script.") diff --git a/organize/actions/rename.py b/organize/actions/rename.py index c530c2ab..9ea869c0 100644 --- a/organize/actions/rename.py +++ b/organize/actions/rename.py @@ -10,7 +10,7 @@ from organize.utils import Template, safe_description from .action import Action -from .copymove_utils import CONFLICT_OPTIONS, resolve_overwrite_conflict +from .copymove_utils import CONFLICT_OPTIONS, check_conflict, resolve_overwrite_conflict logger = logging.getLogger(__name__) @@ -71,9 +71,7 @@ def pipeline(self, args: dict, simulate: bool): "To move files or folders use `move`." ) - parents, full_name = path.split(src_path) - name, ext = path.splitext(full_name) - dst_path = path.join(parents, new_name) + dst_path = path.join(path.dirname(src_path), new_name) if dst_path == src_path: self.print("Name did not change") @@ -84,28 +82,24 @@ def pipeline(self, args: dict, simulate: bool): elif fs.isfile(src_path): move_action = move_file - skip = False - if fs.exists(dst_path): - self.print( - '%s already exists (conflict mode is "%s").' - % (safe_description(fs, dst_path), self.conflict_mode) - ) - fs, dst_path, skip = resolve_overwrite_conflict( - src_fs=fs, - src_path=src_path, - dst_fs=fs, - dst_path=dst_path, - conflict_mode=self.conflict_mode, - rename_template=self.rename_template, - simulate=simulate, - print=self.print, - ) + # check for conflicts + skip, dst_path = check_conflict( + src_fs=fs, + src_path=src_path, + dst_fs=fs, + dst_path=dst_path, + conflict_mode=self.conflict_mode, + rename_template=self.rename_template, + simulate=simulate, + print=self.print, + ) + if not skip: + self.print("Rename to %s" % safe_description(fs, dst_path)) if not simulate: move_action(fs, src_path, fs, dst_path) - self.print("Renamed to %s" % safe_description(fs, dst_path)) - # the next action should work with the newly created copy + # the next action should work with the renamed file return { "fs": fs, "fs_path": dst_path, diff --git a/organize/config.py b/organize/config.py index a0165b7f..3b83b101 100644 --- a/organize/config.py +++ b/organize/config.py @@ -64,12 +64,12 @@ def default_yaml_cnst(loader, tag_suffix, node): yaml.add_multi_constructor("", default_yaml_cnst, Loader=yaml.SafeLoader) -def load_from_string(config: str): +def load_from_string(config: str) -> dict: dedented_config = textwrap.dedent(config) return yaml.load(dedented_config, Loader=yaml.SafeLoader) -def cleanup(config: dict): +def cleanup(config: dict) -> dict: result = defaultdict(list) # delete every root key except "rules" diff --git a/organize/filters/duplicate.py b/organize/filters/duplicate.py index 06b4f533..8354bf5f 100644 --- a/organize/filters/duplicate.py +++ b/organize/filters/duplicate.py @@ -74,11 +74,28 @@ def detect_original(known: File, new: File, method: str, reverse: bool): class Duplicate(Filter): - """Finds duplicate files. + """A fast duplicate file finder. This filter compares files byte by byte and finds identical files with potentially different filenames. + Args: + detect_original_by (str): + Detection method to distinguish between original and duplicate. + Possible values are: + + - `"first_seen"`: Whatever file is visited first is the original. This + depends on the order of your location entries. + - `"name"`: The first entry sorted by name is the original. + - `"created"`: The first entry sorted by creation date is the original. + - `"lastmodified"`: The first file sorted by date of last modification is the original. + + You can reverse the sorting method by prefixing a `-`. + + So with `detect_original_by: "-created"` the file with the older creation date is + the original and the younger file is the duplicate. This works on all methods, for + example `"-first_seen"`, `"-name"`, `"-created"`, `"-lastmodified"`. + **Returns:** `{duplicate.original}` - The path to the original @@ -105,7 +122,7 @@ def __init__(self, detect_original_by="first_seen"): self.first_chunk_known = set() # type: Set[File] self.hash_known = set() # type: Set[File] - def matches(self, fs: FS, path: str, base_path: str) -> Union[bool, Dict[str, str]]: + def matches(self, fs: FS, path: str, base_path: str): file_ = File(fs=fs, path=path, base_path=base_path) # the exact same path has already been handled. This happens if multiple # locations emit this file in a single rule or if we follow symlinks. diff --git a/organize/filters/filter.py b/organize/filters/filter.py index dfa3d58b..47eba6ab 100644 --- a/organize/filters/filter.py +++ b/organize/filters/filter.py @@ -58,7 +58,7 @@ def run(self, **kwargs: Dict) -> FilterResult: def pipeline(self, args: dict) -> FilterResult: raise NotImplementedError - def print(self, msg: str) -> None: + def print(self, *msg: str) -> None: """print a message for the user""" text = " ".join(str(x) for x in msg) for line in text.splitlines(): diff --git a/organize/filters/regex.py b/organize/filters/regex.py index 37d0c40b..b571d0a6 100644 --- a/organize/filters/regex.py +++ b/organize/filters/regex.py @@ -32,7 +32,8 @@ def matches(self, path: str): return self.expr.search(path) def pipeline(self, args: dict) -> FilterResult: - match = self.matches(basename(args["fs_path"])) + fs_path = args["fs_path"] + match = self.matches(basename(fs_path)) return FilterResult( matches=bool(match), updates={ diff --git a/organize/utils.py b/organize/utils.py index 510af25e..50029d35 100644 --- a/organize/utils.py +++ b/organize/utils.py @@ -2,7 +2,7 @@ import os from copy import deepcopy from datetime import datetime -from typing import Any, List, Sequence, Union +from typing import Any, List, Sequence, Union, Tuple import jinja2 from fs import open_fs @@ -65,7 +65,7 @@ def open_fs_or_sim(fs_url, *args, simulate=False, **kwargs): return open_fs(fs_url, *args, **kwargs) -def expand_user(fs_url: str): +def expand_user(fs_url: str) -> str: fs_url = os.path.expanduser(fs_url) if fs_url.startswith("zip://~"): fs_url = fs_url.replace("zip://~", "zip://" + os.path.expanduser("~")) @@ -90,7 +90,9 @@ def expand_args(template: Union[str, jinja2.environment.Template], args=None): return text -def fs_path_from_options(path: str, filesystem: Union[FS, str] = ""): +def fs_path_from_options( + path: str, filesystem: Union[FS, str, None] = "" +) -> Tuple[FS, str]: """ path can be a fs_url a normal fs_path filesystem is optional and may be a fs_url. @@ -105,7 +107,7 @@ def fs_path_from_options(path: str, filesystem: Union[FS, str] = ""): return (open_fs(path), "/") else: if isinstance(filesystem, str): - filesystem = expand_user(filesystem) if filesystem else None + filesystem = expand_user(filesystem) return (open_fs(filesystem), path) return (filesystem, path) @@ -164,8 +166,8 @@ def safe_description(fs: FS, path): if isinstance(fs, SimulationFS): return "%s%s" % (str(fs), fspath.abspath(path)) return fs.getsyspath(path) - except: - return "{} on {}".format(path, fs) + except Exception as e: + return '{} in "{}"'.format(path, fs) def ensure_list(inp): diff --git a/pyproject.toml b/pyproject.toml index 0aa62748..fb7ae092 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "organize-tool" -version = "2.0.0.beta" +version = "2.0.0" description = "The file management automation tool" packages = [{ include = "organize" }] authors = ["Thomas Feldmann "] diff --git a/test.py b/test.py new file mode 100644 index 00000000..b060d817 --- /dev/null +++ b/test.py @@ -0,0 +1,6 @@ +import fs + +with fs.open_fs("mem://") as mem: + mem.touch("Old-name.txt") + mem.move("Old-name.txt", "New-name.txt") + mem.tree() diff --git a/tests/integration/test_codepost_usecase.py b/tests/integration/test_codepost_usecase.py index 325ec9a0..e9f09eba 100644 --- a/tests/integration/test_codepost_usecase.py +++ b/tests/integration/test_codepost_usecase.py @@ -15,7 +15,7 @@ def test_codepost_usecase(): } } - with fs.open_fs("mem://") as mem: + with fs.open_fs("temp://") as mem: make_files(mem, files) filters = config.load_from_string( diff --git a/tests/integration/test_rename.py b/tests/integration/test_rename.py new file mode 100644 index 00000000..43ab73ee --- /dev/null +++ b/tests/integration/test_rename.py @@ -0,0 +1,43 @@ +import fs +from conftest import rules_shortcut, make_files, read_files +from organize import core + + +def test_rename_issue52(): + # test for issue https://github.com/tfeldmann/organize/issues/51 + files = { + "files": { + "19asd_WF_test2.pdf": "", + "other.pdf": "", + "18asd_WFX_test2.pdf": "", + } + } + with fs.open_fs("temp://") as mem: + make_files(mem, files) + config = rules_shortcut( + mem, + filters=""" + - name: + startswith: "19" + contains: + - "_WF_" + """, + actions=[ + {"rename": "{path.stem}_unread{path.suffix}"}, + {"copy": {"dest": "files/copy/", "filesystem": mem}}, + ], + ) + core.run(config, simulate=False) + mem.tree() + result = read_files(mem) + + assert result == { + "files": { + "copy": { + "19asd_WF_test2_unread.pdf": "", + }, + "19asd_WF_test2_unread.pdf": "", + "other.pdf": "", + "18asd_WFX_test2.pdf": "", + } + } From 362bb2b406768e50f997d109c26f07b1715126a7 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 6 Feb 2022 15:38:41 +0100 Subject: [PATCH 100/108] remove test files --- c.yaml | 7 ------- test.py | 6 ------ 2 files changed, 13 deletions(-) delete mode 100644 c.yaml delete mode 100644 test.py diff --git a/c.yaml b/c.yaml deleted file mode 100644 index 75cc0ca8..00000000 --- a/c.yaml +++ /dev/null @@ -1,7 +0,0 @@ -rules: - - locations: ~/Desktop - filters: - - extension - actions: - - copy: ~/Desktop/sorted/{extension}/ - - copy: ~/Desktop/sorted/{extension}/2/ diff --git a/test.py b/test.py deleted file mode 100644 index b060d817..00000000 --- a/test.py +++ /dev/null @@ -1,6 +0,0 @@ -import fs - -with fs.open_fs("mem://") as mem: - mem.touch("Old-name.txt") - mem.move("Old-name.txt", "New-name.txt") - mem.tree() From 5873dad797fe429c632831ba92c04c0ae9932538 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 6 Feb 2022 15:44:39 +0100 Subject: [PATCH 101/108] update tests --- tests/core/test_config.py | 8 ++++---- tests/utils/test_is_same_resource.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 5cbe069f..b67cc52c 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -16,7 +16,7 @@ def validate_and_convert(string: str): def test_basic(): STR = """ rules: - - locations: '~/Desktop' + - locations: '~/' filters: - extension: - jpg @@ -24,7 +24,7 @@ def test_basic(): - extension: txt actions: - move: - dest: '~/Desktop/New Folder' + dest: '~/New Folder' - echo: 'Moved {path}/{extension.upper()}' - locations: - path: '~/test1' @@ -43,8 +43,8 @@ def test_yaml_ref(): - png all_folders: &all - - ~/Desktop - - ~/Documents + - "~" + - "/" rules: - locations: *all diff --git a/tests/utils/test_is_same_resource.py b/tests/utils/test_is_same_resource.py index 0b9d5c05..6334dbcb 100644 --- a/tests/utils/test_is_same_resource.py +++ b/tests/utils/test_is_same_resource.py @@ -24,7 +24,7 @@ def test_mem2(): def test_osfs(): - a = open_fs("~/Desktop") + a = open_fs("~").makedir("Desktop", recreate=True) b = open_fs("~/") c = b.opendir("Desktop") From 6685194c560d7094cd5e3f6fbd75607977c7fe00 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Sun, 6 Feb 2022 15:56:55 +0100 Subject: [PATCH 102/108] add filter tests --- pyproject.toml | 9 ++------- tests/filters/test_extension.py | 32 ++++++++++++++++++-------------- tests/filters/test_regex.py | 26 ++++++++++++++------------ 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fb7ae092..c11a951d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,19 +70,14 @@ module = [ "exifread", "textract", "requests", + "macos_tags", ] ignore_missing_imports = true [tool.pytest.ini_options] addopts = "--doctest-modules" testpaths = ["tests", "organize"] -norecursedirs = [ - "tests/todo", - # "tests/integration", - "tests/filters", - "organize/filters", - ".configs", -] +norecursedirs = ["tests/todo", "organize/filters", ".configs"] [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/tests/filters/test_extension.py b/tests/filters/test_extension.py index 908a021a..1f468fb4 100644 --- a/tests/filters/test_extension.py +++ b/tests/filters/test_extension.py @@ -27,17 +27,21 @@ def test_extension_empty(): def test_extension_result(): - path = "somefile.TxT" - extension = Extension("txt") - assert extension.matches(path) - result = extension.run(path=path)["extension"] - assert str(result) == "TxT" - assert result.lower == "txt" - assert result.upper == "TXT" - - extension = Extension(".txt") - assert extension.matches(path) - result = extension.run(path=path)["extension"] - assert str(result) == "TxT" - assert result.lower == "txt" - assert result.upper == "TXT" + with open_fs("mem://") as mem: + + path = "somefile.TxT" + mem.touch(path) + + extension = Extension("txt") + assert extension.matches(".TxT") + result = extension.run(fs=mem, fs_path=path).updates["extension"] + assert str(result) == "TxT" + assert result.lower() == "txt" + assert result.upper() == "TXT" + + extension = Extension(".txt") + assert extension.matches(".TXT") + result = extension.run(fs=mem, fs_path=path).updates["extension"] + assert str(result) == "TxT" + assert result.lower() == "txt" + assert result.upper() == "TXT" diff --git a/tests/filters/test_regex.py b/tests/filters/test_regex.py index af00aaf3..bd98db27 100644 --- a/tests/filters/test_regex.py +++ b/tests/filters/test_regex.py @@ -3,18 +3,18 @@ TESTDATA = [ - (Path("~/Invoices/RG123456123456-sig.pdf"), True, "123456123456"), - (Path("~/Invoices/RG002312321542-sig.pdf"), True, "002312321542"), - (Path("~/Invoices/RG002312321542.pdf"), False, None), + ("RG123456123456-sig.pdf", True, "123456123456"), + ("RG002312321542-sig.pdf", True, "002312321542"), + ("RG002312321542.pdf", False, None), ] def test_regex_backslash(): regex = Regex(r"^\.pdf$") - assert regex.matches(Path(".pdf")) - assert not regex.matches(Path("+pdf")) - assert not regex.matches(Path("/pdf")) - assert not regex.matches(Path("\\pdf")) + assert regex.matches(".pdf") + assert not regex.matches("+pdf") + assert not regex.matches("/pdf") + assert not regex.matches("\\pdf") def test_regex_basic(): @@ -25,15 +25,17 @@ def test_regex_basic(): def test_regex_return(): regex = Regex(r"^RG(?P\d{12})-sig\.pdf$") - for path, valid, result in TESTDATA: + for path, valid, test_result in TESTDATA: if valid: - dct = regex.run(path=path) - assert dct == {"regex": {"the_number": result}} + result = regex.run(fs_path=path) + assert result.updates == {"regex": {"the_number": test_result}} + assert result.matches == True def test_regex_umlaut(): regex = Regex(r"^Erträgnisaufstellung-(?P\d*)\.pdf") doc = "Erträgnisaufstellung-1998.pdf" assert regex.matches(doc) - dct = regex.run(path=doc) - assert dct == {"regex": {"year": "1998"}} + result = regex.run(fs_path=doc) + assert result.updates == {"regex": {"year": "1998"}} + assert result.matches From 6e801e3243393630c4cd9b161ad6828beb0e9b0e Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 7 Feb 2022 13:09:03 +0100 Subject: [PATCH 103/108] add migration notice and config check --- CHANGELOG.md | 56 +++++++++++++++------------- docs/changelog.md | 1 + docs/filters.md | 15 +++++--- docs/index.md | 3 +- docs/updating-from-v1.md | 79 ++++++++++++++++++++++++++++++++-------- mkdocs.yml | 3 +- organize/cli.py | 79 +++++++++++++++++++++++++++++++++++++--- organize/config.py | 13 +++++++ organize/core.py | 3 ++ organize/filters/name.py | 2 +- organize/migration.py | 38 +++++++++++++++++++ 11 files changed, 235 insertions(+), 57 deletions(-) create mode 100644 organize/migration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d50445..a8a9d020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,45 +5,49 @@ This is a huge update with lots of improvements. Please backup all your important stuff before running and use the simulate option! +[**Migration Guide**](docs/updating-from-v1.md) + ### what's new -- You can now target directories with your rules (copying, renaming, etc a whole folder) -- Organize inside or between (S)FTP, S3 Buckets, Zip archives and many more. - [Available filesystems](https://www.pyfilesystem.org/page/index-of-filesystems/) -- `max_depth` setting when recursing into subfolders +- You can now [target directories](docs/rules.md#targeting-directories) with your rules + (copying, renaming, etc a whole folder) +- [Organize inside or between (S)FTP, S3 Buckets, Zip archives and many more](docs/locations.md#remote-filesystems-and-archives) + (list of [available filesystems](https://www.pyfilesystem.org/page/index-of-filesystems/)). +- [`max_depth`](docs/locations.md#location-options) setting when recursing into subfolders - Respects your rule order - safer, less magic, less surprises. - (v1 tried to be clever. v2 now works your config file from top to bottom) -- Jinja2 template engine for placeholders. + - (organize v1 tried to be clever. v2 now works your config file from top to bottom) +- [Jinja2 template engine for placeholders](docs/rules.md#templates-and-placeholders). - Instant start. (does not need to gather all the files before starting) -- Filters can now be excluded. -- Filter modes: `all`, `any` and `none`. -- Nice terminal output. -- Rule names. -- new conflict resolution settings in `move`, `copy` and `rename` action: +- [Filters can now be excluded](docs/filters.md#how-to-exclude-filters). +- [Filter modes](docs/rules.md#rule-options): `all`, `any` and `none`. +- [Rule names](docs/rules.md#rule-options). +- new conflict resolution settings in [`move`](docs/actions.md#move), + [`copy`](docs/actions.md#copy) and [`rename`](docs/actions.md#rename) action: - Options are `skip`, `overwrite`, `trash`, `rename_new` or `rename_existing` - You can now define a custom `rename_template`. -- The `python` action can now be run in simulation. -- The `shell` action now returns stdout and errorcode. -- Added filter `empty`. -- Added filter `hash`. -- Added action `symlink`. -- Added action `confirm`. -- Many small fixes and improvements. +- The [`python`](docs/actions.md#python) action can now be run in simulation. +- The [`shell`](docs/actions.md#shell) action now returns stdout and errorcode. +- Added filter [`empty`](docs/filters.md#empty) - find empty files and folders +- Added filter [`hash`](docs/filters.md#hash) - generate file hashes +- Added action [`symlink`](docs/actions.md#symlink) - generate symlinks +- Added action [`confirm`](docs/actions.md#confirm) - asks for confirmation +- Many small fixes and improvements! ### changed -- The `timezone` keyword for `lastmodified` and `created` was removed. The timezone is +- The `timezone` keyword for [`lastmodified`](docs/filters.md#lastmodified) and + [`created`](docs/filters.md#created) was removed. The timezone is now the local timezone by default. -- The `filesize` filter was renamed to `size` and can now be used to get directory sizes - as well. -- The `filename` filter was renamed to `name` and can now be used to get directory names - as well. -- The `size` filter now returns multiple formats +- The `filesize` filter was renamed to [`size`](docs/filters.md#size) and can now be + used to get directory sizes as well. +- The `filename` filter was renamed to [`name`](docs/filters.md#name) and can now be + used to get directory names as well. +- The [`size`](docs/filters.md#size) filter now returns multiple formats ### removed -- Glob syntax is gone from folders (no longer needed) -- `"!"` folder exclude syntax is gone (no longer needed) +- Glob syntax is gone from folders ([no longer needed](docs/locations.md)) +- `"!"` folder exclude syntax is gone ([no longer needed](docs/locations.md)) ## v1.10.1 (2021-04-21) diff --git a/docs/changelog.md b/docs/changelog.md index 15fe40c9..11811532 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,4 @@ {% include-markdown "../CHANGELOG.md" + rewrite-relative-urls=true %} diff --git a/docs/filters.md b/docs/filters.md index 39326d2e..3426516e 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -4,9 +4,12 @@ This page shows the specifics of each filter. ## - How to exclude filters - -- To exclude all filters, simply set the `filter_mode` of the rule to `none`. -- To exclude a single filter, prefix the filter name with `not` (e.g. `not empty`, - `not extension: jpg`, etc). +To exclude a filter, prefix the filter name with **not** (e.g. `"not empty"`, +`"not extension": jpg`, etc). + +!!! note + + If you want to exclude all filters you can set the rule's `filter_mode` to `none`. Example: @@ -14,7 +17,7 @@ Example: rules: # using filter_mode - locations: ~/Desktop - filter_mode: "none" + filter_mode: "none" # <- excludes all filters: - empty - name: @@ -25,10 +28,10 @@ rules: # Exclude a single filter - locations: ~/Desktop filters: - - not extension: jpg + - not extension: jpg # <- matches all non-jpgs - name: startswith: "Invoice" - - not empty + - not empty # <- matches files with content actions: - echo: "{name}" ``` diff --git a/docs/index.md b/docs/index.md index 8dc9557c..62a4b441 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,6 @@ # Welcome to organize's documentation {% - include-markdown "../README.md" + include-markdown "../README.md" + rewrite-relative-urls=false %} diff --git a/docs/updating-from-v1.md b/docs/updating-from-v1.md index f519a2ab..c3094edd 100644 --- a/docs/updating-from-v1.md +++ b/docs/updating-from-v1.md @@ -7,33 +7,42 @@ completely. Feel free to pin organize to v1.x, but then you're missing the party Please open a issue on Github if you need help migrating your config file! -## Config +## Folders -- `folders` must be renamed to `locations`. -- REMOVED: The glob syntax (`/Docs/**/*.png`) -- REMOVED: The exclamation mark exlucde syntax (`! ~/Desktop/exclude`) - -With `locations`, there are now much better options in place. -Please change your `folders` definition to the `locations` definition: [Locations documentation](locations.md). +Folders have become [Locations](locations.md) in organize v2. +- `folders` must be renamed to `locations` in your config. +- REMOVED: The glob syntax (`/Docs/**/*.png`). + See [Location options](locations.md#location-options). +- REMOVED: The exclamation mark exlucde syntax (`! ~/Desktop/exclude`). + See [Location options](locations.md#location-options). - All keys (filter names, action names, option names) now must be lowercase. ## Placeholders -organize v2 uses the Jinja template engine. You may need to change some of your placeholders. +organize v2 uses the Jinja template engine. You may need to change some of your +placeholders. - `{basedir}` is no longer available. -- Replace undocumented placeholders like this: +- You have to replace undocumented placeholders like this: + +```python +{created.year}-{created.month:02}-{created.day:02} +``` + +With this: - ```py - {created.year}-{created.month:02}-{created.day:02} - ``` +```python +{created.strftime('%Y-%m-%d')} +``` - With this: +If you need to left pad other numbers you can now use the following syntax: - ```py - {created.strftime('%Y-%m-%d')} - ``` +```python +{ "{:02}".format(your_variable) } +# or +{ '%02d' % your_variable } +``` ## Filters @@ -44,6 +53,44 @@ organize v2 uses the Jinja template engine. You may need to change some of your ## Actions +The copy, move and rename actions got a whole lot more powerful. You now have several +conflict options and can specify exactly how a file should be renamed in case of a +conflict. + +This means you might need to change your config to use the new parameters. + - [`copy`](actions.md#copy) arguments changed to support conflict resolution options. - [`move`](actions.md#move) arguments changed to support conflict resolution options. - [`rename`](actions.md#rename) arguments changed to support conflict resolution options. + +Example: + +```yml +rules: + - folders: ~/Desktop + filters: + - extension: pdf + actions: + - move: + dest: ~/Documents/PDFs/ + overwrite: false + counter_seperator: "-" +``` + +becomes (organize v2): + +```yaml +rules: + - locations: ~/Desktop + filters: + - extension: pdf + actions: + - move: + dest: ~/Documents/PDFs/ + on_conflict: rename_new + rename_template: "{name}-{:02}.format(counter){extension}" +``` + +If you used `move`, `copy` or `rename` without arguments, nothing changes for you. + +That's it. Again, feel free to open a issue if you have trouble migrating your config. diff --git a/mkdocs.yml b/mkdocs.yml index c71a914c..6f5262c7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,13 +3,13 @@ repo_url: https://github.com/tfeldmann/organize/ site_author: "Thomas Feldmann" nav: - Home: index.md + - Updating from organize v1.x: updating-from-v1.md - Configuration: configuration.md - Rules: rules.md - Locations: locations.md - Filters: filters.md - Actions: actions.md - Changelog: changelog.md - - Updating from organize v1.x: updating-from-v1.md plugins: - search - include-markdown @@ -26,7 +26,6 @@ plugins: show_root_heading: false show_source: false watch: - - . - organize markdown_extensions: diff --git a/organize/cli.py b/organize/cli.py index 96bceb0c..200f72fa 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -12,19 +12,21 @@ from . import console from .__version__ import __version__ +from .migration import NeedsMigrationError DOCS_URL = "https://tfeldmann.github.io/organize/" # "https://organize.readthedocs.io" +MIGRATE_URL = DOCS_URL + "updating-from-v1/" DEFAULT_CONFIG = """\ # organize configuration file # {docs} rules: - locations: - - + - # your locations here filters: - - + - # your filters here actions: - - + - # your actions here """.format( docs=DOCS_URL ) @@ -82,6 +84,14 @@ def run_local(config_path: str, working_dir: str, simulate: bool): config = open_fs(config_dir).readtext(config_name) os.chdir(working_dir) core.run(rules=config, simulate=simulate) + except NeedsMigrationError as e: + console.error(e, title="Config needs migration") + console.warn( + "Your config file needs some updates to work with organize v2.\n" + "Please see the migration guide at\n\n" + "%s" % MIGRATE_URL + ) + sys.exit(1) except SchemaError as e: console.error("Invalid config file!") for err in e.autos: @@ -154,12 +164,71 @@ def edit(config, editor): @cli.command() @CLI_CONFIG -def check(config): +@click.option("--debug", is_flag=True, help="Verbose output") +def check(config, debug): """Checks whether a given config file is valid. If called without arguments it will check the default config file. """ - print(config) + print("Checking: " + config) + + from . import migration + from .config import load_from_string, cleanup, validate + from .core import highlighted_console as out, replace_with_instances + + try: + config_dir, config_name = split(str(config)) + config_str = open_fs(config_dir).readtext(config_name) + + if debug: + out.rule("Raw", align="left") + out.print(config_str) + + rules = load_from_string(config_str) + + if debug: + out.print("\n\n") + out.rule("Loaded", align="left") + out.print(rules) + + rules = cleanup(rules) + + if debug: + out.print("\n\n") + out.rule("Cleaned", align="left") + out.print(rules) + + if debug: + out.print("\n\n") + out.rule("Migration from v1", align="left") + + migration.migrate_v1(rules) + + if debug: + out.print("Not needed.") + out.print("\n\n") + out.rule("Schema validation", align="left") + + validate(rules) + + if debug: + out.print("Validtion ok.") + out.print("\n\n") + out.rule("Instantiation", align="left") + + warnings = replace_with_instances(rules) + if debug: + out.print(rules) + for msg in warnings: + out.print("Warning: %s" % msg) + + if debug: + out.print("\n\n") + out.rule("Result", align="left") + out.print("Config is valid.") + + except Exception as e: + out.print_exception() @cli.command() diff --git a/organize/config.py b/organize/config.py index 3b83b101..ae3a5eb7 100644 --- a/organize/config.py +++ b/organize/config.py @@ -69,6 +69,17 @@ def load_from_string(config: str) -> dict: return yaml.load(dedented_config, Loader=yaml.SafeLoader) +def lowercase_keys(obj): + if isinstance(obj, dict): + obj = {key.lower(): value for key, value in obj.items()} + for key, value in obj.items(): + if isinstance(value, list): + for i, item in enumerate(value): + value[i] = lowercase_keys(item) + obj[key] = lowercase_keys(value) + return obj + + def cleanup(config: dict) -> dict: result = defaultdict(list) @@ -81,6 +92,8 @@ def cleanup(config: dict) -> dict: if not result: raise ValueError("No rules defined.") + result = lowercase_keys(result) + # flatten all lists everywhere return flatten_all_lists_in_dict(dict(result)) diff --git a/organize/core.py b/organize/core.py index f990fb65..d8d2a7c2 100644 --- a/organize/core.py +++ b/organize/core.py @@ -14,6 +14,7 @@ from .actions.action import Action from .filters import FILTERS from .filters.filter import Filter +from .migration import migrate_v1 from .utils import ( basic_args, deep_merge_inplace, @@ -290,6 +291,8 @@ def run(rules: Union[str, dict], simulate: bool, validate=True): rules = config.cleanup(rules) + migrate_v1(rules) + if validate: config.validate(rules) diff --git a/organize/filters/name.py b/organize/filters/name.py index b6cf6372..a70d7da4 100644 --- a/organize/filters/name.py +++ b/organize/filters/name.py @@ -8,7 +8,7 @@ class Name(Filter): - """Match files by filename + """Match files and folders by name Args: match (str): diff --git a/organize/migration.py b/organize/migration.py new file mode 100644 index 00000000..35e322c9 --- /dev/null +++ b/organize/migration.py @@ -0,0 +1,38 @@ +class MigrationWarning(UserWarning): + pass + + +class NeedsMigrationError(Exception): + pass + + +def entry_name_args(entry): + if isinstance(entry, str): + return (entry.lower(), []) + elif isinstance(entry, dict): + name, value = next(iter(entry.items())) + if isinstance(value, str): + return (name.lower(), []) + elif isinstance(value, dict): + args = [x.lower() for x in value.keys()] + return (name.lower(), args) + + +def migrate_v1(config: dict): + for rule in config.get("rules", []): + if "folders" in rule: + raise NeedsMigrationError("`folders` are now `locations`") + for fil in rule.get("filters", []): + name, _ = entry_name_args(fil) + if name == "filename": + raise NeedsMigrationError("`filename` is now `name`") + if name == "filesize": + raise NeedsMigrationError("`filesize` is now `size`") + for act in rule.get("actions", []): + name, args = entry_name_args(act) + if name in ("move", "copy", "rename"): + if "overwrite" in args or "counter_seperator" in args: + raise NeedsMigrationError( + "`%s` does not support `overwrite` and " + "`counter_seperator` anymore. Please use the new arguments." + ) From 1bdc9b03ac9d93efa13ca18a95ba4a49f0986426 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 7 Feb 2022 13:13:06 +0100 Subject: [PATCH 104/108] update deps --- organize/cli.py | 1 + poetry.lock | 62 ++++++++++++++++++++++++------------------------- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/organize/cli.py b/organize/cli.py index 200f72fa..57378316 100644 --- a/organize/cli.py +++ b/organize/cli.py @@ -229,6 +229,7 @@ def check(config, debug): except Exception as e: out.print_exception() + sys.exit(1) @cli.command() diff --git a/poetry.lock b/poetry.lock index 90f72fee..382b0c8c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -570,7 +570,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pycryptodome" -version = "3.14.0" +version = "3.14.1" description = "Cryptographic library for Python" category = "main" optional = true @@ -1410,36 +1410,36 @@ pycparser = [ {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] pycryptodome = [ - {file = "pycryptodome-3.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:bd800856e6dea6924504795ae4ec0d822e912e0a9a215e73b77b585c4d15a0f7"}, - {file = "pycryptodome-3.14.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:625f78ad69aa3c45e19b85b9e9cae3a30aa4a1de6b908981a63426b88e860489"}, - {file = "pycryptodome-3.14.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:a1c116dd7a00aac631f67920912fd8ef7a5ad3402cd4d497c6f5cc6b8115747b"}, - {file = "pycryptodome-3.14.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:0d0b6cca6b707b2c7cd4177c2d3cd950efa959ed8f01c30e676f102c68156f00"}, - {file = "pycryptodome-3.14.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9d939a257117cc8c6840ad69f149b3ca5e07268cfe0429bd9feec0f91da2343d"}, - {file = "pycryptodome-3.14.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:41dbb8c2129d43f34ed555cbd365d5e8f023ef0f9238fd9cd0302086b15a38b3"}, - {file = "pycryptodome-3.14.0-cp27-cp27m-win32.whl", hash = "sha256:9b454af09914807cef1222d100a8c523737a160347cb8d699facc4bdfb9fe725"}, - {file = "pycryptodome-3.14.0-cp27-cp27m-win_amd64.whl", hash = "sha256:95bac6e55411650933f3b615e57bf0966bf08f3ce07c01f07482ced95f18cbec"}, - {file = "pycryptodome-3.14.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:0ffbca43c1788243421a8583d85acb59f4cd0b82b001c485fdc3fbfd8fd0804f"}, - {file = "pycryptodome-3.14.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:69b85d78f7db628370d2cc87f1c41a449f6460896ba95f412173618f75027c2c"}, - {file = "pycryptodome-3.14.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:bba348d2823315ab8ebe44f0b2fc2ff8dfac8de881713a08def3dadcfc8e92bb"}, - {file = "pycryptodome-3.14.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:7d667daa851b1f9a20f2b5cad3cff13fba5204bc2f857d12f27c25b178d8629b"}, - {file = "pycryptodome-3.14.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:74918d5de06b12fef2255135bede41307a5f7b929b145ad867111525aea075dc"}, - {file = "pycryptodome-3.14.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c2b6faabd09d2876f9050f8af5d78046d81fe856f99e801c2ddab85b59602007"}, - {file = "pycryptodome-3.14.0-cp35-abi3-manylinux1_i686.whl", hash = "sha256:22a8629315c76d2bec57bc4fd67eb7e01664c3e3b9579df40f530ee5821db1de"}, - {file = "pycryptodome-3.14.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:7e3851e4fbbab72d9b30f98a504f450cc61e497e8e4b0be8205dc198703eee4d"}, - {file = "pycryptodome-3.14.0-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:9006f17944efaacc3be364c01c2253c00a00f0b5fa5a1a85a1191efd861e764d"}, - {file = "pycryptodome-3.14.0-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:8f0da308fca149b4c4da78e1388f82d8dd167e0ce12992a44f81b506cede3109"}, - {file = "pycryptodome-3.14.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:d186e34747985fbd94df7ed4d621f8377165053a06872314c2a594af34741655"}, - {file = "pycryptodome-3.14.0-cp35-abi3-win32.whl", hash = "sha256:2ed4da8f8afe44895c1f49ae1141a55b15d81dc745b5baa7b7a7265d7b40b81e"}, - {file = "pycryptodome-3.14.0-cp35-abi3-win_amd64.whl", hash = "sha256:11167a1f892283e5320feb5e81589fd041a1822b94c047820f00bc03eb98a9f7"}, - {file = "pycryptodome-3.14.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:1714ea5f83bcff25e8ae4640e22359d7a0815157a29d9f4eebc2b9e975a3cda0"}, - {file = "pycryptodome-3.14.0-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:3a011b9fe674bd21056613e88a3e660c56f1b47263138ebf420aa3ee4b8b0107"}, - {file = "pycryptodome-3.14.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:3fd50e3682ac3a684ace5b90ba1aef8090a78eeadf38c1ec385aad3a599cfd68"}, - {file = "pycryptodome-3.14.0-pp27-pypy_73-win32.whl", hash = "sha256:08be50d4195edd595df580077bbeec5599d0e5aa0cc468083178ae870e0b29f4"}, - {file = "pycryptodome-3.14.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:16c171dd969c9046b7b304c6ba0c643624dcf18093a66bd30b8b091703f177a2"}, - {file = "pycryptodome-3.14.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:89bb56cfd1fb74663842710bc41a6be26dafceb60eb8d432536891aea08a3740"}, - {file = "pycryptodome-3.14.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:c30a98c8718ae93d44680a7038adb484a520319860747ba43b6cd0a20f6b5984"}, - {file = "pycryptodome-3.14.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:e972f566ef7b821c8b958dab64174afa072f8271b779e32444ad7c127b0a84b2"}, - {file = "pycryptodome-3.14.0.tar.gz", hash = "sha256:ceea92a4b8ba6c50d8d70f2efbb4ea14b002dac4160ce4dda33f1b7442f8158a"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:75a3a364fee153e77ed889c957f6f94ec6d234b82e7195b117180dcc9fc16f96"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:aae395f79fa549fb1f6e3dc85cf277f0351e15a22e6547250056c7f0c990d6a5"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f403a3e297a59d94121cb3ee4b1cf41f844332940a62d71f9e4a009cc3533493"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ce7a875694cd6ccd8682017a7c06c6483600f151d8916f2b25cf7a439e600263"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a36ab51674b014ba03da7f98b675fcb8eabd709a2d8e18219f784aba2db73b72"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:50a5346af703330944bea503106cd50c9c2212174cfcb9939db4deb5305a8367"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-win32.whl", hash = "sha256:36e3242c4792e54ed906c53f5d840712793dc68b726ec6baefd8d978c5282d30"}, + {file = "pycryptodome-3.14.1-cp27-cp27m-win_amd64.whl", hash = "sha256:c880a98376939165b7dc504559f60abe234b99e294523a273847f9e7756f4132"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:dcd65355acba9a1d0fc9b923875da35ed50506e339b35436277703d7ace3e222"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:766a8e9832128c70012e0c2b263049506cbf334fb21ff7224e2704102b6ef59e"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:2562de213960693b6d657098505fd4493c45f3429304da67efcbeb61f0edfe89"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d1b7739b68a032ad14c5e51f7e4e1a5f92f3628bba024a2bda1f30c481fc85d8"}, + {file = "pycryptodome-3.14.1-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:27e92c1293afcb8d2639baf7eb43f4baada86e4de0f1fb22312bfc989b95dae2"}, + {file = "pycryptodome-3.14.1-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:f2772af1c3ef8025c85335f8b828d0193fa1e43256621f613280e2c81bfad423"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux1_i686.whl", hash = "sha256:9ec761a35dbac4a99dcbc5cd557e6e57432ddf3e17af8c3c86b44af9da0189c0"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:e64738207a02a83590df35f59d708bf1e7ea0d6adce712a777be2967e5f7043c"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:e24d4ec4b029611359566c52f31af45c5aecde7ef90bf8f31620fd44c438efe7"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:8b5c28058102e2974b9868d72ae5144128485d466ba8739abd674b77971454cc"}, + {file = "pycryptodome-3.14.1-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:924b6aad5386fb54f2645f22658cb0398b1f25bc1e714a6d1522c75d527deaa5"}, + {file = "pycryptodome-3.14.1-cp35-abi3-win32.whl", hash = "sha256:53dedbd2a6a0b02924718b520a723e88bcf22e37076191eb9b91b79934fb2192"}, + {file = "pycryptodome-3.14.1-cp35-abi3-win_amd64.whl", hash = "sha256:ea56a35fd0d13121417d39a83f291017551fa2c62d6daa6b04af6ece7ed30d84"}, + {file = "pycryptodome-3.14.1-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:028dcbf62d128b4335b61c9fbb7dd8c376594db607ef36d5721ee659719935d5"}, + {file = "pycryptodome-3.14.1-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:69f05aaa90c99ac2f2af72d8d7f185f729721ad7c4be89e9e3d0ab101b0ee875"}, + {file = "pycryptodome-3.14.1-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:12ef157eb1e01a157ca43eda275fa68f8db0dd2792bc4fe00479ab8f0e6ae075"}, + {file = "pycryptodome-3.14.1-pp27-pypy_73-win32.whl", hash = "sha256:f572a3ff7b6029dd9b904d6be4e0ce9e309dcb847b03e3ac8698d9d23bb36525"}, + {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9924248d6920b59c260adcae3ee231cd5af404ac706ad30aa4cd87051bf09c50"}, + {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:e0c04c41e9ade19fbc0eff6aacea40b831bfcb2c91c266137bcdfd0d7b2f33ba"}, + {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:893f32210de74b9f8ac869ed66c97d04e7d351182d6d39ebd3b36d3db8bda65d"}, + {file = "pycryptodome-3.14.1-pp36-pypy36_pp73-win32.whl", hash = "sha256:7fb90a5000cc9c9ff34b4d99f7f039e9c3477700e309ff234eafca7b7471afc0"}, + {file = "pycryptodome-3.14.1.tar.gz", hash = "sha256:e04e40a7f8c1669195536a37979dd87da2c32dbdc73d6fe35f0077b0c17c803b"}, ] pygments = [ {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, From 4539d64f265d28950c4d787f4f0479a4f4b6913d Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 7 Feb 2022 13:27:39 +0100 Subject: [PATCH 105/108] update rtd config --- .readthedocs.yml | 26 +++++++++++--------------- poetry.lock | 2 +- pyproject.toml | 8 ++++---- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 75475439..e49a71d7 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,27 +1,23 @@ -# .readthedocs.yml +# .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 -# Build documentation in the docs/ directory with Sphinx -sphinx: - configuration: docs/conf.py - -# Build documentation with MkDocs -#mkdocs: -# configuration: mkdocs.yml - -# Optionally build your docs in additional formats such as PDF and ePub -formats: all - +# Set the version of Python and other tools you might need build: - image: latest + os: ubuntu-20.04 + tools: + python: "3.9" + +mkdocs: + configuration: mkdocs.yml -# Optionally set the version of Python and requirements required to build your docs +# Optionally declare the Python requirements required to build your docs python: - version: 3.7 install: - method: pip path: . + extra_requirements: + - docs diff --git a/poetry.lock b/poetry.lock index 382b0c8c..f288a67b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -966,7 +966,7 @@ textract = ["textract"] [metadata] lock-version = "1.1" python-versions = "^3.6.2" -content-hash = "2dcfc08bd911333a7ea1da72b2af1c95985949d1ec2426828fe55a8472910eee" +content-hash = "06d46cffe503d5f1dfde1d798c57a742c2caf464373543ca23a4763dc5410642" [metadata.files] appdirs = [ diff --git a/pyproject.toml b/pyproject.toml index c11a951d..e92324f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,10 +51,10 @@ textract = ["textract"] [tool.poetry.dev-dependencies] pytest = "^6.2.5" mypy = "^0.931" -mkdocs = "^1.2.3" -mkdocstrings = "^0.17.0" -mkdocs-include-markdown-plugin = "^3.2.3" -mkdocs-autorefs = "^0.3.1" +mkdocs = { version = "^1.2.3", extras = ["docs"] } +mkdocstrings = { version = "^0.17.0", extras = ["docs"] } +mkdocs-include-markdown-plugin = { version = "^3.2.3", extras = ["docs"] } +mkdocs-autorefs = { version = "^0.3.1", extras = ["docs"] } requests = "^2.27.1" types-PyYAML = "^6.0.3" From 7e8af32347fa0096e379950a6a38e4feb5e18066 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 7 Feb 2022 13:39:55 +0100 Subject: [PATCH 106/108] update pyproject --- pyproject.toml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e92324f6..0da71c7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,16 +45,24 @@ schema = "^0.7.5" Jinja2 = "^3.0.3" click = "^8.0.3" +# must be in main dependencies for readthedocs. +mkdocs = { version = "^1.2.3", optional = true } +mkdocstrings = { version = "^0.17.0", optional = true } +mkdocs-include-markdown-plugin = { version = "^3.2.3", optional = true } +mkdocs-autorefs = { version = "^0.3.1", optional = true } + [tool.poetry.extras] textract = ["textract"] +docs = [ + "mkdocs", + "mkdocstrings", + "mkdocs-include-markdown-plugin", + "mkdocs-autorefs", +] [tool.poetry.dev-dependencies] pytest = "^6.2.5" mypy = "^0.931" -mkdocs = { version = "^1.2.3", extras = ["docs"] } -mkdocstrings = { version = "^0.17.0", extras = ["docs"] } -mkdocs-include-markdown-plugin = { version = "^3.2.3", extras = ["docs"] } -mkdocs-autorefs = { version = "^0.3.1", extras = ["docs"] } requests = "^2.27.1" types-PyYAML = "^6.0.3" From 1787a2375f249c2fb6d5f64355aa5c5dd93930bd Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 7 Feb 2022 14:21:55 +0100 Subject: [PATCH 107/108] update docs --- docs/actions.md | 37 ++++++++------ docs/filters.md | 88 ++++++++++++++++++++++++++++++-- docs/rules.md | 14 ++--- tests/integration/test_rename.py | 5 +- 4 files changed, 116 insertions(+), 28 deletions(-) diff --git a/docs/actions.md b/docs/actions.md index 7f76a871..435cfcd0 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -13,7 +13,7 @@ Confirm before deleting a duplicate ```yaml rules: - - name: "Delete duplicates" + - name: "Delete duplicates with confirmation" locations: - ~/Downloads - ~/Documents @@ -22,7 +22,7 @@ rules: - duplicate - name actions: - - confirm: "Delete {duplicate}?" + - confirm: "Delete {name}?" - trash ``` @@ -54,7 +54,7 @@ rules: - jpg actions: - copy: - dest: "~/Desktop/{extension.upper}/" + dest: "~/Desktop/{extension.upper()}/" on_conflict: overwrite ``` @@ -81,6 +81,8 @@ rules: **Examples:** +Delete old downloads. + ```yaml rules: - locations: "~/Downloads" @@ -94,6 +96,8 @@ rules: - delete ``` +Delete all empty subfolders + ```yaml rules: - name: Delete all empty subfolders @@ -134,7 +138,7 @@ rules: - echo: "Hello World! {path}" ``` -This will print something like `Found a PNG: "test.png"` for each file on your desktop +This will print something like `Found a ZIP: "backup"` for each file on your desktop ```yaml rules: @@ -142,11 +146,12 @@ rules: - ~/Desktop filters: - extension + - name actions: - - echo: 'Found a {extension.upper}: "{path.name}"' + - echo: 'Found a {extension.upper()}: "{name}"' ``` -Show the `{basedir}` and `{path}` of all files in '~/Downloads', '~/Desktop' and their subfolders: +Show the `{relative_path}` and `{path}` of all files in '~/Downloads', '~/Desktop' and their subfolders: ```yaml rules: @@ -156,8 +161,8 @@ rules: - path: ~/Downloads max_depth: null actions: - - echo: "Basedir: {basedir}" - - echo: "Path: {path}" + - echo: "Path: {path}" + - echo: "Relative: {relative_path}" ``` ## macos_tags @@ -251,7 +256,7 @@ rules: - jpg actions: - move: - dest: "~/Desktop/{extension.upper}/" + dest: "~/Desktop/{extension.upper()}/" on_conflict: "overwrite" ``` @@ -297,11 +302,11 @@ rules: - regex: '^(?P.*)\.(?P.*)$' actions: - python: | - print('Name: %s' % regex.name) - print('Extension: %s' % regex.extension) + print('Name: %s' % regex["name"]) + print('Extension: %s' % regex["extension"]) ``` -Running in simulation and yaml aliases: +Running in simulation and [yaml aliases](rules.md#advanced-aliases): ```yaml my_python_script: &script | @@ -330,7 +335,7 @@ rules: actions: - python: | import webbrowser - webbrowser.open('https://www.google.com/search?q=%s' % path.stem) + webbrowser.open('https://www.google.com/search?q=%s' % name) ``` ## rename @@ -344,9 +349,10 @@ rules: - name: "Convert all .PDF file extensions to lowercase (.pdf)" locations: "~/Desktop" filters: + - name - extension: PDF actions: - - rename: "{path.stem}.pdf" + - rename: "{name}.pdf" ``` ```yaml @@ -354,9 +360,10 @@ rules: - name: "Convert **all** file extensions to lowercase" locations: "~/Desktop" filters: + - name - extension actions: - - rename: "{path.stem}.{extension.lower}" + - rename: "{name}.{extension.lower()}" ``` ## shell diff --git a/docs/filters.md b/docs/filters.md index 3426516e..1b255d8f 100644 --- a/docs/filters.md +++ b/docs/filters.md @@ -42,6 +42,8 @@ rules: **Examples:** +Show all files on your desktop created at least 10 days ago + ```yaml rules: - name: Show all files on your desktop created at least 10 days ago @@ -53,6 +55,8 @@ rules: - echo: "Was created at least 10 days ago" ``` +Show all files on your desktop which were created within the last 5 hours + ```yaml rules: - name: Show all files on your desktop which were created within the last 5 hours @@ -65,6 +69,8 @@ rules: - echo: "Was created within the last 5 hours" ``` +Sort pdfs by year of creation + ```yaml rules: - name: Sort pdfs by year of creation @@ -76,12 +82,27 @@ rules: - move: "~/Documents/PDF/{created.year}/" ``` +Formatting the creation date + +```yaml +rules: + - name: Display the creation date + locations: "~/Documents" + filters: + - extension: pdf + - created + actions: + - echo: "{created.strftime('%Y-%m-%d')}" +``` + ## duplicate ::: organize.filters.Duplicate **Examples:** +Show all duplicate files in your desktop and download folder (and their subfolders) + ```yaml rules: - name: Show all duplicate files in your desktop and download folder (and their subfolders) @@ -95,6 +116,8 @@ rules: - echo: "{path} is a duplicate of {duplicate.original}" ``` +Check for duplicated files between Desktop and a Zip file, select original by creation date + ```yaml rules: - name: "Check for duplicated files between Desktop and a Zip file, select original by creation date" @@ -114,10 +137,11 @@ rules: **Examples:** +Recursively delete empty folders + ```yaml rules: - - name: "Recursively delete empty folders" - targets: dirs + - targets: dirs locations: - path: ~/Desktop max_depth: null @@ -131,6 +155,8 @@ rules: ::: organize.filters.Exif +Show available EXIF data of your pictures + ```yaml rules: - name: "Show available EXIF data of your pictures" @@ -157,6 +183,8 @@ rules: - copy: ~/Pictures/with_gps/{relative_path}/ ``` +Filter by camera manufacturer + ```yaml rules: - name: "Filter by camera manufacturer" @@ -193,6 +221,8 @@ rules: **Examples:** +Match a single file extension + ```yaml rules: - name: "Match a single file extension" @@ -203,6 +233,8 @@ rules: - echo: "Found PNG file: {path}" ``` +Match multiple file extensions + ```yaml rules: - name: "Match multiple file extensions" @@ -215,6 +247,8 @@ rules: - echo: "Found JPG file: {path}" ``` +Make all file extensions lowercase + ```yaml rules: - name: "Make all file extensions lowercase" @@ -222,9 +256,11 @@ rules: filters: - extension actions: - - rename: "{path.stem}.{extension.lower}" + - rename: "{path.stem}.{extension.lower()}" ``` +Using extension lists ([yaml aliases](rules.md#advanced-aliases) + ```yaml img_ext: &img - png @@ -253,17 +289,21 @@ rules: **Examples:** +Show the content of all your PDF files + ```yaml rules: - name: "Show the content of all your PDF files" locations: ~/Documents filters: - extension: pdf - - filecontent: "(?P.*)" + - filecontent actions: - - echo: "{filecontent.all}" + - echo: "{filecontent}" ``` +Match an invoice with a regular expression and sort by customer + ```yaml rules: - name: "Match an invoice with a regular expression and sort by customer" @@ -278,6 +318,21 @@ rules: ::: organize.filters.Hash +**Examples:** + +Show the hashes of your files: + +```yaml +rules: + - name: "Show the hashes and size of your files" + locations: "~/Desktop" + filters: + - hash + - size + actions: + - echo: "{hash} {size.decimal}" +``` + ## lastmodified ::: organize.filters.LastModified @@ -308,6 +363,8 @@ rules: - echo: "Was modified within the last 5 hours" ``` +Sort pdfs by year of last modification + ```yaml rules: - name: "Sort pdfs by year of last modification" @@ -319,12 +376,27 @@ rules: - move: "~/Documents/PDF/{lastmodified.year}/" ``` +Formatting the last modified date + +```yaml +rules: + - name: Formatting the lastmodified date + locations: "~/Documents" + filters: + - extension: pdf + - lastmodified + actions: + - echo: "{lastmodified.strftime('%Y-%m-%d')}" +``` + ## mimetype ::: organize.filters.MimeType **Examples:** +Show MIME types + ```yaml rules: - name: "Show MIME types" @@ -335,6 +407,8 @@ rules: - echo: "{mimetype}" ``` +Filter by 'image' mimetype + ```yaml rules: - name: "Filter by 'image' mimetype" @@ -345,6 +419,8 @@ rules: - echo: "This file is an image: {mimetype}" ``` +Filter by specific MIME type + ```yaml rules: - name: Filter by specific MIME type @@ -355,6 +431,8 @@ rules: - echo: "Found a PDF file" ``` +Filter by multiple specific MIME types + ```yaml rules: - name: Filter by multiple specific MIME types diff --git a/docs/rules.md b/docs/rules.md index 29d9ad53..607ba92d 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -76,6 +76,9 @@ All your environment variables. You can access individual env vars like this: `{ The full path to the current file / folder on the local harddrive. This is not available for remote locations - in this case use `fs` and `fs_path`. +`{relative_path}` (`str`)
+the relative path of the current file in `{fs}`. + `{now}` (`datetime`)
The current datetime in the local timezone. @@ -83,13 +86,10 @@ The current datetime in the local timezone. The current UTC datetime. `{fs}` (`FS`)
-The filesystem of the current location. +The filesystem of the current location. Normally you should not need this. `{fs_path}` (`str`)
-The path of the current file / folder in related to `fs`. - -`{relative_path}` (`str`)
-the relative path of the current file in `{fs}`. +The path of the current file / folder in related to `fs`. Normally you should not need this. In addition to that nearly all filters add new placeholders with information about the currently handled file / folder. @@ -106,7 +106,9 @@ rules: - echo: "{size} {hash}" ``` -Note: In order to use a value returned by a filter it must be listed in the filters! +!!! note + + In order to use a value returned by a filter it must be listed in the filters! ## Advanced: Aliases diff --git a/tests/integration/test_rename.py b/tests/integration/test_rename.py index 43ab73ee..5e8df5bf 100644 --- a/tests/integration/test_rename.py +++ b/tests/integration/test_rename.py @@ -7,7 +7,7 @@ def test_rename_issue52(): # test for issue https://github.com/tfeldmann/organize/issues/51 files = { "files": { - "19asd_WF_test2.pdf": "", + "19asd_WF_test2.PDF": "", "other.pdf": "", "18asd_WFX_test2.pdf": "", } @@ -17,13 +17,14 @@ def test_rename_issue52(): config = rules_shortcut( mem, filters=""" + - extension - name: startswith: "19" contains: - "_WF_" """, actions=[ - {"rename": "{path.stem}_unread{path.suffix}"}, + {"rename": "{path.stem}_unread.{extension.lower()}"}, {"copy": {"dest": "files/copy/", "filesystem": mem}}, ], ) From 47163db1a19040bbe959ebb27e845f5c44201162 Mon Sep 17 00:00:00 2001 From: Thomas Feldmann Date: Mon, 7 Feb 2022 14:30:48 +0100 Subject: [PATCH 108/108] update docs --- .github/workflows/tests.yml | 4 ++-- CHANGELOG.md | 2 +- README.md | 13 +++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4792d487..5c89329d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,10 +6,10 @@ on: - "docs/**" - "*.md" branches: - - v2 + - main pull_request: branches: - - v2 + - main workflow_dispatch: jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index a8a9d020..68d74c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## v2.0.0 - WIP +## v2.0.0 (2022-02-07) This is a huge update with lots of improvements. Please backup all your important stuff before running and use the simulate option! diff --git a/README.md b/README.md index 583506dd..073454c7 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@
Full documentation at Read the docs

+- [**organize v2 is released!**](#organize-v2-is-released) - [About](#about) - [Features](#features) - [Getting started](#getting-started) @@ -34,6 +35,18 @@ - [Example rules](#example-rules) - [Command line interface](#command-line-interface) +## **organize v2 is released!** + +This is a huge update with lots of improvements. + +See [the changelog](https://organize.readthedocs.io/en/v2/changelog/) for all the new +features! + +Unfortunately your configuration may need some small adjustments: +[**Migration Guide**](docs/updating-from-v1.md) + +Please backup all your important stuff before running and use the simulate option! + ## About Your desktop is a mess? You cannot find anything in your downloads and