diff --git a/.drone.star b/.drone.star index 6959d09..e7fd7b7 100644 --- a/.drone.star +++ b/.drone.star @@ -99,16 +99,16 @@ def notify_step(): # Builds a notify pipeline that will notify when a dependency fails def notify(depends_on=None): - if depends_on is None: + if not depends_on: depends_on = ["tests"] - return list(dict( - kind="pipeline", - name="notify", - depends_on=depends_on, - trigger=dict(status=["failure"]), - steps=[notify_step()] - )) + return [{ + "kind": "pipeline", + "name": "notify", + "depends_on": depends_on, + "trigger": {"status": ["failure"]}, + "steps": [notify_step()] + }] # Push package to pypi diff --git a/Makefile b/Makefile index f8db855..d4b9aa0 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ OPEN_CMD := $(shell type xdg-open &> /dev/null && echo 'xdg-open' || echo 'open') -NAME := paddy +NAME := padio ENV := env .PHONY: default @@ -98,19 +98,3 @@ htmlcov/index.html: .coverage .PHONY: open-coverage open-coverage: htmlcov/index.html $(OPEN_CMD) htmlcov/index.html - -# Cleans out docs -.PHONY: docs-clean -docs-clean: - rm -fr docs/build/* docs/source/code/* - -# Builds docs -docs/build/html/index.html: - $(ENV)/bin/tox -e docs - -# Shorthand for building docs -.PHONY: docs -docs: docs/build/html/index.html - -.PHONY: clean-all -clean-all: clean dist-clean docs-clean diff --git a/README.md b/README.md index c1075a2..a9c6396 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,49 @@ -# paddy +# padio Zero pad numeric filenames +Turn a bunch of files like this: + + file1.txt + file10.txt + file5.txt + +and want them to be sorted like this: + + file01.txt + file05.txt + file10.txt + +you can run: + + padio *.txt + + +## Installation + + pip install padio + +## Usage + + usage: padio [-h] [-l LENGTH] [-f] [-v] [-d] [-i REGEX] [--ignore-files IGNOREFILE [IGNOREFILE ...]] file [file ...] + + Pads numbers in file names so they consistently align and sort + + positional arguments: + file Files to be renamed + + optional arguments: + -h, --help show this help message and exit + -l LENGTH, --length LENGTH + Length of numbers after padding (default: auto) + -f, --force Force rename, even if file at destination exists + -v, --verbose Print all actions + -d, --dry-run Print actions only without modifying any file. Implies --verbose + -i REGEX, --ignore REGEX + Regular expression used to ignore files matching the name + --ignore-files IGNOREFILE [IGNOREFILE ...] + Files to ignore for renaming. Must add -- before positional arguments + ## Original repo -Originally hosted at https://git.iamthefij.com/iamthefij/paddy.git +Originally hosted at https://git.iamthefij.com/iamthefij/padio.git diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index d5a275a..0000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,52 +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('.')) - - -# -- Project information ----------------------------------------------------- - -project = 'paddy' -copyright = '2021, iamthefij' -author = 'iamthefij' - - -# -- 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 = [ -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# 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 = [] - - -# -- 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' - -# 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'] \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index fb3b750..0000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,20 +0,0 @@ -.. paddy documentation master file, created by - sphinx-quickstart on Fri Feb 5 15:06:05 2021. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to paddy's documentation! -======================================== - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/padio.py b/padio.py new file mode 100755 index 0000000..e6468f7 --- /dev/null +++ b/padio.py @@ -0,0 +1,155 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +import argparse +import re +from pathlib import Path +from typing import Generator +from typing import List +from typing import Optional +from typing import Tuple + + +num_matcher = re.compile(r"([0-9]+)") + + +def extract_numbers(s: str) -> str: + number_match = num_matcher.search(s) + if number_match: + return number_match.group(1) + return "" + + +def calc_pad_length(files: List[Path]) -> int: + max_len = 0 + for f in files: + numbers = extract_numbers(f.name) + max_len = max(len(numbers), max_len) + return max_len + + +def parse_args(sys_args: Optional[List[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Pads numbers in file names so they consistently align and sort" + ) + parser.add_argument("files", nargs="+", metavar="file", help="Files to be renamed") + parser.add_argument( + "-l", + "--length", + type=int, + help="Length of numbers after padding (default: auto)", + ) + parser.add_argument( + "-f", + "--force", + action="store_true", + help="Force rename, even if file at destination exists", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", help="Print all actions" + ) + parser.add_argument( + "-d", + "--dry-run", + action="store_true", + help="Print actions only without modifying any file. Implies --verbose", + ) + parser.add_argument( + "-i", + "--ignore", + metavar="REGEX", + help="Regular expression used to ignore files matching the name", + ) + parser.add_argument( + "--ignore-files", + nargs="+", + metavar="IGNOREFILE", + help="Files to ignore for renaming. Must add -- before positional arguments", + ) + args = parser.parse_args(sys_args) + + # Dry run implies verbose + if args.dry_run: + args.verbose = True + + return args + + +def get_files( + filenames: List[str], + ignore_filenames: List[str], + ignore_pattern: str, +) -> List[Path]: + # Compile ignore pattern, if provided + ignore_matcher: Optional[re.Pattern] = None + if ignore_pattern: + ignore_matcher = re.compile(ignore_pattern) + + p = Path(".") + files: List[Path] = [] + for f in filenames: + if ignore_filenames and f in ignore_filenames: + continue + if ignore_matcher and ignore_matcher.match(f): + continue + files.append(p / f) + + return files + + +def pad_files( + files: List[Path], + pad_len: int, + verbose=False, +) -> Generator[Tuple[Path, Path], None, None]: + for f in files: + numbers = extract_numbers(f.name) + if len(numbers) == pad_len: + if verbose: + print(f"{f.name} is already padded.") + continue + + # Pad number and get destination path + new_numbers = numbers.zfill(pad_len) + new_name = num_matcher.sub(new_numbers, f.name, count=1) + new_file = f.parent / new_name + + if f == new_file: + if verbose: + print(f"{f.name} already matches destination.") + continue + + yield f, new_file + + +def main(sys_args: Optional[List[str]] = None) -> int: + args = parse_args(sys_args) + + # Build list of files to act on + files = get_files(args.files, args.ignore_files, args.ignore) + + pad_len = args.length + if pad_len is None: + pad_len = calc_pad_length(files) + + if args.verbose: + print(f"Padding to {pad_len}") + + status = 0 + for f, new_file in pad_files(files, pad_len, args.verbose): + # Possibly rename unless exists or forced + if not new_file.exists() or args.force: + if args.verbose: + print(f"Rename {f.name} to {new_file.name}") + if not args.dry_run: + f.rename(new_file) + else: + print( + f"Could not rename {f.name} to {new_file.name}. Destination file exists." + ) + status = 1 + + return status + + +if __name__ == "__main__": + exit(main()) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..0d340f7 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-e . +pytest +coverage diff --git a/setup.py b/setup.py index aa10266..5d86eb9 100644 --- a/setup.py +++ b/setup.py @@ -11,13 +11,13 @@ with open(path.join(here, "README.md"), encoding="utf-8") as f: long_description = f.read() setup( - name="paddy", + name="padio", version="0.0.0", description="Zero pad numeric filenames", long_description=long_description, long_description_content_type="text/markdown", - url="https://git.iamthefij.com/iamthefij/paddy.git", - download_url=("https://git.iamthefij.com/iamthefij/paddy.git/archive/master.tar.gz"), + url="https://git.iamthefij.com/iamthefij/padio.git", + download_url=("https://git.iamthefij.com/iamthefij/padio.git/archive/master.tar.gz"), author="iamthefij", author_email="", classifiers=[ @@ -39,7 +39,7 @@ setup( install_requires=[], entry_points={ "console_scripts": [ - "paddy=paddy.main:main", + "padio=padio:main", ], }, ) diff --git a/tests/test_paddy.py b/tests/test_paddy.py new file mode 100644 index 0000000..24230a2 --- /dev/null +++ b/tests/test_paddy.py @@ -0,0 +1,90 @@ +from pathlib import Path + +import pytest + +import padio + + +@pytest.mark.parametrize( + "value,expected", + ( + ("file123", "123"), + ("123file", "123"), + ("123file456", "123"), + ("file", ""), + ), +) +def test_extract_numbers(value, expected): + assert padio.extract_numbers(value) == expected + + +@pytest.mark.parametrize( + "file_paths,expected", + ( + ([Path("/path/to/file.a123.txt"), Path("/path/to/file.b123.txt")], 3), + ([Path("/path/to/file.a023.txt"), Path("/path/to/file.b023.txt")], 3), + ([Path("/path/to/file.a123.txt"), Path("/path/to/file.b12.txt")], 3), + ), +) +def test_calc_pad_length(file_paths, expected): + assert padio.calc_pad_length(file_paths) == expected + + +@pytest.mark.parametrize( + "files,ignore_files,ignore,expected", + ( + ( + [ + "file.a123.txt", + "file.b123.txt", + ], + [], + "", + [ + Path("./file.a123.txt"), + Path("./file.b123.txt"), + ], + ), + ( + [ + "file.a123.txt", + "file.b123.txt", + ], + [ + "file.a123.txt", + ], + "", + [ + Path("./file.b123.txt"), + ], + ), + ( + [ + "file.a123.txt", + "file.b123.txt", + "ignore.txt" + ], + [ + "file.a123.txt", + ], + "ignore.*", + [ + Path("./file.b123.txt"), + ], + ), + ), +) +def test_get_files(files, ignore_files, ignore, expected): + assert padio.get_files(files, ignore_files, ignore) == expected + + +@pytest.mark.parametrize( + "files,pad_len,expected", + ( + ([Path("./file.a12")], 1, []), + ([Path("./file.a12")], 2, []), + ([Path("./file.a12")], 3, [Path("./file.a012")]), + ), +) +def test_pad_files(files, pad_len, expected): + assert list(padio.pad_files(files, pad_len)) == list(zip(files, expected)) diff --git a/tox.ini b/tox.ini index 518f7a7..1b83338 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,13 @@ [tox] -envlist = py3,pypy3 +envlist = py3,py36,py37,py38,pypy3 [testenv] deps = -rrequirements-dev.txt commands = coverage erase - coverage run --source=paddy/ -m pytest --capture=no -vv {posargs:tests} - coverage report -m --fail-under 70 + coverage run --source=padio -m pytest --capture=no -vv {posargs:tests} + coverage report -m --fail-under 50 pre-commit run --all-files [testenv:pre-commit] @@ -15,13 +15,3 @@ deps = pre-commit commands = pre-commit {posargs} - -[testenv:docs] -deps = - {[base]deps} - sphinx - sphinx_rtd_theme -changedir = docs -commands = - sphinx-apidoc -f -e -o source/code ../paddy - sphinx-build -b html -d build/doctrees source/ build/html