···11#!/usr/bin/env python3
22-from argparse import ArgumentParser, Namespace
22+from re import compile
33+from types import SimpleNamespace
34from pathlib import Path
45import shlex
66+import os.path
77+from pprint import pprint
88+from functools import reduce
99+import click
51066-def main():
77- parser = ArgumentParser()
88- parser.add_argument("-m", "--method", choices=["re"], default="re")
99- parser.add_argument("-g", "--global", dest="global_", action="store_true")
1010- parser.add_argument("-i", "--ignore-case", action="store_true")
1111- parser.add_argument("-d", "--dry-run", action="store_true")
1212- parser.add_argument("-q", "--quiet", action="store_true")
1313- parser.add_argument("pattern")
1414- parser.add_argument("replacement")
1515- parser.add_argument("files", nargs="*", type=Path)
1616- args = parser.parse_args()
1111+from .vendor.toposort import toposort
17121313+@click.group()
1414+@click.option("-n", "--dry-run", is_flag=True) # -n is more common
1515+@click.pass_context
1616+def main(ctx, dry_run):
1717+ ctx.ensure_object(SimpleNamespace)
1818+ ctx.obj.dry_run = dry_run
18191919- # Per method code
2020- if args.method == "re":
2121- import re
22202323- def new_path(old):
2424- return old.with_name(
2525- re.sub(
2626- args.pattern,
2727- repl=args.replacement,
2828- string=old.name,
2929- flags=(re.I if args.ignore_case else 0),
3030- count=(0 if args.global_ else 1),
3131- )
3232- )
2121+@main.command()
2222+@click.option("-g", "--global", "global_", is_flag=True)
2323+@click.option("--eval", "eval_", is_flag=True)
2424+@click.argument("pattern")
2525+@click.argument("replacement")
2626+@click.argument("files", nargs=-1, type=click.Path())
2727+@click.pass_context
2828+def re(ctx, pattern, replacement, files, eval_, global_):
2929+ if eval_:
3030+ repl = replacement
3131+ def replacement(match):
3232+ local = {
3333+ "m0": match.group(0),
3434+ **{f"m{i}": g for i, g in enumerate(match.groups(), 1)},
3535+ **match.groupdict(),
3636+ }
3737+ return str(eval(repl, {}, local))
33383434-3535- # Generic code
3636- moves = []
3737- for old in args.files:
3838- if old != (new := new_path(old)):
3939- moves.append(Namespace(old=old, new=new))
4040-4141- if any(m.new.exists() for m in moves):
4242- raise Error # Collides with pre-existing file
3939+ count = 0 if global_ else 1
4040+ regex = compile(pattern)
4141+ moves = {f: regex.sub(replacement, f, count=count) for f in files}
4242+ perform_moves(ctx, moves)
43434444- if len({m.new for m in moves}) < len(moves):
4545- raise Error # List has duplicates
46444747- for move in moves:
4848- if not args.quiet:
4949- print(
5050- f"- {shlex.quote(str(move.old))}",
5151- f"+ {shlex.quote(str(move.new))}",
5252- sep="\n",
5353- )
5454- if not args.dry_run:
5555- move.old.rename(move.new)
4545+def perform_moves(ctx, moves):
4646+ unmoved = {k for k,v in moves.items() if k == v or v is None}
4747+ if len(l := list(moves.keys())) != len(sources := set(l)):
4848+ raise Exception('Duplicate sources')
4949+ if len(l := list(moves.values())) != len(targets := set(l)):
5050+ raise Exception('Duplicate destinations')
5151+ if not ctx.obj.dry_run:
5252+ if any(not os.path.exists(f) for f in sources - unmoved):
5353+ raise Exception('Non-existant source')
5454+ if any(os.path.exists(f) for f in targets - sources - unmoved):
5555+ raise Exception('Pre-existing destination')
5656+ topo = {k:{v} for k,v in moves.items() if k not in unmoved}
5757+ changes = {k:next(iter(topo[k])) for ks in toposort(topo) for k in ks}
5858+ for u in unmoved:
5959+ print(f"~ {shlex.quote(u)}")
6060+6161+ for src,tgt in changes.items():
6262+ print(
6363+ f"- {shlex.quote(src)}",
6464+ f"+ {shlex.quote(tgt)}",
6565+ sep="\n",
6666+ )
6767+ if not ctx.obj.dry_run:
6868+ os.rename(src,tgt)
6969+if __name__ == "__main__":
7070+ main()
+86
multimv/vendor/toposort/__init__.py
···11+from copy import deepcopy
22+from functools import reduce
33+44+#######################################################################
55+# Implements a topological sort algorithm.
66+#
77+# Copyright 2014 True Blade Systems, Inc.
88+#
99+# Licensed under the Apache License, Version 2.0 (the "License");
1010+# you may not use this file except in compliance with the License.
1111+# You may obtain a copy of the License at
1212+#
1313+# http://www.apache.org/licenses/LICENSE-2.0
1414+#
1515+# Unless required by applicable law or agreed to in writing, software
1616+# distributed under the License is distributed on an "AS IS" BASIS,
1717+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1818+# See the License for the specific language governing permissions and
1919+# limitations under the License.
2020+#
2121+# Notes:
2222+# Based on http://code.activestate.com/recipes/578272-topological-sort
2323+# with these major changes:
2424+# Added unittests.
2525+# Deleted doctests (maybe not the best idea in the world, but it cleans
2626+# up the docstring).
2727+# Moved functools import to the top of the file.
2828+# Changed assert to a ValueError.
2929+# Changed iter[items|keys] to [items|keys], for python 3
3030+# compatibility. I don't think it matters for python 2 these are
3131+# now lists instead of iterables.
3232+# Copy the input so as to leave it unmodified.
3333+# Renamed function from toposort2 to toposort.
3434+# Handle empty input.
3535+# Switch tests to use set literals.
3636+#
3737+########################################################################
3838+# Further changes for multimv vendoring:
3939+# Based on: https://gitlab.com/ericvsmith/toposort/-/blob/master/toposort.py
4040+# Changed the output line slightly
4141+# Deepcopy the input to actually leave it unmodified
4242+4343+class CircularDependencyError(ValueError):
4444+ def __init__(self, data):
4545+ # Sort the data just to make the output consistent, for use in
4646+ # error messages. That's convenient for doctests.
4747+ s = 'Circular dependencies exist among these items: {{{}}}'.format(', '.join('{!r}:{!r}'.format(key, value) for key, value in sorted(data.items())))
4848+ super(CircularDependencyError, self).__init__(s)
4949+ self.data = data
5050+5151+5252+def toposort(data):
5353+ """Dependencies are expressed as a dictionary whose keys are items
5454+and whose values are a set of dependent items. Output is a list of
5555+sets in topological order. The first set consists of items with no
5656+dependences, each subsequent set consists of items that depend upon
5757+items in the preceeding sets.
5858+"""
5959+6060+ # Special case empty input.
6161+ if len(data) == 0:
6262+ return
6363+6464+ # Copy the input so as to leave it unmodified.
6565+ data = deepcopy(data)
6666+6767+ # Ignore self dependencies.
6868+ for k, v in data.items():
6969+ v.discard(k)
7070+ # Find all items that don't depend on anything.
7171+ extra_items_in_deps = reduce(set.union, data.values()) - set(data.keys())
7272+ # Add empty dependences where needed.
7373+ data.update({item:set() for item in extra_items_in_deps})
7474+ while True:
7575+ ordered = set(item for item, dep in data.items() if len(dep) == 0)
7676+ if not ordered:
7777+ break
7878+ # NOTE: changed this line so non-depends don't get emitted
7979+ # yield ordered
8080+ yield ordered - extra_items_in_deps
8181+ data = {item: (dep - ordered)
8282+ for item, dep in data.items()
8383+ if item not in ordered}
8484+ if len(data) != 0:
8585+ raise CircularDependencyError(data)
8686+