A regex file renamer/mover
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Rewrite

Summer d9f1ae45 ec994560

+232 -46
+60 -45
multimv/__init__.py
··· 1 1 #!/usr/bin/env python3 2 - from argparse import ArgumentParser, Namespace 2 + from re import compile 3 + from types import SimpleNamespace 3 4 from pathlib import Path 4 5 import shlex 6 + import os.path 7 + from pprint import pprint 8 + from functools import reduce 9 + import click 5 10 6 - def main(): 7 - parser = ArgumentParser() 8 - parser.add_argument("-m", "--method", choices=["re"], default="re") 9 - parser.add_argument("-g", "--global", dest="global_", action="store_true") 10 - parser.add_argument("-i", "--ignore-case", action="store_true") 11 - parser.add_argument("-d", "--dry-run", action="store_true") 12 - parser.add_argument("-q", "--quiet", action="store_true") 13 - parser.add_argument("pattern") 14 - parser.add_argument("replacement") 15 - parser.add_argument("files", nargs="*", type=Path) 16 - args = parser.parse_args() 11 + from .vendor.toposort import toposort 17 12 13 + @click.group() 14 + @click.option("-n", "--dry-run", is_flag=True) # -n is more common 15 + @click.pass_context 16 + def main(ctx, dry_run): 17 + ctx.ensure_object(SimpleNamespace) 18 + ctx.obj.dry_run = dry_run 18 19 19 - # Per method code 20 - if args.method == "re": 21 - import re 22 20 23 - def new_path(old): 24 - return old.with_name( 25 - re.sub( 26 - args.pattern, 27 - repl=args.replacement, 28 - string=old.name, 29 - flags=(re.I if args.ignore_case else 0), 30 - count=(0 if args.global_ else 1), 31 - ) 32 - ) 21 + @main.command() 22 + @click.option("-g", "--global", "global_", is_flag=True) 23 + @click.option("--eval", "eval_", is_flag=True) 24 + @click.argument("pattern") 25 + @click.argument("replacement") 26 + @click.argument("files", nargs=-1, type=click.Path()) 27 + @click.pass_context 28 + def re(ctx, pattern, replacement, files, eval_, global_): 29 + if eval_: 30 + repl = replacement 31 + def replacement(match): 32 + local = { 33 + "m0": match.group(0), 34 + **{f"m{i}": g for i, g in enumerate(match.groups(), 1)}, 35 + **match.groupdict(), 36 + } 37 + return str(eval(repl, {}, local)) 33 38 34 - 35 - # Generic code 36 - moves = [] 37 - for old in args.files: 38 - if old != (new := new_path(old)): 39 - moves.append(Namespace(old=old, new=new)) 40 - 41 - if any(m.new.exists() for m in moves): 42 - raise Error # Collides with pre-existing file 39 + count = 0 if global_ else 1 40 + regex = compile(pattern) 41 + moves = {f: regex.sub(replacement, f, count=count) for f in files} 42 + perform_moves(ctx, moves) 43 43 44 - if len({m.new for m in moves}) < len(moves): 45 - raise Error # List has duplicates 46 44 47 - for move in moves: 48 - if not args.quiet: 49 - print( 50 - f"- {shlex.quote(str(move.old))}", 51 - f"+ {shlex.quote(str(move.new))}", 52 - sep="\n", 53 - ) 54 - if not args.dry_run: 55 - move.old.rename(move.new) 45 + def perform_moves(ctx, moves): 46 + unmoved = {k for k,v in moves.items() if k == v or v is None} 47 + if len(l := list(moves.keys())) != len(sources := set(l)): 48 + raise Exception('Duplicate sources') 49 + if len(l := list(moves.values())) != len(targets := set(l)): 50 + raise Exception('Duplicate destinations') 51 + if not ctx.obj.dry_run: 52 + if any(not os.path.exists(f) for f in sources - unmoved): 53 + raise Exception('Non-existant source') 54 + if any(os.path.exists(f) for f in targets - sources - unmoved): 55 + raise Exception('Pre-existing destination') 56 + topo = {k:{v} for k,v in moves.items() if k not in unmoved} 57 + changes = {k:next(iter(topo[k])) for ks in toposort(topo) for k in ks} 58 + for u in unmoved: 59 + print(f"~ {shlex.quote(u)}") 60 + 61 + for src,tgt in changes.items(): 62 + print( 63 + f"- {shlex.quote(src)}", 64 + f"+ {shlex.quote(tgt)}", 65 + sep="\n", 66 + ) 67 + if not ctx.obj.dry_run: 68 + os.rename(src,tgt) 69 + if __name__ == "__main__": 70 + main()
+86
multimv/vendor/toposort/__init__.py
··· 1 + from copy import deepcopy 2 + from functools import reduce 3 + 4 + ####################################################################### 5 + # Implements a topological sort algorithm. 6 + # 7 + # Copyright 2014 True Blade Systems, Inc. 8 + # 9 + # Licensed under the Apache License, Version 2.0 (the "License"); 10 + # you may not use this file except in compliance with the License. 11 + # You may obtain a copy of the License at 12 + # 13 + # http://www.apache.org/licenses/LICENSE-2.0 14 + # 15 + # Unless required by applicable law or agreed to in writing, software 16 + # distributed under the License is distributed on an "AS IS" BASIS, 17 + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 + # See the License for the specific language governing permissions and 19 + # limitations under the License. 20 + # 21 + # Notes: 22 + # Based on http://code.activestate.com/recipes/578272-topological-sort 23 + # with these major changes: 24 + # Added unittests. 25 + # Deleted doctests (maybe not the best idea in the world, but it cleans 26 + # up the docstring). 27 + # Moved functools import to the top of the file. 28 + # Changed assert to a ValueError. 29 + # Changed iter[items|keys] to [items|keys], for python 3 30 + # compatibility. I don't think it matters for python 2 these are 31 + # now lists instead of iterables. 32 + # Copy the input so as to leave it unmodified. 33 + # Renamed function from toposort2 to toposort. 34 + # Handle empty input. 35 + # Switch tests to use set literals. 36 + # 37 + ######################################################################## 38 + # Further changes for multimv vendoring: 39 + # Based on: https://gitlab.com/ericvsmith/toposort/-/blob/master/toposort.py 40 + # Changed the output line slightly 41 + # Deepcopy the input to actually leave it unmodified 42 + 43 + class CircularDependencyError(ValueError): 44 + def __init__(self, data): 45 + # Sort the data just to make the output consistent, for use in 46 + # error messages. That's convenient for doctests. 47 + s = 'Circular dependencies exist among these items: {{{}}}'.format(', '.join('{!r}:{!r}'.format(key, value) for key, value in sorted(data.items()))) 48 + super(CircularDependencyError, self).__init__(s) 49 + self.data = data 50 + 51 + 52 + def toposort(data): 53 + """Dependencies are expressed as a dictionary whose keys are items 54 + and whose values are a set of dependent items. Output is a list of 55 + sets in topological order. The first set consists of items with no 56 + dependences, each subsequent set consists of items that depend upon 57 + items in the preceeding sets. 58 + """ 59 + 60 + # Special case empty input. 61 + if len(data) == 0: 62 + return 63 + 64 + # Copy the input so as to leave it unmodified. 65 + data = deepcopy(data) 66 + 67 + # Ignore self dependencies. 68 + for k, v in data.items(): 69 + v.discard(k) 70 + # Find all items that don't depend on anything. 71 + extra_items_in_deps = reduce(set.union, data.values()) - set(data.keys()) 72 + # Add empty dependences where needed. 73 + data.update({item:set() for item in extra_items_in_deps}) 74 + while True: 75 + ordered = set(item for item, dep in data.items() if len(dep) == 0) 76 + if not ordered: 77 + break 78 + # NOTE: changed this line so non-depends don't get emitted 79 + # yield ordered 80 + yield ordered - extra_items_in_deps 81 + data = {item: (dep - ordered) 82 + for item, dep in data.items() 83 + if item not in ordered} 84 + if len(data) != 0: 85 + raise CircularDependencyError(data) 86 +
+84
poetry.lock
··· 1 + [[package]] 2 + name = "click" 3 + version = "8.0.1" 4 + description = "Composable command line interface toolkit" 5 + category = "main" 6 + optional = false 7 + python-versions = ">=3.6" 8 + 9 + [package.dependencies] 10 + colorama = {version = "*", markers = "platform_system == \"Windows\""} 11 + importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 12 + 13 + [[package]] 14 + name = "colorama" 15 + version = "0.4.4" 16 + description = "Cross-platform colored terminal text." 17 + category = "main" 18 + optional = false 19 + python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 20 + 21 + [[package]] 22 + name = "importlib-metadata" 23 + version = "4.6.1" 24 + description = "Read metadata from Python packages" 25 + category = "main" 26 + optional = false 27 + python-versions = ">=3.6" 28 + 29 + [package.dependencies] 30 + typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 31 + zipp = ">=0.5" 32 + 33 + [package.extras] 34 + docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 35 + perf = ["ipython"] 36 + testing = ["pytest (>=4.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)"] 37 + 38 + [[package]] 39 + name = "typing-extensions" 40 + version = "3.10.0.0" 41 + description = "Backported and Experimental Type Hints for Python 3.5+" 42 + category = "main" 43 + optional = false 44 + python-versions = "*" 45 + 46 + [[package]] 47 + name = "zipp" 48 + version = "3.5.0" 49 + description = "Backport of pathlib-compatible object wrapper for zip files" 50 + category = "main" 51 + optional = false 52 + python-versions = ">=3.6" 53 + 54 + [package.extras] 55 + docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] 56 + 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"] 57 + 58 + [metadata] 59 + lock-version = "1.1" 60 + python-versions = "^3.6" 61 + content-hash = "6125d34623e9af4b0c72a15d076004d43dea586d3d0df75b5734990e069c623a" 62 + 63 + [metadata.files] 64 + click = [ 65 + {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, 66 + {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"}, 67 + ] 68 + colorama = [ 69 + {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, 70 + {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, 71 + ] 72 + importlib-metadata = [ 73 + {file = "importlib_metadata-4.6.1-py3-none-any.whl", hash = "sha256:9f55f560e116f8643ecf2922d9cd3e1c7e8d52e683178fecd9d08f6aa357e11e"}, 74 + {file = "importlib_metadata-4.6.1.tar.gz", hash = "sha256:079ada16b7fc30dfbb5d13399a5113110dab1aa7c2bc62f66af75f0b717c8cac"}, 75 + ] 76 + typing-extensions = [ 77 + {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, 78 + {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, 79 + {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, 80 + ] 81 + zipp = [ 82 + {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, 83 + {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, 84 + ]
+2 -1
pyproject.toml
··· 1 1 [tool.poetry] 2 2 name = "multimv" 3 - version = "0.2.0" 3 + version = "0.5.0" 4 4 description = "Multi mv via fixed string / regex / bash pattern substitutions" 5 5 authors = ["Summer"] 6 6 license = "MIT" 7 7 8 8 [tool.poetry.dependencies] 9 9 python = "^3.6" 10 + click = "^8.0.1" 10 11 11 12 [tool.poetry.scripts] 12 13 multimv = 'multimv:main'