diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0bcc907 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: [push, pull_request] + +jobs: + build-package: + runs-on: ubuntu-latest + concurrency: + group: ${{ github.event_name }}-${{ github.workflow }}-${{ github.ref_name }}-build + cancel-in-progress: true + timeout-minutes: 20 + container: + image: ghcr.io/thombashi/python-ci:3.11 + + steps: + - uses: actions/checkout@v4 + + - run: make build + + lint: + runs-on: ubuntu-latest + permissions: + contents: read + concurrency: + group: ${{ github.event_name }}-${{ github.workflow }}-${{ github.ref_name }}-lint + cancel-in-progress: true + timeout-minutes: 20 + container: + image: ghcr.io/thombashi/python-ci:3.11 + + steps: + - uses: actions/checkout@v4 + + - run: make check + + unit-test: + runs-on: ${{ matrix.os }} + concurrency: + group: ${{ github.event_name }}-${{ github.workflow }}-${{ github.ref_name }}-ut-${{ matrix.os }}-${{ matrix.python-version }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + os: [ubuntu-latest, macos-latest, windows-latest] + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - name: Setup Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + **/*requirements.txt + setup.py + tox.ini + + - run: make setup-ci + + - name: Run tests + run: tox -e py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..294434a --- /dev/null +++ b/.gitignore @@ -0,0 +1,121 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# User settings +_sandbox/ +*_profile +Untitled.ipynb diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..c358b25 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,9 @@ +include LICENSE +include README.rst +include tox.ini + +recursive-include requirements * +recursive-include test * + +global-exclude __pycache__/* +global-exclude *.pyc diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cfbc476 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +PYTHON := python3 + +AUTHOR := thombashi +PACKAGE := pathvalidate-cli + +BUILD_WORK_DIR := _work +PKG_BUILD_DIR := $(BUILD_WORK_DIR)/$(PACKAGE) + + +.PHONY: build-remote +build-remote: clean + @mkdir -p $(BUILD_WORK_DIR) + @cd $(BUILD_WORK_DIR) && \ + git clone https://github.com/$(AUTHOR)/$(PACKAGE).git --depth 1 && \ + cd $(PACKAGE) && \ + $(PYTHON) -m tox -e build + ls -lh $(PKG_BUILD_DIR)/dist/* + +.PHONY: build +build: clean + $(PYTHON) -m tox -e build + ls -lh dist/* + +.PHONY: check +check: + $(PYTHON) -m tox -e lint + +.PHONY: clean +clean: + rm -rf $(BUILD_WORK_DIR) + $(PYTHON) -m tox -e clean + +.PHONY: fmt +fmt: + $(PYTHON) -m tox -e fmt + +.PHONY: release +release: + $(PYTHON) setup.py release --sign --verbose + $(MAKE) clean + +.PHONY: setup-ci +setup-ci: + $(PYTHON) -m pip install -q --disable-pip-version-check --upgrade tox + +.PHONY: setup +setup: setup-ci + $(PYTHON) -m pip install -q --disable-pip-version-check --upgrade -e .[test] releasecmd + $(PYTHON) -m pip check diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..9cb1609 --- /dev/null +++ b/README.rst @@ -0,0 +1,64 @@ +.. contents:: **pathvalidate-cli** + :backlinks: top + :depth: 2 + + +Summary +============================================ + +pathvalidate-cli is a command line interface for `pathvalidate `__ library. + + +Installation +============================================ +:: + + pip install pathvalidate-cli + + +Usage +============================================ + +:: + + $ pathvalidate sanitize 'fi:l*e/p"a?t>h|.th|.th|.t', '|', '<'), value='fi:l*e/p"a?t>h|.t None: + if is_enable: + logger.enable(MODULE_NAME) + else: + logger.disable(MODULE_NAME) + + +def initialize_logger(name: str, log_level: str) -> None: + logger.remove() + + if log_level == LogLevel.QUIET: + logger.disable(name) + return + + if log_level == LogLevel.DEBUG: + log_format = ( + "{level: <8} | " + "{name}:" + "{function}:" + "{line} - {message}" + ) + else: + log_format = "[{level}] {message}" + + logger.add(sys.stderr, colorize=True, format=log_format, level=log_level) + logger.enable(name) diff --git a/pathvalidate_cli/main.py b/pathvalidate_cli/main.py new file mode 100644 index 0000000..5571782 --- /dev/null +++ b/pathvalidate_cli/main.py @@ -0,0 +1,285 @@ +import sys +from enum import Enum, auto, unique +from textwrap import dedent +from typing import Final, List, Sequence + +import click +import msgfy +import pytablewriter as ptw +from click.core import Context +from pathvalidate import ( + AbstractSanitizer, + AbstractValidator, + FileNameSanitizer, + FileNameValidator, + FilePathSanitizer, + FilePathValidator, + normalize_platform, +) +from pathvalidate.error import ErrorReason, ValidationError + +from .__version__ import __version__ +from ._const import MODULE_NAME +from ._logger import LogLevel, initialize_logger, logger + + +COMMAND_EPILOG: Final[str] = dedent( + f"""\ + Issue tracker: https://github.com/thombashi/{MODULE_NAME}/issues + """ +) +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"], obj={}) + + +@unique +class ContextKey(Enum): + CHECK_RESERVED = auto() + LOG_LEVEL = auto() + IS_FILENAME = auto() + NORMALIZE = auto() + MAX_LEN = auto() + MIN_LEN = auto() + PLATFORM = auto() + VERBOSITY_LEVEL = auto() + VALIDATE_AFTER_SANITIZE = auto() + + +def use_stdin(args: Sequence) -> bool: + if sys.stdin.isatty(): + return False + + return len(args) == 1 and "-" in args + + +def print_err(e: ValidationError, fmt: str) -> None: + if fmt == "jsonl": + print(e.as_slog()) + return + + print(e) + + +@click.group(context_settings=CONTEXT_SETTINGS) +@click.version_option(version=__version__, message="%(prog)s %(version)s") +@click.option("--debug", "log_level", flag_value=LogLevel.DEBUG, help="For debug print.") +@click.option( + "-q", + "--quiet", + "log_level", + flag_value=LogLevel.QUIET, + help="Suppress execution log messages.", +) +@click.option("--filename", "is_filename", is_flag=True, help="Consider inputs as filenames.") +@click.option( + "--max-len", + "--max-bytes", + metavar="BYTES", + type=int, + show_default=True, + default=-1, + help="Maximum byte counts of file paths. -1: same value with the platform limitation.", +) +@click.option( + "--platform", + metavar="PLATFORM", + default="universal", + show_default=True, + help=" ".join( + [ + "Execution platform name (case-insensitive).", + "Valid platform specifiers are Linux/Windows/macOS.", + "Valid special values are: POSIX, universal\n", + ] + ) + + """\ + (a) auto: automatically detects the execution platform. + (b) universal: platform independent. +""", +) +@click.option( + "-v", "--verbose", "verbosity_level", help="Verbosity level", show_default=True, count=True +) +@click.pass_context +def cmd( + ctx: Context, + log_level: LogLevel, + is_filename: bool, + max_len: int, + platform: str, + verbosity_level: int, +) -> None: + ctx.obj[ContextKey.LOG_LEVEL] = LogLevel.INFO if log_level is None else log_level + ctx.obj[ContextKey.IS_FILENAME] = is_filename + ctx.obj[ContextKey.MAX_LEN] = max_len + ctx.obj[ContextKey.PLATFORM] = normalize_platform(platform) + ctx.obj[ContextKey.VERBOSITY_LEVEL] = verbosity_level + + initialize_logger(name=f"{MODULE_NAME:s}", log_level=ctx.obj[ContextKey.LOG_LEVEL]) + + for key, value in ctx.obj.items(): + logger.debug(f"{key}={value}") + + +def create_sanitizer(ctx: Context) -> AbstractSanitizer: + kwargs = { + "max_len": ctx.obj[ContextKey.MAX_LEN], + "platform": ctx.obj[ContextKey.PLATFORM], + "validate_after_sanitize": ctx.obj[ContextKey.VALIDATE_AFTER_SANITIZE], + } + + if not ctx.obj[ContextKey.IS_FILENAME]: + kwargs["normalize"] = ctx.obj[ContextKey.NORMALIZE] + + logger.debug(str(kwargs)) + + if ctx.obj[ContextKey.IS_FILENAME]: + return FileNameSanitizer(**kwargs) + + return FilePathSanitizer(**kwargs) + + +def create_validator(ctx: Context) -> AbstractValidator: + kwargs = { + "max_len": ctx.obj[ContextKey.MAX_LEN], + "min_len": ctx.obj[ContextKey.MIN_LEN], + "platform": ctx.obj[ContextKey.PLATFORM], + "check_reserved": ctx.obj[ContextKey.CHECK_RESERVED], + } + + if ctx.obj[ContextKey.IS_FILENAME]: + return FileNameValidator(**kwargs) + + return FilePathValidator(**kwargs) + + +def to_error_reason_row(code: str) -> List[str]: + for reason in ErrorReason: + if reason.code != code: + continue + + return [reason.code, reason.name, reason.description] + + raise ValueError(f"Error code {code} is not found.") + + +@cmd.command(epilog=COMMAND_EPILOG) +@click.pass_context +@click.argument("filepaths", type=str, nargs=-1) +@click.option( + "--replacement-text", + show_default=True, + default="", + help=""" + Replacement text for invalid characters. + Defaults to an empty string (remove invalid strings). + """, +) +@click.option( + "--normalize", + is_flag=True, + help="Normalize the path.", +) +@click.option( + "--validate-after-sanitize", + is_flag=True, + help="Execute validation after sanitization.", +) +def sanitize( + ctx: Context, + filepaths: List[str], + replacement_text: str, + normalize: bool, + validate_after_sanitize: bool, +) -> None: + """Sanitize file paths.""" + + ctx.obj[ContextKey.VALIDATE_AFTER_SANITIZE] = validate_after_sanitize + ctx.obj[ContextKey.NORMALIZE] = normalize + + if use_stdin(filepaths): + filepaths = sys.stdin.read().splitlines() + + sanitizer = create_sanitizer(ctx) + + for filepath in filepaths: + logger.debug(f"{sanitizer.__class__.__name__}: {filepath}") + + try: + print(sanitizer.sanitize(filepath, replacement_text)) + except ValidationError as e: + logger.error(msgfy.to_error_message(e)) + + +@cmd.command(epilog=COMMAND_EPILOG) +@click.pass_context +@click.argument("filepaths", type=str, nargs=-1) +@click.option( + "--min-len", + "--min-bytes", + metavar="BYTES", + type=int, + show_default=True, + default=1, + help="Minimum byte counts of file paths.", +) +@click.option("--no-check-reserved", is_flag=True, help="Check reserved names.") +def validate(ctx: Context, filepaths: List[str], min_len: int, no_check_reserved: bool) -> None: + """Validate file paths.""" + + if use_stdin(filepaths): + filepaths = sys.stdin.read().splitlines() + + ctx.obj[ContextKey.CHECK_RESERVED] = not no_check_reserved + ctx.obj[ContextKey.MIN_LEN] = min_len + validator = create_validator(ctx) + found_invalid = False + + for filepath in filepaths: + logger.debug(f"{validator.__class__.__name__}: {filepath}") + + try: + validator.validate(filepath) + except ValidationError as e: + print_err(e, fmt="text") + found_invalid = True + continue + + if ctx.obj[ContextKey.VERBOSITY_LEVEL] >= 1: + logger.info(f"{filepath} is a valid path for {ctx.obj[ContextKey.PLATFORM].value}") + + if found_invalid: + sys.exit(1) + + +@cmd.command(epilog=COMMAND_EPILOG) +@click.pass_context +@click.argument("codes", type=str, nargs=-1) +@click.option("--list", "list_errors", is_flag=True, help="List error reasons.") +def error(ctx: Context, codes: List[str], list_errors: bool) -> None: + """Print error reasons.""" + + if len(codes) == 0 and not list_errors: + click.echo(ctx.get_help()) + ctx.exit() + + errors: List[List[str]] = [] + exit_code = 0 + + if list_errors: + errors = [[reason.code, reason.name, reason.description] for reason in ErrorReason] + else: + for code in codes: + try: + errors.append(to_error_reason_row(code)) + except ValueError as e: + exit_code = 1 + click.echo(e) + + writer = ptw.MarkdownTableWriter( + table_name="Error Reason", + headers=["Code", "Name", "Description"], + value_matrix=errors, + margin=1, + ) + writer.write_table() + ctx.exit(exit_code) diff --git a/pylama.ini b/pylama.ini new file mode 100644 index 0000000..6f642fd --- /dev/null +++ b/pylama.ini @@ -0,0 +1,16 @@ +[pylama] +skip = .eggs/*,.tox/*,*/.env/*,build/*,node_modules/*,_sandbox/*,build/*,docs/conf.py + +[pylama:pycodestyle] +max_line_length = 100 + +# E203: whitespace before ':' (for black) +# W503: line break before binary operator (for black) +ignore = E203,W503 + +[pylama:pylint] +max_line_length = 100 + +[pylama:*/__init__.py] +# W0611: imported but unused [pyflakes] +ignore = W0611 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..42a1aad --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = ["setuptools>=61.0"] + +[tool.black] +exclude = ''' +/( + \.eggs + | \.git + | \.mypy_cache + | \.tox + | \.venv + | \.pytype + | _build + | buck-out + | build + | dist +)/ +| docs/conf.py +''' +line-length = 100 +target-version = ['py38', 'py39', 'py310', 'py311'] + +[tool.isort] +include_trailing_comma = true +known_third_party = ['pytest'] +line_length = 100 +lines_after_imports = 2 +multi_line_output = 3 +skip_glob = [ + '*/.eggs/*', + '*/.pytype/*', + '*/.tox/*', +] + +[tool.mypy] +ignore_missing_imports = true +python_version = 3.8 + +pretty = true + +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_defs = true +no_implicit_optional = true +show_error_codes = true +show_error_context = true +warn_redundant_casts = true +warn_unreachable = true +warn_unused_configs = true +warn_unused_ignores = true + +[tool.pytest.ini_options] +testpaths = [ + "test", +] + +md_report = true +md_report_color = "auto" +md_report_verbose = 0 diff --git a/requirements/requirements.txt b/requirements/requirements.txt new file mode 100644 index 0000000..e5743f0 --- /dev/null +++ b/requirements/requirements.txt @@ -0,0 +1,4 @@ +click>=6.2,<9 +loguru>=0.4.1,<1 +msgfy>=0.2,<1 +pathvalidate>=3.2.0,<4 diff --git a/requirements/test_requirements.txt b/requirements/test_requirements.txt new file mode 100644 index 0000000..9f58949 --- /dev/null +++ b/requirements/test_requirements.txt @@ -0,0 +1,2 @@ +pytest>=7 +pytest-md-report>=0.4.1 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..aee1029 --- /dev/null +++ b/setup.py @@ -0,0 +1,76 @@ +import os.path +from typing import Dict, Final, Type + +import setuptools + + +MODULE_NAME: Final[str] = "pathvalidate-cli" +MODULE_NAME_UNDERSCORE: Final[str] = MODULE_NAME.replace("-", "_") +REPOSITORY_URL: Final[str] = f"https://github.com/thombashi/{MODULE_NAME:s}" +REQUIREMENT_DIR: Final[str] = "requirements" +ENCODING: Final[str] = "utf8" + +pkg_info: Dict[str, str] = {} + + +def get_release_command_class() -> Dict[str, Type[setuptools.Command]]: + try: + from releasecmd import ReleaseCommand + except ImportError: + return {} + + return {"release": ReleaseCommand} + + +with open(os.path.join(MODULE_NAME_UNDERSCORE, "__version__.py")) as f: + exec(f.read(), pkg_info) + +with open("README.rst", encoding=ENCODING) as f: + LONG_DESCRIPTION = f.read() + +with open(os.path.join(REQUIREMENT_DIR, "requirements.txt")) as f: + INSTALL_REQUIRES = [line.strip() for line in f if line.strip()] + +with open(os.path.join(REQUIREMENT_DIR, "test_requirements.txt")) as f: + TESTS_REQUIRES = [line.strip() for line in f if line.strip()] + +setuptools.setup( + name=MODULE_NAME, + version=pkg_info["__version__"], + url=REPOSITORY_URL, + author=pkg_info["__author__"], + author_email=pkg_info["__email__"], + description="pathvalidate-cli is a command line interface for pathvalidate library.", + include_package_data=True, + keywords=["file", "path", "validate", "sanitize"], + license=pkg_info["__license__"], + long_description=LONG_DESCRIPTION, + long_description_content_type="text/x-rst", + packages=setuptools.find_packages(exclude=["test*"]), + project_urls={ + "Source": REPOSITORY_URL, + "Tracker": f"{REPOSITORY_URL:s}/issues", + }, + python_requires=">=3.8", + install_requires=INSTALL_REQUIRES, + extras_require={ + "test": TESTS_REQUIRES, + }, + classifiers=[ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Terminals", + "Topic :: Utilities", + ], + cmdclass=get_release_command_class(), + entry_points={"console_scripts": [f"pathvalidate={MODULE_NAME_UNDERSCORE}.main:cmd"]}, +) diff --git a/test/test_error_subcommand.py b/test/test_error_subcommand.py new file mode 100644 index 0000000..a65eec8 --- /dev/null +++ b/test/test_error_subcommand.py @@ -0,0 +1,45 @@ +import pytest +from click.testing import CliRunner + +from pathvalidate_cli.main import cmd + + +class Test_error_subcommand: + @pytest.mark.parametrize( + ["value", "options", "expected"], + [ + [ + ["PV1001"], + [], + 0, + ], + [ + ["PV1001", "PV1002"], + [], + 0, + ], + [ + [], + ["--list"], + 0, + ], + ], + ) + def test_normal(self, value, options, expected): + runner = CliRunner() + result = runner.invoke(cmd, ["error"] + value + options) + + assert result.exit_code == expected + assert result.output + + @pytest.mark.parametrize( + ["value", "options", "expected"], + [ + [["INVALID"], [], 1], + ], + ) + def test_normal_filename(self, value, options, expected): + runner = CliRunner() + result = runner.invoke(cmd, ["error"] + value + options) + + assert result.exit_code == expected diff --git a/test/test_help.py b/test/test_help.py new file mode 100644 index 0000000..5856dbd --- /dev/null +++ b/test/test_help.py @@ -0,0 +1,20 @@ +import pytest +from click.testing import CliRunner + +from pathvalidate_cli.main import cmd + + +class Test_main: + @pytest.mark.parametrize( + ["options", "expected"], + [ + [["-h"], 0], + [["sanitize", "-h"], 0], + [["validate", "-h"], 0], + ], + ) + def test_help(self, options, expected): + runner = CliRunner() + result = runner.invoke(cmd, options) + + assert result.exit_code == expected diff --git a/test/test_sanitize_subcommand.py b/test/test_sanitize_subcommand.py new file mode 100644 index 0000000..0710efd --- /dev/null +++ b/test/test_sanitize_subcommand.py @@ -0,0 +1,40 @@ +import pytest +from click.testing import CliRunner + +from pathvalidate_cli.main import cmd + + +class Test_sanitize_subcommand: + @pytest.mark.parametrize( + ["value", "options", "expected"], + [ + [ + [r'fi:l*e/p"a?t>h|.th|.th|.th|.t=0.10 + twine + wheel +commands = + python -m build + twine check dist/*.whl dist/*.tar.gz + +[testenv:clean] +skip_install = true +deps = + cleanpy>=0.4 +commands = + cleanpy --all --exclude-envs . + +[testenv:cov] +passenv = * +extras = + test +deps = + coverage[toml]>=5 +commands = + coverage run -m pytest {posargs:-vv} test/ + coverage report -m + +[testenv:fmt] +skip_install = true +deps = + autoflake>=2 + black>=23.1 + isort>=5 +commands = + autoflake --in-place --recursive --remove-all-unused-imports --ignore-init-module-imports . + isort . + black setup.py test pathvalidate_cli + +[testenv:lint] +skip_install = true +deps = + black>=23.1 + mypy>=1 + pylama>=8.4.1 + types-click +commands = + black --check setup.py test pathvalidate_cli + mypy pathvalidate_cli setup.py + pylama