From 27b6940d9fc7c6ef1a2275e44133b26ad31df695 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Mon, 5 Jan 2026 07:59:29 +0100 Subject: [PATCH 01/11] add new `base.decorators` module for custom `@mypyc_attr` decorator --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 3 +-- src/xulbux/__init__.py | 2 +- src/xulbux/base/decorators.py | 28 ++++++++++++++++++++++++++++ src/xulbux/base/exceptions.py | 2 +- src/xulbux/console.py | 2 +- src/xulbux/path.py | 2 +- src/xulbux/regex.py | 3 ++- src/xulbux/system.py | 2 +- 9 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 src/xulbux/base/decorators.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d058a..09b0ebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ #
Changelog
+ + +## ... `v1.9.4` + +* Added a new base module `base.decorators` which contains custom decorators used throughout the library. +* Made `mypy_extensions` an optional dependency by wrapping all uses of `mypy_extensions.mypyc_attr` in a custom decorator that acts as a no-op if `mypy_extensions` is not installed. + + ## 01.01.2026 `v1.9.3` Big Update 🚀 diff --git a/pyproject.toml b/pyproject.toml index df5e6d3..ab56202 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ build-backend = "setuptools.build_meta" [project] name = "xulbux" -version = "1.9.3" +version = "1.9.4" authors = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }] maintainers = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }] description = "A Python library to simplify common programming tasks." @@ -23,7 +23,6 @@ license-files = ["LICENSE"] requires-python = ">=3.10.0" dependencies = [ "keyboard>=0.13.5", - "mypy-extensions>=1.1.0", "prompt_toolkit>=3.0.41", "regex>=2023.10.3", ] diff --git a/src/xulbux/__init__.py b/src/xulbux/__init__.py index e40a2f3..a6920ce 100644 --- a/src/xulbux/__init__.py +++ b/src/xulbux/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.9.3" +__version__ = "1.9.4" __author__ = "XulbuX" __email__ = "xulbux.real@gmail.com" diff --git a/src/xulbux/base/decorators.py b/src/xulbux/base/decorators.py new file mode 100644 index 0000000..78fd66c --- /dev/null +++ b/src/xulbux/base/decorators.py @@ -0,0 +1,28 @@ +""" +This module contains custom decorators used throughout the library. +""" + +from typing import Callable, TypeVar, Any + + +T = TypeVar("T") + + +def _noop_decorator(obj: T) -> T: + """No-op decorator that returns the object unchanged.""" + return obj + + +def mypyc_attr(**kwargs: Any) -> Callable[[T], T]: + """A custom decorator that wraps `mypy_extensions.mypyc_attr` when available,
+ or acts as a no-op decorator when `mypy_extensions` is not installed.\n + This allows the use of mypyc compilation hints for compiling without making + `mypy_extensions` a required dependency.\n + ---------------------------------------------------------------------------------- + - `**kwargs` -⠀arguments to pass to `mypy_extensions.mypyc_attr` if available""" + try: + from mypy_extensions import mypyc_attr as _mypyc_attr + return _mypyc_attr(**kwargs) + except ImportError: + # IF 'mypy_extensions' IS NOT INSTALLED, JUST RETURN A NO-OP DECORATOR + return _noop_decorator diff --git a/src/xulbux/base/exceptions.py b/src/xulbux/base/exceptions.py index c403253..352c929 100644 --- a/src/xulbux/base/exceptions.py +++ b/src/xulbux/base/exceptions.py @@ -2,7 +2,7 @@ This module contains all custom exception classes used throughout the library. """ -from mypy_extensions import mypyc_attr +from .decorators import mypyc_attr # ################################################## FILE ################################################## diff --git a/src/xulbux/console.py b/src/xulbux/console.py index b4f5d2e..b37c76f 100644 --- a/src/xulbux/console.py +++ b/src/xulbux/console.py @@ -4,6 +4,7 @@ """ from .base.types import ArgConfigWithDefault, ArgResultRegular, ArgResultPositional, ProgressUpdater, AllTextChars, Rgba, Hexa +from .base.decorators import mypyc_attr from .base.consts import COLOR, CHARS, ANSI from .format_codes import _PATTERNS as _FC_PATTERNS, FormatCodes @@ -16,7 +17,6 @@ from prompt_toolkit.validation import ValidationError, Validator from prompt_toolkit.styles import Style from prompt_toolkit.keys import Keys -from mypy_extensions import mypyc_attr from contextlib import contextmanager from io import StringIO import prompt_toolkit as _pt diff --git a/src/xulbux/path.py b/src/xulbux/path.py index 4266475..74f8aed 100644 --- a/src/xulbux/path.py +++ b/src/xulbux/path.py @@ -3,9 +3,9 @@ """ from .base.exceptions import PathNotFoundError +from .base.decorators import mypyc_attr from typing import Optional -from mypy_extensions import mypyc_attr import tempfile as _tempfile import difflib as _difflib import shutil as _shutil diff --git a/src/xulbux/regex.py b/src/xulbux/regex.py index c7ad5d9..a9ef866 100644 --- a/src/xulbux/regex.py +++ b/src/xulbux/regex.py @@ -3,8 +3,9 @@ to dynamically generate complex regex patterns for common use cases. """ +from .base.decorators import mypyc_attr + from typing import Optional -from mypy_extensions import mypyc_attr import regex as _rx import re as _re diff --git a/src/xulbux/system.py b/src/xulbux/system.py index d62f634..e7ec66a 100644 --- a/src/xulbux/system.py +++ b/src/xulbux/system.py @@ -4,12 +4,12 @@ """ from .base.types import MissingLibsMsgs +from .base.decorators import mypyc_attr from .format_codes import FormatCodes from .console import Console from typing import Optional -from mypy_extensions import mypyc_attr import subprocess as _subprocess import platform as _platform import ctypes as _ctypes From 2e78cbfcf3c94f55a0ffc9d2fe390c0c64e782f6 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Mon, 5 Jan 2026 11:58:14 +0100 Subject: [PATCH 02/11] add/adjust package metadata & make metadata consistency test more advanced --- pyproject.toml | 7 +- src/xulbux/__init__.py | 16 ++- tests/test_metadata_consistency.py | 154 +++++++++++++++++++++++++++++ tests/test_version_consistency.py | 71 ------------- 4 files changed, 171 insertions(+), 77 deletions(-) create mode 100644 tests/test_metadata_consistency.py delete mode 100644 tests/test_version_consistency.py diff --git a/pyproject.toml b/pyproject.toml index ab56202..553162c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,10 +14,10 @@ build-backend = "setuptools.build_meta" [project] name = "xulbux" version = "1.9.4" -authors = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }] -maintainers = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }] description = "A Python library to simplify common programming tasks." readme = "README.md" +authors = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }] +maintainers = [{ name = "XulbuX", email = "xulbux.real@gmail.com" }] license = "MIT" license-files = ["LICENSE"] requires-python = ">=3.10.0" @@ -32,6 +32,7 @@ optional-dependencies = { dev = [ "flake8>=6.1.0", "isort>=5.12.0", "pytest>=7.4.2", + "toml>=0.10.2", ] } classifiers = [ "Development Status :: 5 - Production/Stable", @@ -171,9 +172,9 @@ testpaths = [ "tests/test_file.py", "tests/test_format_codes.py", "tests/test_json.py", + "tests/test_metadata_consistency.py", "tests/test_path.py", "tests/test_regex.py", "tests/test_string.py", "tests/test_system.py", - "tests/test_version_consistency.py", ] diff --git a/src/xulbux/__init__.py b/src/xulbux/__init__.py index a6920ce..a6cdf47 100644 --- a/src/xulbux/__init__.py +++ b/src/xulbux/__init__.py @@ -1,11 +1,21 @@ +__package_name__ = "xulbux" __version__ = "1.9.4" +__description__ = "A Python library to simplify common programming tasks." +__status__ = "Production/Stable" + +__url__ = "https://github.com/XulbuX/PythonLibraryXulbuX" __author__ = "XulbuX" __email__ = "xulbux.real@gmail.com" __license__ = "MIT" -__copyright__ = "Copyright (c) 2024 XulbuX" -__url__ = "https://github.com/XulbuX/PythonLibraryXulbuX" -__description__ = "A Python library to simplify common programming tasks." +__copyright__ = "Copyright (c) 2024-2026 XulbuX" + +__requires_python__ = ">=3.10.0" +__dependencies__ = [ + "keyboard>=0.13.5", + "prompt_toolkit>=3.0.41", + "regex>=2023.10.3", +] __all__ = [ "Code", diff --git a/tests/test_metadata_consistency.py b/tests/test_metadata_consistency.py new file mode 100644 index 0000000..07bbcd1 --- /dev/null +++ b/tests/test_metadata_consistency.py @@ -0,0 +1,154 @@ +from typing import Optional +from datetime import datetime +from pathlib import Path +import subprocess +import pytest +import toml +import os +import re + +# DEFINE PATHS RELATIVE TO THIS TEST FILE tests/test_version.py +ROOT_DIR = Path(__file__).parent.parent +PYPROJECT_PATH = ROOT_DIR / "pyproject.toml" +INIT_PATH = ROOT_DIR / "src" / "xulbux" / "__init__.py" + + +def get_current_branch() -> Optional[str]: + # CHECK GITHUB ACTIONS ENVIRONMENT VARIABLES FIRST + # GITHUB_HEAD_REF IS SET FOR PULL REQUESTS (SOURCE BRANCH) + if branch := os.environ.get("GITHUB_HEAD_REF"): + return branch + # GITHUB_REF_NAME IS SET FOR PUSHES (BRANCH NAME) + if branch := os.environ.get("GITHUB_REF_NAME"): + return branch + + # FALLBACK TO GIT COMMAND FOR LOCAL DEV + try: + result = subprocess.run(["git", "branch", "--show-current"], capture_output=True, text=True, check=True) + return result.stdout.strip() or None + except (subprocess.CalledProcessError, FileNotFoundError): + return None + + +################################################## VERSION CONSISTENCY TEST ################################################## + + +def test_version_consistency(): + """Verifies that the version numbers in `pyproject.toml` and `__init__.py` + match the version specified in the current release branch name (`dev/1.X.Y`).""" + # SKIP IF WE CAN'T DETERMINE THE BRANCH (DETACHED HEAD OR NOT A GIT REPO) + if not (branch_name := get_current_branch()): + pytest.skip("Could not determine git branch name") + + # SKIP IF BRANCH NAME DOESN'T MATCH RELEASE PATTERN dev/1.X.Y + if not (branch_match := re.match(r"^dev/(1\.[0-9]+\.[0-9]+)$", branch_name)): + pytest.skip(f"Current branch '{branch_name}' is not a release branch (dev/1.X.Y)") + + expected_version = branch_match.group(1) + + # EXTRACT VERSION FROM __init__.py + with open(INIT_PATH, "r", encoding="utf-8") as f: + init_content = f.read() + init_version_match = re.search(r'^__version__\s*=\s*"([^"]+)"', init_content, re.MULTILINE) + init_version = init_version_match.group(1) if init_version_match else None + + # EXTRACT VERSION FROM pyproject.toml + with open(PYPROJECT_PATH, "r", encoding="utf-8") as f: + pyproject_data = toml.load(f) + pyproject_version = pyproject_data.get("project", {}).get("version", "") + + assert init_version is not None, f"Could not find var '__version__' in {INIT_PATH}" + assert pyproject_version, f"Could not find var 'version' in {PYPROJECT_PATH}" + + assert init_version == expected_version, \ + f"Hardcoded lib-version in src/xulbux/__init__.py ({init_version}) does not match branch version ({expected_version})" + + assert pyproject_version == expected_version, \ + f"Hardcoded lib-version in pyproject.toml ({pyproject_version}) does not match branch version ({expected_version})" + + +################################################## COPYRIGHT YEAR TEST ################################################## + + +def test_copyright_year(): + """Verifies that the copyright year in `__init__.py` ends with the current year.""" + current_year = datetime.now().year + + # EXTRACT COPYRIGHT YEAR (SINGLE/RANGE) FROM __init__.py + with open(INIT_PATH, "r", encoding="utf-8") as f: + content = f.read() + init_copyright_range = re.search(r'^__copyright__\s*=\s*"Copyright \(c\) (\d{4})-(\d{4}) .+"', content, re.MULTILINE) + init_copyright_single = re.search(r'^__copyright__\s*=\s*"Copyright \(c\) (\d{4}) .+"', content, re.MULTILINE) + + if init_copyright_range: + start_year = int(init_copyright_range.group(1)) + end_year = int(init_copyright_range.group(2)) + + assert end_year == current_year, \ + f"Copyright end year in src/xulbux/__init__.py ({end_year}) does not match current year ({current_year})" + assert start_year <= end_year, \ + f"Copyright start year ({start_year}) is greater than end year ({end_year}) in src/xulbux/__init__.py" + + elif init_copyright_single: + year = int(init_copyright_single.group(1)) + + assert year == current_year, \ + f"Copyright year in src/xulbux/__init__.py ({year}) does not match current year ({current_year})" + + else: + pytest.fail(f"Could not find var '__copyright__' with valid year format in {INIT_PATH}") + + +################################################## DEPENDENCIES CONSISTENCY TEST ################################################## + + +def test_dependencies_consistency(): + """Verifies that dependencies in `pyproject.toml` match `__dependencies__` in `__init__.py`.""" + # EXTRACT DEPENDENCIES FROM __init__.py + with open(INIT_PATH, "r", encoding="utf-8") as f: + init_content = f.read() + init_deps = re.search(r'__dependencies__\s*=\s*\[(.*?)\]', init_content, re.DOTALL) + + # EXTRACT DEPENDENCIES FROM pyproject.toml + with open(PYPROJECT_PATH, "r", encoding="utf-8") as f: + pyproject_data = toml.load(f) + pyproject_deps = pyproject_data.get("project", {}).get("dependencies", []) + + assert init_deps is not None, f"Could not find var '__dependencies__' in {INIT_PATH}" + assert pyproject_deps, f"Could not find 'dependencies' in {PYPROJECT_PATH}" + + init_deps = [dep.strip().strip('"').strip("'") for dep in init_deps.group(1).split(",") if dep.strip()] + + # SORT FOR COMPARISON + pyproject_deps_sorted = sorted(pyproject_deps) + init_deps_sorted = sorted(init_deps) + + assert init_deps_sorted == pyproject_deps_sorted, \ + f"\nDependencies mismatch:\n" \ + f" __init__.py : {init_deps_sorted}\n" \ + f" pyproject.toml : {pyproject_deps_sorted}\n" + + +################################################## DESCRIPTION CONSISTENCY TEST ################################################## + + +def test_description_consistency(): + """Verifies that the description in `pyproject.toml` matches `__description__` in `__init__.py`.""" + # EXTRACT DESCRIPTION FROM __init__.py + with open(INIT_PATH, "r", encoding="utf-8") as f: + init_content = f.read() + init_desc_match = re.search(r'^__description__\s*=\s*"([^"]+)"', init_content, re.MULTILINE) + init_desc = init_desc_match.group(1) if init_desc_match else None + + # EXTRACT DESCRIPTION FROM pyproject.toml + with open(PYPROJECT_PATH, "r", encoding="utf-8") as f: + pyproject_data = toml.load(f) + pyproject_desc = pyproject_data.get("project", {}).get("description", "") + + assert init_desc is not None, f"Could not find var '__description__' in {INIT_PATH}" + assert pyproject_desc, f"Could not find 'description' in {PYPROJECT_PATH}" + + assert init_desc == pyproject_desc, \ + f"\nDescription mismatch:\n" \ + f" __init__.py : {init_desc}\n" \ + f" pyproject.toml : {pyproject_desc}\n" diff --git a/tests/test_version_consistency.py b/tests/test_version_consistency.py deleted file mode 100644 index 5d0e71d..0000000 --- a/tests/test_version_consistency.py +++ /dev/null @@ -1,71 +0,0 @@ -from typing import Optional -from pathlib import Path -import subprocess -import pytest -import os -import re - -# DEFINE PATHS RELATIVE TO THIS TEST FILE tests/test_version.py -ROOT_DIR = Path(__file__).parent.parent -PYPROJECT_PATH = ROOT_DIR / "pyproject.toml" -INIT_PATH = ROOT_DIR / "src" / "xulbux" / "__init__.py" - - -def get_current_branch() -> Optional[str]: - # CHECK GITHUB ACTIONS ENVIRONMENT VARIABLES FIRST - # GITHUB_HEAD_REF IS SET FOR PULL REQUESTS (SOURCE BRANCH) - if branch := os.environ.get("GITHUB_HEAD_REF"): - return branch - # GITHUB_REF_NAME IS SET FOR PUSHES (BRANCH NAME) - if branch := os.environ.get("GITHUB_REF_NAME"): - return branch - - # FALLBACK TO GIT COMMAND FOR LOCAL DEV - try: - result = subprocess.run(["git", "branch", "--show-current"], capture_output=True, text=True, check=True) - return result.stdout.strip() or None - except (subprocess.CalledProcessError, FileNotFoundError): - return None - - -def get_file_version(file_path: Path, pattern: str) -> Optional[str]: - if not file_path.exists(): - return None - - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - match = re.search(pattern, content, re.MULTILINE) - if match: - return match.group(1) - - return None - - -################################################## VERSION CONSISTENCY TEST ################################################## - - -def test_version_consistency(): - """Verifies that the version numbers in `pyproject.toml` and `__init__.py` - match the version specified in the current release branch name (`dev/1.X.Y`).""" - # SKIP IF WE CAN'T DETERMINE THE BRANCH (DETACHED HEAD OR NOT A GIT REPO) - if not (branch_name := get_current_branch()): - pytest.skip("Could not determine git branch name") - - # SKIP IF BRANCH NAME DOESN'T MATCH RELEASE PATTERN dev/1.X.Y - if not (branch_match := re.match(r"^dev/(1\.[0-9]+\.[0-9]+)$", branch_name)): - pytest.skip(f"Current branch '{branch_name}' is not a release branch (dev/1.X.Y)") - - expected_version = branch_match.group(1) - - # EXTRACT VERSIONS - pyproject_version = get_file_version(PYPROJECT_PATH, r'^version\s*=\s*"([^"]+)"') - init_version = get_file_version(INIT_PATH, r'^__version__\s*=\s*"([^"]+)"') - - assert pyproject_version is not None, f"Could not find var 'version' in {PYPROJECT_PATH}" - assert init_version is not None, f"Could not find var '__version__' in {INIT_PATH}" - - assert pyproject_version == expected_version, \ - f"Hardcoded lib-version in pyproject.toml ({pyproject_version}) does not match branch version ({expected_version})" - - assert init_version == expected_version, \ - f"Hardcoded lib-version in src/xulbux/__init__.py ({init_version}) does not match branch version ({expected_version})" From 2f1485f3fb43cae33d9562aae895ac3fe0c693c2 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Mon, 5 Jan 2026 12:55:03 +0100 Subject: [PATCH 03/11] remove unused config & use func instead of lambda --- pyproject.toml | 29 ----------------------------- tests/test_console.py | 5 ++++- 2 files changed, 4 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 553162c..60dd4ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,10 +27,8 @@ dependencies = [ "regex>=2023.10.3", ] optional-dependencies = { dev = [ - "black>=23.7.0", "flake8-pyproject>=1.2.3", "flake8>=6.1.0", - "isort>=5.12.0", "pytest>=7.4.2", "toml>=0.10.2", ] } @@ -118,33 +116,6 @@ keywords = [ [project.scripts] xulbux-help = "xulbux.cli.help:show_help" -[tool.black] -line-length = 127 -target-version = ['py310', 'py311', 'py312', 'py313', 'py314'] -include = '\.pyi?$' -extend-exclude = ''' -/( - # directories - \.eggs - | \.git - | \.hg - | \.mypy_cache - | \.tox - | \.venv - | build - | dist -)/ -''' - -[tool.isort] -profile = "black" -line_length = 127 -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -ensure_newline_before_comments = true - [tool.flake8] max-complexity = 12 max-line-length = 127 diff --git a/tests/test_console.py b/tests/test_console.py index 87c56c6..ca98cd7 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -15,7 +15,10 @@ @pytest.fixture def mock_terminal_size(monkeypatch): TerminalSize = namedtuple("TerminalSize", ["columns", "lines"]) - mock_get_terminal_size = lambda: TerminalSize(columns=80, lines=24) + + def mock_get_terminal_size(): + return TerminalSize(columns=80, lines=24) + monkeypatch.setattr(console._os, "get_terminal_size", mock_get_terminal_size) From 4cbd49b749de29244f93513b259685b519708df9 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Tue, 6 Jan 2026 10:17:36 +0100 Subject: [PATCH 04/11] rename `path` to `file_sys` & replace all usage of `os.path` with `pathlib.Path` --- CHANGELOG.md | 10 ++ README.md | 8 +- setup.py | 8 +- src/xulbux/__init__.py | 4 +- src/xulbux/base/decorators.py | 6 +- src/xulbux/base/types.py | 4 + src/xulbux/env_path.py | 78 ++++++++------ src/xulbux/file.py | 26 ++--- src/xulbux/{path.py => file_sys.py} | 158 +++++++++++++++------------- src/xulbux/json.py | 38 ++++--- tests/test_env_path.py | 7 +- tests/test_file.py | 44 ++++---- tests/test_json.py | 8 +- tests/test_path.py | 90 +++++++++------- 14 files changed, 278 insertions(+), 211 deletions(-) rename src/xulbux/{path.py => file_sys.py} (59%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b0ebc..b1c06d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,16 @@ * Added a new base module `base.decorators` which contains custom decorators used throughout the library. * Made `mypy_extensions` an optional dependency by wrapping all uses of `mypy_extensions.mypyc_attr` in a custom decorator that acts as a no-op if `mypy_extensions` is not installed. +* The methods from the `env_path` module that modify the PATH environment variable, no longer sort all paths alphabetically, but keep the original order, to not mess with the user's intended PATH order. +* Added a new TypeAlias `PathsList` to the `base.types` module, which matches a list of paths as strings or `pathlib.Path` objects. + +**BREAKING CHANGES:** +* Renamed the module `path` to `file_sys` and its main class `Path` to `FileSys`, so you can better use it alongside the built-in `pathlib.Path` class without always needing to import one of them under an alias. +* Renamed most `FileSys` methods to better describe their functionality: + - `Path.extend()` is now `FileSys.extend_path()` + - `Path.extend_or_make()` is now `FileSys.extend_or_make_path()` +* Updated all library methods that work with paths to accept `pathlib.Path` objects additionally to strings, as path inputs. +* Also, all library methods that return paths now return `pathlib.Path` objects instead of strings. diff --git a/README.md b/README.md index 67b5500..adf497f 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,10 @@ from xulbux.color import rgba, hsla, hexa env_path EnvPath class, which includes methods to work with the PATH environment variable. + + path + FileSys class, which includes methods to work with the file system and directories. + file File class, which includes methods to work with files and file paths. @@ -135,10 +139,6 @@ from xulbux.color import rgba, hsla, hexa Json class, which includes methods to read, create and update JSON files,
with support for comments inside the JSON data. - - path - Path class, which includes methods to work with file and directory paths. - regex Regex class, which includes methods to dynamically generate complex regex patterns
diff --git a/setup.py b/setup.py index 34d21a1..1984e31 100644 --- a/setup.py +++ b/setup.py @@ -1,14 +1,12 @@ from mypyc.build import mypycify from setuptools import setup -import os +from pathlib import Path def find_python_files(directory: str) -> list[str]: python_files: list[str] = [] - for root, _, files in os.walk(directory): - for file in files: - if file.endswith(".py"): - python_files.append(os.path.join(root, file)) + for file in Path(directory).rglob("*.py"): + python_files.append(str(file)) return python_files diff --git a/src/xulbux/__init__.py b/src/xulbux/__init__.py index a6cdf47..b75d828 100644 --- a/src/xulbux/__init__.py +++ b/src/xulbux/__init__.py @@ -24,9 +24,9 @@ "Data", "EnvPath", "File", + "FileSys", "FormatCodes", "Json", - "Path", "Regex", "String", "System", @@ -38,9 +38,9 @@ from .data import Data from .env_path import EnvPath from .file import File +from .file_sys import FileSys from .format_codes import FormatCodes from .json import Json -from .path import Path from .regex import Regex from .string import String from .system import System diff --git a/src/xulbux/base/decorators.py b/src/xulbux/base/decorators.py index 78fd66c..7d3d092 100644 --- a/src/xulbux/base/decorators.py +++ b/src/xulbux/base/decorators.py @@ -16,10 +16,10 @@ def _noop_decorator(obj: T) -> T: def mypyc_attr(**kwargs: Any) -> Callable[[T], T]: """A custom decorator that wraps `mypy_extensions.mypyc_attr` when available,
or acts as a no-op decorator when `mypy_extensions` is not installed.\n - This allows the use of mypyc compilation hints for compiling without making + This allows the use of `mypyc` compilation hints for compiling without making `mypy_extensions` a required dependency.\n - ---------------------------------------------------------------------------------- - - `**kwargs` -⠀arguments to pass to `mypy_extensions.mypyc_attr` if available""" + ----------------------------------------------------------------------------------------- + - `**kwargs` -⠀keyword arguments to pass to `mypy_extensions.mypyc_attr` if available""" try: from mypy_extensions import mypyc_attr as _mypyc_attr return _mypyc_attr(**kwargs) diff --git a/src/xulbux/base/types.py b/src/xulbux/base/types.py index 9ad98e1..e61a84f 100644 --- a/src/xulbux/base/types.py +++ b/src/xulbux/base/types.py @@ -3,6 +3,7 @@ """ from typing import TYPE_CHECKING, Annotated, TypeAlias, TypedDict, Optional, Protocol, Union, Any +from pathlib import Path # PREVENT CIRCULAR IMPORTS if TYPE_CHECKING: @@ -26,6 +27,9 @@ # ################################################## TypeAlias ################################################## +PathsList: TypeAlias = Union[list[Path], list[str], list[Path | str]] +"""Union of all supported list types for a list of paths.""" + DataStructure: TypeAlias = Union[list, tuple, set, frozenset, dict] """Union of supported data structures used in the `data` module.""" DataStructureTypes = (list, tuple, set, frozenset, dict) diff --git a/src/xulbux/env_path.py b/src/xulbux/env_path.py index 9957237..33535f5 100644 --- a/src/xulbux/env_path.py +++ b/src/xulbux/env_path.py @@ -3,9 +3,10 @@ methods to work with the PATH environment variable. """ -from .path import Path +from .file_sys import FileSys -from typing import Optional +from typing import Optional, cast +from pathlib import Path import sys as _sys import os as _os @@ -14,76 +15,82 @@ class EnvPath: """This class includes methods to work with the PATH environment variable.""" @classmethod - def paths(cls, as_list: bool = False) -> str | list: + def paths(cls, as_list: bool = False) -> Path | list[Path]: """Get the PATH environment variable.\n - ------------------------------------------------------------------------------ - - `as_list` -⠀if true, returns the paths as a list; otherwise, as a string""" - paths = _os.environ.get("PATH", "") - return paths.split(_os.pathsep) if as_list else paths + ------------------------------------------------------------------------------------------------ + - `as_list` -⠀if true, returns the paths as a list of `Path`s; otherwise, as a single `Path`""" + paths_str = _os.environ.get("PATH", "") + if as_list: + return [Path(path) for path in paths_str.split(_os.pathsep) if path] + return Path(paths_str) @classmethod - def has_path(cls, path: Optional[str] = None, cwd: bool = False, base_dir: bool = False) -> bool: + def has_path(cls, path: Optional[Path | str] = None, cwd: bool = False, base_dir: bool = False) -> bool: """Check if a path is present in the PATH environment variable.\n ------------------------------------------------------------------------ - `path` -⠀the path to check for - `cwd` -⠀if true, uses the current working directory as the path - `base_dir` -⠀if true, uses the script's base directory as the path""" - return _os.path.normpath(cls._get(path, cwd, base_dir)) \ - in {_os.path.normpath(p) for p in cls.paths(as_list=True)} + check_path = cls._get(path, cwd, base_dir).resolve() + return check_path in {path.resolve() for path in cast(list[Path], cls.paths(as_list=True))} @classmethod - def add_path(cls, path: Optional[str] = None, cwd: bool = False, base_dir: bool = False) -> None: + def add_path(cls, path: Optional[Path | str] = None, cwd: bool = False, base_dir: bool = False) -> None: """Add a path to the PATH environment variable.\n ------------------------------------------------------------------------ - `path` -⠀the path to add - `cwd` -⠀if true, uses the current working directory as the path - `base_dir` -⠀if true, uses the script's base directory as the path""" - if not cls.has_path(path := cls._get(path, cwd, base_dir)): - cls._persistent(path) + path_obj = cls._get(path, cwd, base_dir) + if not cls.has_path(path_obj): + cls._persistent(path_obj) @classmethod - def remove_path(cls, path: Optional[str] = None, cwd: bool = False, base_dir: bool = False) -> None: + def remove_path(cls, path: Optional[Path | str] = None, cwd: bool = False, base_dir: bool = False) -> None: """Remove a path from the PATH environment variable.\n ------------------------------------------------------------------------ - `path` -⠀the path to remove - `cwd` -⠀if true, uses the current working directory as the path - `base_dir` -⠀if true, uses the script's base directory as the path""" - if cls.has_path(path := cls._get(path, cwd, base_dir)): - cls._persistent(path, remove=True) + path_obj = cls._get(path, cwd, base_dir) + if cls.has_path(path_obj): + cls._persistent(path_obj, remove=True) @staticmethod - def _get(path: Optional[str] = None, cwd: bool = False, base_dir: bool = False) -> str: + def _get(path: Optional[Path | str] = None, cwd: bool = False, base_dir: bool = False) -> Path: """Internal method to get the normalized `path`, CWD path or script directory path.\n -------------------------------------------------------------------------------------- Raise an error if no path is provided and neither `cwd` or `base_dir` is true.""" if cwd: if base_dir: raise ValueError("Both 'cwd' and 'base_dir' cannot be True at the same time.") - path = Path.cwd + return FileSys.cwd elif base_dir: - path = Path.script_dir + return FileSys.script_dir if path is None: raise ValueError("No path provided.\nPlease provide a 'path' or set either 'cwd' or 'base_dir' to True.") - return _os.path.normpath(path) + return Path(path) if isinstance(path, str) else path @classmethod - def _persistent(cls, path: str, remove: bool = False) -> None: + def _persistent(cls, path: Path, remove: bool = False) -> None: """Internal method to add or remove a path from the PATH environment variable, persistently, across sessions, as well as the current session.""" - current_paths = list(cls.paths(as_list=True)) - path = _os.path.normpath(path) + current_paths = cast(list[Path], cls.paths(as_list=True)) + path_resolved = path.resolve() if remove: - current_paths = [ - path for path in current_paths \ - if _os.path.normpath(path) != _os.path.normpath(path) - ] + # FILTER OUT THE PATH TO REMOVE + current_paths = [path for path in current_paths if path.resolve() != path_resolved] else: - current_paths.append(path) + # ADD THE NEW PATH IF NOT ALREADY PRESENT + if path_resolved not in {p.resolve() for p in current_paths}: + current_paths = [*current_paths, path_resolved] - _os.environ["PATH"] = new_path = _os.pathsep.join(sorted(set(filter(bool, current_paths)))) + # CONVERT TO STRINGS ONLY FOR SETTING THE ENVIRONMENT VARIABLE + path_strings = [str(p) for p in current_paths] + _os.environ["PATH"] = new_path = _os.pathsep.join(dict.fromkeys(filter(bool, path_strings))) if _sys.platform == "win32": # WINDOWS try: @@ -95,20 +102,21 @@ def _persistent(cls, path: str, remove: bool = False) -> None: raise RuntimeError("Failed to update PATH in registry:\n " + str(e).replace("\n", " \n")) else: # UNIX-LIKE (LINUX/macOS) - shell_rc_file = _os.path.expanduser( - "~/.bashrc" if _os.path.exists(_os.path.expanduser("~/.bashrc")) \ - else "~/.zshrc" - ) + home_path = Path.home() + bashrc = home_path / ".bashrc" + zshrc = home_path / ".zshrc" + shell_rc_file = bashrc if bashrc.exists() else zshrc with open(shell_rc_file, "r+") as file: content = file.read() file.seek(0) if remove: - new_content = [line for line in content.splitlines() if not line.endswith(f':{path}"')] + new_content = [line for line in content.splitlines() if not line.endswith(f':{path_resolved}"')] file.write("\n".join(new_content)) else: - file.write(f'{content.rstrip()}\n# Added by XulbuX\nexport PATH="{new_path}"\n') + file.write(f"{content.rstrip()}\n# Added by 'xulbux'\n" + f'export PATH="{new_path}"\n') file.truncate() diff --git a/src/xulbux/file.py b/src/xulbux/file.py index 98afd92..38f85f1 100644 --- a/src/xulbux/file.py +++ b/src/xulbux/file.py @@ -6,7 +6,7 @@ from .base.exceptions import SameContentFileExistsError from .string import String -import os as _os +from pathlib import Path class File: @@ -15,11 +15,11 @@ class File: @classmethod def rename_extension( cls, - file_path: str, + file_path: Path | str, new_extension: str, full_extension: bool = False, camel_case_filename: bool = False, - ) -> str: + ) -> Path: """Rename the extension of a file.\n ---------------------------------------------------------------------------- - `file_path` -⠀the path to the file whose extension should be changed @@ -28,8 +28,8 @@ def rename_extension( or just the last part of it (e.g. `.gz`) - `camel_case_filename` -⠀whether to convert the filename to CamelCase in addition to changing the files extension""" - normalized_file = _os.path.normpath(file_path) - directory, filename_with_ext = _os.path.split(normalized_file) + path = Path(file_path) + filename_with_ext = path.name if full_extension: try: @@ -38,17 +38,17 @@ def rename_extension( except ValueError: filename = filename_with_ext else: - filename, _ = _os.path.splitext(filename_with_ext) + filename = path.stem if camel_case_filename: filename = String.to_camel_case(filename) if new_extension and not new_extension.startswith("."): new_extension = "." + new_extension - return _os.path.join(directory, f"{filename}{new_extension}") + return path.parent / f"{filename}{new_extension}" @classmethod - def create(cls, file_path: str, content: str = "", force: bool = False) -> str: + def create(cls, file_path: Path | str, content: str = "", force: bool = False) -> Path: """Create a file with ot without content.\n ------------------------------------------------------------------ - `file_path` -⠀the path where the file should be created @@ -59,14 +59,16 @@ def create(cls, file_path: str, content: str = "", force: bool = False) -> str: The method will throw a `FileExistsError` if a file with the same name already exists and a `SameContentFileExistsError` if a file with the same name and same content already exists.""" - if _os.path.exists(file_path) and not force: - with open(file_path, "r", encoding="utf-8") as existing_file: + path = Path(file_path) + + if path.exists() and not force: + with open(path, "r", encoding="utf-8") as existing_file: existing_content = existing_file.read() if existing_content == content: raise SameContentFileExistsError("Already created this file. (nothing changed)") raise FileExistsError("File already exists.") - with open(file_path, "w", encoding="utf-8") as f: + with open(path, "w", encoding="utf-8") as f: f.write(content) - return _os.path.abspath(file_path) + return path.resolve() diff --git a/src/xulbux/path.py b/src/xulbux/file_sys.py similarity index 59% rename from src/xulbux/path.py rename to src/xulbux/file_sys.py index 74f8aed..991ddf6 100644 --- a/src/xulbux/path.py +++ b/src/xulbux/file_sys.py @@ -1,11 +1,14 @@ """ -This module provides the `Path` class, which includes methods to work with file and directory paths. +This module provides the `FileSys` class, which includes +methods to work with the file system and directories. """ +from .base.types import PathsList from .base.exceptions import PathNotFoundError from .base.decorators import mypyc_attr from typing import Optional +from pathlib import Path import tempfile as _tempfile import difflib as _difflib import shutil as _shutil @@ -14,45 +17,45 @@ @mypyc_attr(native_class=False) -class _PathMeta(type): +class _FileSysMeta(type): @property - def cwd(cls) -> str: + def cwd(cls) -> Path: """The path to the current working directory.""" - return _os.getcwd() + return Path.cwd() @property - def home(cls) -> str: + def home(cls) -> Path: """The path to the user's home directory.""" - return _os.path.expanduser("~") + return Path.home() @property - def script_dir(cls) -> str: + def script_dir(cls) -> Path: """The path to the directory of the current script.""" if getattr(_sys, "frozen", False): - base_path = _os.path.dirname(_sys.executable) + base_path = Path(_sys.executable).parent else: main_module = _sys.modules["__main__"] if hasattr(main_module, "__file__") and main_module.__file__ is not None: - base_path = _os.path.dirname(_os.path.abspath(main_module.__file__)) + base_path = Path(main_module.__file__).resolve().parent elif (hasattr(main_module, "__spec__") and main_module.__spec__ and main_module.__spec__.origin is not None): - base_path = _os.path.dirname(_os.path.abspath(main_module.__spec__.origin)) + base_path = Path(main_module.__spec__.origin).resolve().parent else: raise RuntimeError("Can only get base directory if accessed from a file.") return base_path -class Path(metaclass=_PathMeta): +class FileSys(metaclass=_FileSysMeta): """This class provides methods to work with file and directory paths.""" @classmethod - def extend( + def extend_path( cls, - rel_path: str, - search_in: Optional[str | list[str]] = None, + rel_path: Path | str, + search_in: Optional[Path | str | PathsList] = None, raise_error: bool = False, use_closest_match: bool = False, - ) -> Optional[str]: + ) -> Optional[Path]: """Tries to resolve and extend a relative path to an absolute path.\n ------------------------------------------------------------------------------------------- - `rel_path` -⠀the relative path to extend @@ -67,56 +70,66 @@ def extend( it will be searched in the `search_in` directory/s.
If the `rel_path` is still not found, it returns `None` or raises a `PathNotFoundError` if `raise_error` is true.""" - search_dirs: list[str] = [] + search_dirs: list[Path] = [] if search_in is not None: - if isinstance(search_in, str): - search_dirs.extend([search_in]) + if isinstance(search_in, (str, Path)): + search_dirs.extend([Path(search_in)]) elif isinstance(search_in, list): - search_dirs.extend(search_in) + search_dirs.extend([Path(p) for p in search_in]) else: - raise TypeError(f"The 'search_in' parameter must be a string or a list of strings, got {type(search_in)}") + raise TypeError( + f"The 'search_in' parameter must be a string, Path, or a list of strings/Paths, got {type(search_in)}" + ) - if rel_path == "": + # CONVERT rel_path TO PATH + path = Path(str(rel_path)) if rel_path else None + + if not path or str(path) == "": if raise_error: raise PathNotFoundError("Path is empty.") else: return None - elif _os.path.isabs(rel_path): - return rel_path - rel_path = _os.path.normpath(cls._expand_env_path(rel_path)) + # IF ALREADY ABSOLUTE, RETURN IT + if path.is_absolute(): + return path + + # EXPAND ENVIRONMENT VARIABLES AND NORMALIZE + path = Path(cls._expand_env_path(str(path))) - if _os.path.isabs(rel_path): - drive, rel_path = _os.path.splitdrive(rel_path) - rel_path = rel_path.lstrip(_os.sep) - search_dirs.extend([(drive + _os.sep) if drive else _os.sep]) + if path.is_absolute(): + # SPLIT DRIVE AND PATH ON WINDOWS + if path.drive: + search_dirs.extend([Path(path.drive + _os.sep)]) + path = Path(*path.parts[1:]) # REMOVE DRIVE FROM PARTS + else: + search_dirs.extend([Path(_os.sep)]) + path = Path(*path.parts[1:]) # REMOVE ROOT FROM PARTS else: - rel_path = rel_path.lstrip(_os.sep) - search_dirs.extend([_os.getcwd(), cls.script_dir, _os.path.expanduser("~"), _tempfile.gettempdir()]) + search_dirs.extend([Path.cwd(), cls.script_dir, Path.home(), Path(_tempfile.gettempdir())]) for search_dir in search_dirs: - if _os.path.exists(full_path := _os.path.join(search_dir, rel_path)): + full_path = search_dir / path + if full_path.exists(): return full_path - if (match := ( - cls._find_path(search_dir, rel_path.split(_os.sep), use_closest_match) \ - if use_closest_match else None - )): - return match + if use_closest_match: + if (match := cls._find_path(search_dir, path.parts, use_closest_match)): + return match if raise_error: - raise PathNotFoundError(f"Path '{rel_path}' not found in specified directories.") + raise PathNotFoundError(f"Path {rel_path!r} not found in specified directories.") else: return None @classmethod - def extend_or_make( + def extend_or_make_path( cls, - rel_path: str, - search_in: Optional[str | list[str]] = None, + rel_path: Path | str, + search_in: Optional[Path | str | list[Path | str]] = None, prefer_script_dir: bool = True, use_closest_match: bool = False, - ) -> str: + ) -> Path: """Tries to locate and extend a relative path to an absolute path, and if the `rel_path` couldn't be located, it generates a path, as if it was located.\n ------------------------------------------------------------------------------------------- @@ -136,45 +149,45 @@ def extend_or_make( If `prefer_script_dir` is false, it will instead make a path that points to where the `rel_path` would be in the CWD.""" try: - return str(cls.extend( \ + result = cls.extend_path( rel_path=rel_path, search_in=search_in, raise_error=True, use_closest_match=use_closest_match, - )) + ) + return result if result is not None else Path() except PathNotFoundError: - return _os.path.join( - cls.script_dir if prefer_script_dir else _os.getcwd(), - _os.path.normpath(rel_path), - ) + path = Path(str(rel_path)) + base_dir = cls.script_dir if prefer_script_dir else Path.cwd() + return base_dir / path @classmethod - def remove(cls, path: str, only_content: bool = False) -> None: + def remove(cls, path: Path | str, only_content: bool = False) -> None: """Removes the directory or the directory's content at the specified path.\n ----------------------------------------------------------------------------- - `path` -⠀the path to the directory or file to remove - `only_content` -⠀if true, only the content of the directory is removed and the directory itself is kept""" - if not _os.path.exists(path): + if not (path_obj := Path(path)).exists(): return None if not only_content: - if _os.path.isfile(path) or _os.path.islink(path): - _os.unlink(path) - elif _os.path.isdir(path): - _shutil.rmtree(path) - - elif _os.path.isdir(path): - for filename in _os.listdir(path): - file_path = _os.path.join(path, filename) + if path_obj.is_file() or path_obj.is_symlink(): + path_obj.unlink() + elif path_obj.is_dir(): + _shutil.rmtree(path_obj) + + elif path_obj.is_dir(): + for item in path_obj.iterdir(): try: - if _os.path.isfile(file_path) or _os.path.islink(file_path): - _os.unlink(file_path) - elif _os.path.isdir(file_path): - _shutil.rmtree(file_path) + if item.is_file() or item.is_symlink(): + item.unlink() + elif item.is_dir(): + _shutil.rmtree(item) except Exception as e: - raise Exception(f"Failed to delete {file_path}. Reason: {e}") + fmt_error = "\n ".join(str(e).splitlines()) + raise Exception(f"Failed to delete {item!r}:\n {fmt_error}") from e @staticmethod def _expand_env_path(path_str: str) -> str: @@ -189,27 +202,26 @@ def _expand_env_path(path_str: str) -> str: return "".join(parts) @classmethod - def _find_path(cls, start_dir: str, path_parts: list[str], use_closest_match: bool) -> Optional[str]: + def _find_path(cls, start_dir: Path, path_parts: tuple[str, ...], use_closest_match: bool) -> Optional[Path]: """Internal method to find a path by traversing the given parts from the start directory, optionally using closest matches for each part.""" - current_dir: str = start_dir + current_path: Path = start_dir for part in path_parts: - if _os.path.isfile(current_dir): - return current_dir - if (closest_match := cls._get_closest_match(current_dir, part) if use_closest_match else part) is None: + if current_path.is_file(): + return current_path + if (closest_match := cls._get_closest_match(current_path, part) if use_closest_match else part) is None: return None - current_dir = _os.path.join(current_dir, closest_match) + current_path = current_path / closest_match - return current_dir if _os.path.exists(current_dir) and current_dir != start_dir else None + return current_path if current_path.exists() and current_path != start_dir else None @staticmethod - def _get_closest_match(dir: str, path_part: str) -> Optional[str]: + def _get_closest_match(dir: Path, path_part: str) -> Optional[str]: """Internal method to get the closest matching file or folder name in the given directory for the given path part.""" try: - return matches[0] if ( - matches := _difflib.get_close_matches(path_part, _os.listdir(dir), n=1, cutoff=0.6) - ) else None + items = [item.name for item in dir.iterdir()] + return matches[0] if (matches := _difflib.get_close_matches(path_part, items, n=1, cutoff=0.6)) else None except Exception: return None diff --git a/src/xulbux/json.py b/src/xulbux/json.py index f5e7fa7..4fb91e0 100644 --- a/src/xulbux/json.py +++ b/src/xulbux/json.py @@ -3,11 +3,12 @@ create and update JSON files, with support for comments inside the JSON data. """ +from .file_sys import FileSys from .data import Data from .file import File -from .path import Path from typing import Literal, Any, cast +from pathlib import Path import json as _json @@ -18,7 +19,7 @@ class Json: @classmethod def read( cls, - json_file: str, + json_file: Path | str, comment_start: str = ">>", comment_end: str = "<<", return_original: bool = False, @@ -35,10 +36,9 @@ def read( ------------------------------------------------------------------------------------ For more detailed information about the comment handling, see the `Data.remove_comments()` method documentation.""" - if not json_file.endswith(".json"): - json_file += ".json" - if (file_path := Path.extend_or_make(json_file, prefer_script_dir=True)) is None: - raise FileNotFoundError(f"Could not find JSON file: {json_file}") + if (json_path := Path(json_file) if isinstance(json_file, str) else json_file).suffix != ".json": + json_path = json_path.with_suffix(".json") + file_path = FileSys.extend_or_make_path(json_path, prefer_script_dir=True) with open(file_path, "r") as f: content = f.read() @@ -46,22 +46,23 @@ def read( try: data = _json.loads(content) except _json.JSONDecodeError as e: - raise ValueError(f"Error parsing JSON in '{file_path}': {str(e)}") + fmt_error = "\n ".join(str(e).splitlines()) + raise ValueError(f"Error parsing JSON in {file_path!r}:\n {fmt_error}") from e if not (processed_data := dict(Data.remove_comments(data, comment_start, comment_end))): - raise ValueError(f"The JSON file '{file_path}' is empty or contains only comments.") + raise ValueError(f"The JSON file {file_path!r} is empty or contains only comments.") return (processed_data, data) if return_original else processed_data @classmethod def create( cls, - json_file: str, + json_file: Path | str, data: dict, indent: int = 2, compactness: Literal[0, 1, 2] = 1, force: bool = False, - ) -> str: + ) -> Path: """Create a nicely formatted JSON file from a dictionary.\n --------------------------------------------------------------------------- - `json_file` -⠀the path (relative or absolute) to the JSON file to create @@ -75,12 +76,19 @@ def create( The method will throw a `FileExistsError` if a file with the same name already exists and a `SameContentFileExistsError` if a file with the same name and same content already exists.""" - if not json_file.endswith(".json"): - json_file += ".json" + if (json_path := Path(json_file) if isinstance(json_file, str) else json_file).suffix != ".json": + json_path = json_path.with_suffix(".json") + file_path = FileSys.extend_or_make_path(json_path, prefer_script_dir=True) File.create( - file_path=(file_path := Path.extend_or_make(json_file, prefer_script_dir=True)), - content=Data.render(data=data, indent=indent, compactness=compactness, as_json=True), + file_path=file_path, + content=Data.render( + data=data, + indent=indent, + compactness=compactness, + as_json=True, + syntax_highlighting=False, + ), force=force, ) @@ -89,7 +97,7 @@ def create( @classmethod def update( cls, - json_file: str, + json_file: Path | str, update_values: dict[str, Any], comment_start: str = ">>", comment_end: str = "<<", diff --git a/tests/test_env_path.py b/tests/test_env_path.py index 497f3a1..ce71e9a 100644 --- a/tests/test_env_path.py +++ b/tests/test_env_path.py @@ -1,5 +1,7 @@ from xulbux.env_path import EnvPath +from pathlib import Path + # ################################################## EnvPath TESTS ################################################## @@ -9,10 +11,11 @@ def test_get_paths(): paths_list = EnvPath.paths(as_list=True) assert paths assert paths_list - assert isinstance(paths, str) + assert isinstance(paths, Path) assert isinstance(paths_list, list) assert len(paths_list) > 0 - assert isinstance(paths_list[0], str) + assert all(isinstance(path, Path) for path in paths_list) + assert isinstance(paths_list[0], Path) def test_add_path(): diff --git a/tests/test_file.py b/tests/test_file.py index 6760996..4d8d90b 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -1,8 +1,8 @@ from xulbux.base.exceptions import SameContentFileExistsError from xulbux.file import File +from pathlib import Path import pytest -import os # ################################################## File TESTS ################################################## @@ -13,11 +13,11 @@ ("myfile.txt", ".log", False, False, "myfile.log"), ("my_file_name.data", ".csv", False, False, "my_file_name.csv"), ("another-file.json", ".xml", False, False, "another-file.xml"), - ("path/to/myfile.txt", ".md", False, False, os.path.join("path", "to", "myfile.md")), + ("path/to/myfile.txt", ".md", False, False, str(Path("path") / "to" / "myfile.md")), ("my_file_name.data", ".csv", True, False, "MyFileName.csv"), ("another-file.json", ".xml", True, False, "AnotherFile.xml"), ("alreadyCamelCase.config", ".yaml", True, False, "AlreadyCamelCase.yaml"), - (os.path.join("path", "to", "my_file.txt"), ".log", True, False, os.path.join("path", "to", "MyFile.log")), + (Path("path") / "to" / "my_file.txt", ".log", True, False, str(Path("path") / "to" / "MyFile.log")), ("filename", ".ext", False, False, "filename.ext"), ("file_name", ".ext", True, False, "FileName.ext"), ("test_file.blade.php", ".vue", False, False, "test_file.blade.vue"), @@ -26,28 +26,30 @@ ("test_file.blade.php", ".vue", False, True, "test_file.vue"), ("archive.tar.gz", ".zip", False, True, "archive.zip"), ("my_archive.tar.gz", ".zip", True, True, "MyArchive.zip"), - (os.path.join("some", "dir", "file.config.yaml"), ".json", False, True, os.path.join("some", "dir", "file.json")), + (Path("some") / "dir" / "file.config.yaml", ".json", False, True, str(Path("some") / "dir" / "file.json")), ( - os.path.join("some", "dir", "file_name.config.yaml"), + Path("some") / "dir" / "file_name.config.yaml", ".json", True, True, - os.path.join("some", "dir", "FileName.json"), + str(Path("some") / "dir" / "FileName.json"), ), ("nodotfile", ".txt", False, True, "nodotfile.txt"), ("no_dot_file", ".txt", True, True, "NoDotFile.txt"), ] ) def test_rename_extension(input_file, new_extension, full_extension, camel_case, expected_output): - expected_output = expected_output.replace("/", os.sep).replace("\\", os.sep) - assert File.rename_extension(input_file, new_extension, full_extension, camel_case) == expected_output + result = File.rename_extension(input_file, new_extension, full_extension, camel_case) + assert isinstance(result, Path) + assert str(result) == expected_output def test_create_new_file(tmp_path): file_path = tmp_path / "new_file.txt" abs_path = File.create(str(file_path)) - assert os.path.exists(file_path) - assert os.path.abspath(str(file_path)) == abs_path + assert isinstance(abs_path, Path) + assert file_path.exists() + assert abs_path.resolve() == file_path.resolve() with open(file_path, "r", encoding="utf-8") as f: assert f.read() == "" @@ -56,8 +58,9 @@ def test_create_file_with_content(tmp_path): file_path = tmp_path / "content_file.log" content = "This is the file content.\nWith multiple lines." abs_path = File.create(str(file_path), content=content) - assert os.path.exists(file_path) - assert os.path.abspath(str(file_path)) == abs_path + assert isinstance(abs_path, Path) + assert file_path.exists() + assert abs_path.resolve() == file_path.resolve() with open(file_path, "r", encoding="utf-8") as f: assert f.read() == content @@ -87,8 +90,9 @@ def test_create_file_force_overwrite_different_content(tmp_path): assert open(file_path, "r", encoding="utf-8").read() == initial_content abs_path = File.create(str(file_path), content=new_content, force=True) - assert os.path.exists(file_path) - assert os.path.abspath(str(file_path)) == abs_path + assert isinstance(abs_path, Path) + assert file_path.exists() + assert abs_path.resolve() == file_path.resolve() with open(file_path, "r", encoding="utf-8") as f: assert f.read() == new_content @@ -101,8 +105,9 @@ def test_create_file_force_overwrite_same_content(tmp_path): assert open(file_path, "r", encoding="utf-8").read() == content abs_path = File.create(str(file_path), content=content, force=True) - assert os.path.exists(file_path) - assert os.path.abspath(str(file_path)) == abs_path + assert isinstance(abs_path, Path) + assert file_path.exists() + assert abs_path.resolve() == file_path.resolve() with open(file_path, "r", encoding="utf-8") as f: assert f.read() == content @@ -115,9 +120,10 @@ def test_create_file_in_subdirectory(tmp_path): with pytest.raises(FileNotFoundError): File.create(str(file_path), content=content) - os.makedirs(dir_path) + dir_path.mkdir() abs_path = File.create(str(file_path), content=content) - assert os.path.exists(file_path) - assert os.path.abspath(str(file_path)) == abs_path + assert isinstance(abs_path, Path) + assert file_path.exists() + assert abs_path.resolve() == file_path.resolve() with open(file_path, "r", encoding="utf-8") as f: assert f.read() == content diff --git a/tests/test_json.py b/tests/test_json.py index 8ae5e49..0c6df53 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -1,9 +1,9 @@ from xulbux.base.exceptions import SameContentFileExistsError from xulbux.json import Json +from pathlib import Path import pytest import json -import os def create_test_json(tmp_path, filename, data): @@ -133,8 +133,8 @@ def test_read_comment_only_json(tmp_path): def test_create_simple(tmp_path): file_path_str = str(tmp_path / "created.json") created_path = Json.create(file_path_str, SIMPLE_DATA) - assert os.path.exists(created_path) - assert file_path_str == created_path + assert isinstance(created_path, Path) + assert created_path.exists() with open(created_path, "r") as f: data = json.load(f) assert data == SIMPLE_DATA @@ -155,7 +155,9 @@ def test_create_force_false_exists(tmp_path): def test_create_force_false_same_content(tmp_path): + from pathlib import Path file_path = Json.create(f"{tmp_path}/existing_same.json", SIMPLE_DATA, force=False) + assert isinstance(file_path, Path) with pytest.raises(SameContentFileExistsError): Json.create(file_path, SIMPLE_DATA, force=False) diff --git a/tests/test_path.py b/tests/test_path.py index 7ac268f..5ad74ed 100644 --- a/tests/test_path.py +++ b/tests/test_path.py @@ -1,5 +1,5 @@ from xulbux.base.exceptions import PathNotFoundError -from xulbux.path import Path +from xulbux.file_sys import FileSys import tempfile import pytest @@ -49,102 +49,116 @@ def setup_test_environment(tmp_path, monkeypatch): def test_path_cwd(setup_test_environment): - cwd_output = Path.cwd - assert isinstance(cwd_output, str) - assert cwd_output == str(setup_test_environment["cwd"]) + from pathlib import Path as Path + cwd_output = FileSys.cwd + assert isinstance(cwd_output, Path) + assert str(cwd_output) == str(setup_test_environment["cwd"]) def test_path_script_dir(setup_test_environment): - script_dir_output = Path.script_dir - assert isinstance(script_dir_output, str) - assert script_dir_output == str(setup_test_environment["script_dir"]) + from pathlib import Path as Path + script_dir_output = FileSys.script_dir + assert isinstance(script_dir_output, Path) + assert str(script_dir_output) == str(setup_test_environment["script_dir"]) def test_path_home(): - home = Path.home - assert isinstance(home, str) - assert len(home) > 0 - assert os.path.exists(home) - assert os.path.isdir(home) + from pathlib import Path as Path + home = FileSys.home + assert isinstance(home, Path) + assert len(str(home)) > 0 + assert home.exists() + assert home.is_dir() def test_extend(setup_test_environment): + from pathlib import Path as Path env = setup_test_environment search_dir = str(env["search_in"]) search_dirs = [str(env["cwd"]), search_dir] # ABSOLUTE PATH - assert Path.extend(str(env["abs_file"])) == str(env["abs_file"]) + result = FileSys.extend_path(str(env["abs_file"])) + assert isinstance(result, Path) + assert str(result) == str(env["abs_file"]) # EMPTY PATH - assert Path.extend("") is None + assert FileSys.extend_path("") is None with pytest.raises(PathNotFoundError, match="Path is empty."): - Path.extend("", raise_error=True) + FileSys.extend_path("", raise_error=True) # FOUND IN STANDARD LOCATIONS - assert Path.extend("file_in_cwd.txt") == str(env["cwd"] / "file_in_cwd.txt") - assert Path.extend("subdir/file_in_script_subdir.txt") == str(env["script_dir"] / "subdir" / "file_in_script_subdir.txt") - assert Path.extend("file_in_home.txt") == str(env["home"] / "file_in_home.txt") - assert Path.extend("temp_file.tmp") == str(env["temp"] / "temp_file.tmp") + assert str(FileSys.extend_path("file_in_cwd.txt")) == str(env["cwd"] / "file_in_cwd.txt") + assert str(FileSys.extend_path("subdir/file_in_script_subdir.txt") + ) == str(env["script_dir"] / "subdir" / "file_in_script_subdir.txt") + assert str(FileSys.extend_path("file_in_home.txt")) == str(env["home"] / "file_in_home.txt") + assert str(FileSys.extend_path("temp_file.tmp")) == str(env["temp"] / "temp_file.tmp") # FOUND IN search_in - assert Path.extend("custom_file.dat", search_in=search_dir) == str(env["search_in"] / "custom_file.dat") - assert Path.extend("custom_file.dat", search_in=search_dirs) == str(env["search_in"] / "custom_file.dat") + assert str(FileSys.extend_path("custom_file.dat", search_in=search_dir)) == str(env["search_in"] / "custom_file.dat") + assert str(FileSys.extend_path("custom_file.dat", search_in=search_dirs)) == str(env["search_in"] / "custom_file.dat") # NOT FOUND - assert Path.extend("non_existent_file.xyz") is None + assert FileSys.extend_path("non_existent_file.xyz") is None with pytest.raises(PathNotFoundError, match="'non_existent_file.xyz' not found"): - Path.extend("non_existent_file.xyz", raise_error=True) + FileSys.extend_path("non_existent_file.xyz", raise_error=True) # CLOSEST MATCH expected_typo = env["search_in"] / "TypoDir" / "file_in_typo.txt" - assert Path.extend("TypoDir/file_in_typo.txt", search_in=search_dir, use_closest_match=False) == str(expected_typo) - assert Path.extend("TypoDir/file_in_typo.txt", search_in=search_dir, use_closest_match=True) == str(expected_typo) - assert Path.extend("TypoDir/file_in_typx.txt", search_in=search_dir, use_closest_match=True) == str(expected_typo) - assert Path.extend("CompletelyWrong/no_file_here.dat", search_in=search_dir, use_closest_match=True) is None + assert str(FileSys.extend_path("TypoDir/file_in_typo.txt", search_in=search_dir, + use_closest_match=False)) == str(expected_typo) + assert str(FileSys.extend_path("TypoDir/file_in_typo.txt", search_in=search_dir, + use_closest_match=True)) == str(expected_typo) + assert str(FileSys.extend_path("TypoDir/file_in_typx.txt", search_in=search_dir, + use_closest_match=True)) == str(expected_typo) + assert FileSys.extend_path("CompletelyWrong/no_file_here.dat", search_in=search_dir, use_closest_match=True) is None def test_extend_or_make(setup_test_environment): + from pathlib import Path as Path env = setup_test_environment search_dir = str(env["search_in"]) # FOUND - assert Path.extend_or_make("file_in_cwd.txt") == str(env["cwd"] / "file_in_cwd.txt") + result = FileSys.extend_or_make_path("file_in_cwd.txt") + assert isinstance(result, Path) + assert str(result) == str(env["cwd"] / "file_in_cwd.txt") # NOT FOUND - MAKE PATH (PREFER SCRIPT DIR) rel_path_script = "new_dir/new_file.txt" expected_script = env["script_dir"] / rel_path_script - assert Path.extend_or_make(rel_path_script, prefer_script_dir=True) == str(expected_script) + assert str(FileSys.extend_or_make_path(rel_path_script, prefer_script_dir=True)) == str(expected_script) # NOT FOUND - MAKE PATH (PREFER CWD) rel_path_cwd = "another_new_dir/another_new_file.txt" expected_cwd = env["cwd"] / rel_path_cwd - assert Path.extend_or_make(rel_path_cwd, prefer_script_dir=False) == str(expected_cwd) + assert str(FileSys.extend_or_make_path(rel_path_cwd, prefer_script_dir=False)) == str(expected_cwd) # USES CLOSEST MATCH WHEN FINDING expected_typo = env["search_in"] / "TypoDir" / "file_in_typo.txt" - assert Path.extend_or_make("TypoDir/file_in_typx.txt", search_in=search_dir, use_closest_match=True) == str(expected_typo) + assert str(FileSys.extend_or_make_path("TypoDir/file_in_typx.txt", search_in=search_dir, + use_closest_match=True)) == str(expected_typo) # MAKES PATH WHEN CLOSEST MATCH FAILS rel_path_wrong = "VeryWrong/made_up.file" expected_made = env["script_dir"] / rel_path_wrong - assert Path.extend_or_make(rel_path_wrong, search_in=search_dir, use_closest_match=True) == str(expected_made) + assert str(FileSys.extend_or_make_path(rel_path_wrong, search_in=search_dir, use_closest_match=True)) == str(expected_made) def test_remove(tmp_path): # NON-EXISTENT non_existent_path = tmp_path / "does_not_exist" assert not non_existent_path.exists() - Path.remove(str(non_existent_path)) + FileSys.remove(str(non_existent_path)) assert not non_existent_path.exists() - Path.remove(str(non_existent_path), only_content=True) + FileSys.remove(str(non_existent_path), only_content=True) assert not non_existent_path.exists() # FILE REMOVAL file_to_remove = tmp_path / "remove_me.txt" file_to_remove.touch() assert file_to_remove.exists() - Path.remove(str(file_to_remove)) + FileSys.remove(str(file_to_remove)) assert not file_to_remove.exists() # DIRECTORY REMOVAL (FULL) @@ -154,7 +168,7 @@ def test_remove(tmp_path): (dir_to_remove / "subdir").mkdir() (dir_to_remove / "subdir" / "file2.txt").touch() assert dir_to_remove.exists() - Path.remove(str(dir_to_remove)) + FileSys.remove(str(dir_to_remove)) assert not dir_to_remove.exists() # DIRECTORY REMOVAL (ONLY CONTENT) @@ -164,7 +178,7 @@ def test_remove(tmp_path): (dir_to_empty / "subdir").mkdir() (dir_to_empty / "subdir" / "file2.txt").touch() assert dir_to_empty.exists() - Path.remove(str(dir_to_empty), only_content=True) + FileSys.remove(str(dir_to_empty), only_content=True) assert dir_to_empty.exists() assert not list(dir_to_empty.iterdir()) @@ -172,6 +186,6 @@ def test_remove(tmp_path): file_path_content = tmp_path / "file_content.txt" file_path_content.write_text("content") assert file_path_content.exists() - Path.remove(str(file_path_content), only_content=True) + FileSys.remove(str(file_path_content), only_content=True) assert file_path_content.exists() assert file_path_content.read_text() == "content" From e7f95c7aa900334d04813eee29640d19e4b633b7 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Tue, 6 Jan 2026 10:37:51 +0100 Subject: [PATCH 05/11] finish renaming `path` to `file_sys`, rename ambiguous var names & fix failing tests --- pyproject.toml | 2 +- src/xulbux/code.py | 2 +- src/xulbux/env_path.py | 4 +-- src/xulbux/file.py | 4 +-- src/xulbux/file_sys.py | 2 +- src/xulbux/format_codes.py | 2 +- src/xulbux/json.py | 4 +-- tests/test_file.py | 24 +++++++++--------- tests/{test_path.py => test_file_sys.py} | 13 ++++------ tests/test_json.py | 32 ++++++++++++------------ tests/test_metadata_consistency.py | 28 ++++++++++----------- 11 files changed, 57 insertions(+), 60 deletions(-) rename tests/{test_path.py => test_file_sys.py} (95%) diff --git a/pyproject.toml b/pyproject.toml index 60dd4ff..3e03bf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,11 +140,11 @@ testpaths = [ "tests/test_console.py", "tests/test_data.py", "tests/test_env_path.py", + "tests/test_file_sys.py", "tests/test_file.py", "tests/test_format_codes.py", "tests/test_json.py", "tests/test_metadata_consistency.py", - "tests/test_path.py", "tests/test_regex.py", "tests/test_string.py", "tests/test_system.py", diff --git a/src/xulbux/code.py b/src/xulbux/code.py index afbdedf..1971176 100644 --- a/src/xulbux/code.py +++ b/src/xulbux/code.py @@ -104,7 +104,7 @@ def is_js(cls, code: str, funcs: set[str] = {"__", "$t", "$lang"}) -> bool: return True js_score = 0.0 - funcs_pattern = r"(" + "|".join(_rx.escape(f) for f in funcs) + r")" + Regex.brackets("()") + funcs_pattern = r"(" + "|".join(_rx.escape(func) for func in funcs) + r")" + Regex.brackets("()") js_indicators: list[tuple[str, float]] = [ (r"\b(var|let|const)\s+[\w_$]+", 2.0), # JS VARIABLE DECLARATIONS (r"\$[\w_$]+\s*=", 2.0), # jQuery-STYLE VARIABLES diff --git a/src/xulbux/env_path.py b/src/xulbux/env_path.py index 33535f5..b70d125 100644 --- a/src/xulbux/env_path.py +++ b/src/xulbux/env_path.py @@ -85,11 +85,11 @@ def _persistent(cls, path: Path, remove: bool = False) -> None: current_paths = [path for path in current_paths if path.resolve() != path_resolved] else: # ADD THE NEW PATH IF NOT ALREADY PRESENT - if path_resolved not in {p.resolve() for p in current_paths}: + if path_resolved not in {path.resolve() for path in current_paths}: current_paths = [*current_paths, path_resolved] # CONVERT TO STRINGS ONLY FOR SETTING THE ENVIRONMENT VARIABLE - path_strings = [str(p) for p in current_paths] + path_strings = [str(path) for path in current_paths] _os.environ["PATH"] = new_path = _os.pathsep.join(dict.fromkeys(filter(bool, path_strings))) if _sys.platform == "win32": # WINDOWS diff --git a/src/xulbux/file.py b/src/xulbux/file.py index 38f85f1..7621b8d 100644 --- a/src/xulbux/file.py +++ b/src/xulbux/file.py @@ -68,7 +68,7 @@ def create(cls, file_path: Path | str, content: str = "", force: bool = False) - raise SameContentFileExistsError("Already created this file. (nothing changed)") raise FileExistsError("File already exists.") - with open(path, "w", encoding="utf-8") as f: - f.write(content) + with open(path, "w", encoding="utf-8") as file: + file.write(content) return path.resolve() diff --git a/src/xulbux/file_sys.py b/src/xulbux/file_sys.py index 991ddf6..9070dff 100644 --- a/src/xulbux/file_sys.py +++ b/src/xulbux/file_sys.py @@ -76,7 +76,7 @@ def extend_path( if isinstance(search_in, (str, Path)): search_dirs.extend([Path(search_in)]) elif isinstance(search_in, list): - search_dirs.extend([Path(p) for p in search_in]) + search_dirs.extend([Path(path) for path in search_in]) else: raise TypeError( f"The 'search_in' parameter must be a string, Path, or a list of strings/Paths, got {type(search_in)}" diff --git a/src/xulbux/format_codes.py b/src/xulbux/format_codes.py index 66c21b3..62ea6c4 100644 --- a/src/xulbux/format_codes.py +++ b/src/xulbux/format_codes.py @@ -693,7 +693,7 @@ def build_output(self, match: _rx.Match[str]) -> str: """Build the final output string based on processed formats and resets.""" # CHECK IF ALL FORMATS WERE VALID has_single_valid_ansi = len(self.ansi_formats) == 1 and self.ansi_formats[0].count(f"{ANSI.CHAR}{ANSI.START}") >= 1 - all_formats_valid = all(f.startswith(f"{ANSI.CHAR}{ANSI.START}") for f in self.ansi_formats) + all_formats_valid = all(ansi_format.startswith(f"{ANSI.CHAR}{ANSI.START}") for ansi_format in self.ansi_formats) if not has_single_valid_ansi and not all_formats_valid: return match.group(0) diff --git a/src/xulbux/json.py b/src/xulbux/json.py index 4fb91e0..c60bca7 100644 --- a/src/xulbux/json.py +++ b/src/xulbux/json.py @@ -40,8 +40,8 @@ def read( json_path = json_path.with_suffix(".json") file_path = FileSys.extend_or_make_path(json_path, prefer_script_dir=True) - with open(file_path, "r") as f: - content = f.read() + with open(file_path, "r") as file: + content = file.read() try: data = _json.loads(content) diff --git a/tests/test_file.py b/tests/test_file.py index 4d8d90b..0f04aa2 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -50,8 +50,8 @@ def test_create_new_file(tmp_path): assert isinstance(abs_path, Path) assert file_path.exists() assert abs_path.resolve() == file_path.resolve() - with open(file_path, "r", encoding="utf-8") as f: - assert f.read() == "" + with open(file_path, "r", encoding="utf-8") as file: + assert file.read() == "" def test_create_file_with_content(tmp_path): @@ -61,14 +61,14 @@ def test_create_file_with_content(tmp_path): assert isinstance(abs_path, Path) assert file_path.exists() assert abs_path.resolve() == file_path.resolve() - with open(file_path, "r", encoding="utf-8") as f: - assert f.read() == content + with open(file_path, "r", encoding="utf-8") as file: + assert file.read() == content def test_create_file_exists_error(tmp_path): file_path = tmp_path / "existing_file.txt" - with open(file_path, "w", encoding="utf-8") as f: - f.write("Initial content") + with open(file_path, "w", encoding="utf-8") as file: + file.write("Initial content") with pytest.raises(FileExistsError): File.create(str(file_path), content="New content", force=False) @@ -93,8 +93,8 @@ def test_create_file_force_overwrite_different_content(tmp_path): assert isinstance(abs_path, Path) assert file_path.exists() assert abs_path.resolve() == file_path.resolve() - with open(file_path, "r", encoding="utf-8") as f: - assert f.read() == new_content + with open(file_path, "r", encoding="utf-8") as file: + assert file.read() == new_content def test_create_file_force_overwrite_same_content(tmp_path): @@ -108,8 +108,8 @@ def test_create_file_force_overwrite_same_content(tmp_path): assert isinstance(abs_path, Path) assert file_path.exists() assert abs_path.resolve() == file_path.resolve() - with open(file_path, "r", encoding="utf-8") as f: - assert f.read() == content + with open(file_path, "r", encoding="utf-8") as file: + assert file.read() == content def test_create_file_in_subdirectory(tmp_path): @@ -125,5 +125,5 @@ def test_create_file_in_subdirectory(tmp_path): assert isinstance(abs_path, Path) assert file_path.exists() assert abs_path.resolve() == file_path.resolve() - with open(file_path, "r", encoding="utf-8") as f: - assert f.read() == content + with open(file_path, "r", encoding="utf-8") as file: + assert file.read() == content diff --git a/tests/test_path.py b/tests/test_file_sys.py similarity index 95% rename from tests/test_path.py rename to tests/test_file_sys.py index 5ad74ed..74c7053 100644 --- a/tests/test_path.py +++ b/tests/test_file_sys.py @@ -1,6 +1,7 @@ from xulbux.base.exceptions import PathNotFoundError from xulbux.file_sys import FileSys +from pathlib import Path import tempfile import pytest import sys @@ -16,8 +17,8 @@ def setup_test_environment(tmp_path, monkeypatch): mock_temp = tmp_path / "mock_temp" mock_search_in = tmp_path / "mock_search_in" - for p in [mock_cwd, mock_script_dir, mock_home, mock_temp, mock_search_in]: - p.mkdir() + for path in [mock_cwd, mock_script_dir, mock_home, mock_temp, mock_search_in]: + path.mkdir() (mock_cwd / "file_in_cwd.txt").touch() (mock_script_dir / "subdir").mkdir() @@ -30,7 +31,8 @@ def setup_test_environment(tmp_path, monkeypatch): abs_file = mock_cwd / "absolute_file.txt" abs_file.touch() - monkeypatch.setattr(os, "getcwd", lambda: str(mock_cwd)) + monkeypatch.setattr(Path, "cwd", staticmethod(lambda: mock_cwd)) + monkeypatch.setattr(Path, "home", staticmethod(lambda: mock_home)) monkeypatch.setattr(sys.modules["__main__"], "__file__", str(mock_script_dir / "mock_script.py")) monkeypatch.setattr(os.path, "expanduser", lambda path: str(mock_home) if path == "~" else path) monkeypatch.setattr(tempfile, "gettempdir", lambda: str(mock_temp)) @@ -49,21 +51,18 @@ def setup_test_environment(tmp_path, monkeypatch): def test_path_cwd(setup_test_environment): - from pathlib import Path as Path cwd_output = FileSys.cwd assert isinstance(cwd_output, Path) assert str(cwd_output) == str(setup_test_environment["cwd"]) def test_path_script_dir(setup_test_environment): - from pathlib import Path as Path script_dir_output = FileSys.script_dir assert isinstance(script_dir_output, Path) assert str(script_dir_output) == str(setup_test_environment["script_dir"]) def test_path_home(): - from pathlib import Path as Path home = FileSys.home assert isinstance(home, Path) assert len(str(home)) > 0 @@ -72,7 +71,6 @@ def test_path_home(): def test_extend(setup_test_environment): - from pathlib import Path as Path env = setup_test_environment search_dir = str(env["search_in"]) search_dirs = [str(env["cwd"]), search_dir] @@ -115,7 +113,6 @@ def test_extend(setup_test_environment): def test_extend_or_make(setup_test_environment): - from pathlib import Path as Path env = setup_test_environment search_dir = str(env["search_in"]) diff --git a/tests/test_json.py b/tests/test_json.py index 0c6df53..3344e30 100644 --- a/tests/test_json.py +++ b/tests/test_json.py @@ -8,15 +8,15 @@ def create_test_json(tmp_path, filename, data): file_path = tmp_path / filename - with open(file_path, "w") as f: - json.dump(data, f, indent=2) + with open(file_path, "w") as file: + json.dump(data, file, indent=2) return file_path def create_test_json_string(tmp_path, filename, content): file_path = tmp_path / filename - with open(file_path, "w") as f: - f.write(content) + with open(file_path, "w") as file: + file.write(content) return file_path @@ -135,16 +135,16 @@ def test_create_simple(tmp_path): created_path = Json.create(file_path_str, SIMPLE_DATA) assert isinstance(created_path, Path) assert created_path.exists() - with open(created_path, "r") as f: - data = json.load(f) + with open(created_path, "r") as file: + data = json.load(file) assert data == SIMPLE_DATA def test_create_with_indent_compactness(tmp_path): file_path_str = str(tmp_path / "formatted.json") Json.create(file_path_str, SIMPLE_DATA, indent=4, compactness=0) - with open(file_path_str, "r") as f: - content = f.read() + with open(file_path_str, "r") as file: + content = file.read() assert '\n "name":' in content @@ -165,16 +165,16 @@ def test_create_force_false_same_content(tmp_path): def test_create_force_true_exists(tmp_path): file_path = create_test_json(tmp_path, "overwrite.json", {"a": 1}) Json.create(str(file_path), {"b": 2}, force=True) - with open(file_path, "r") as f: - data = json.load(f) + with open(file_path, "r") as file: + data = json.load(file) assert data == {"b": 2} def test_update_existing_values(tmp_path): file_path = create_test_json(tmp_path, "update_test.json", UPDATE_DATA_START) Json.update(str(file_path), UPDATE_VALUES) - with open(file_path, "r") as f: - data = json.load(f) + with open(file_path, "r") as file: + data = json.load(file) assert data == UPDATE_DATA_END @@ -194,14 +194,14 @@ def test_update_with_comments(tmp_path): def test_update_different_path_sep(tmp_path): file_path = create_test_json(tmp_path, "update_sep.json", {"a": {"b": 1}}) Json.update(str(file_path), {"a/b": 2}, path_sep="/") - with open(file_path, "r") as f: - data = json.load(f) + with open(file_path, "r") as file: + data = json.load(file) assert data == {"a": {"b": 2}} def test_update_create_non_existent_path(tmp_path): file_path = create_test_json(tmp_path, "update_create.json", {"existing": 1}) Json.update(str(file_path), {"new->nested->value": "created"}) - with open(file_path, "r") as f: - data = json.load(f) + with open(file_path, "r") as file: + data = json.load(file) assert data == {"existing": 1, "new": {"nested": {"value": "created"}}} diff --git a/tests/test_metadata_consistency.py b/tests/test_metadata_consistency.py index 07bbcd1..056349a 100644 --- a/tests/test_metadata_consistency.py +++ b/tests/test_metadata_consistency.py @@ -47,14 +47,14 @@ def test_version_consistency(): expected_version = branch_match.group(1) # EXTRACT VERSION FROM __init__.py - with open(INIT_PATH, "r", encoding="utf-8") as f: - init_content = f.read() + with open(INIT_PATH, "r", encoding="utf-8") as file: + init_content = file.read() init_version_match = re.search(r'^__version__\s*=\s*"([^"]+)"', init_content, re.MULTILINE) init_version = init_version_match.group(1) if init_version_match else None # EXTRACT VERSION FROM pyproject.toml - with open(PYPROJECT_PATH, "r", encoding="utf-8") as f: - pyproject_data = toml.load(f) + with open(PYPROJECT_PATH, "r", encoding="utf-8") as file: + pyproject_data = toml.load(file) pyproject_version = pyproject_data.get("project", {}).get("version", "") assert init_version is not None, f"Could not find var '__version__' in {INIT_PATH}" @@ -75,8 +75,8 @@ def test_copyright_year(): current_year = datetime.now().year # EXTRACT COPYRIGHT YEAR (SINGLE/RANGE) FROM __init__.py - with open(INIT_PATH, "r", encoding="utf-8") as f: - content = f.read() + with open(INIT_PATH, "r", encoding="utf-8") as file: + content = file.read() init_copyright_range = re.search(r'^__copyright__\s*=\s*"Copyright \(c\) (\d{4})-(\d{4}) .+"', content, re.MULTILINE) init_copyright_single = re.search(r'^__copyright__\s*=\s*"Copyright \(c\) (\d{4}) .+"', content, re.MULTILINE) @@ -105,13 +105,13 @@ def test_copyright_year(): def test_dependencies_consistency(): """Verifies that dependencies in `pyproject.toml` match `__dependencies__` in `__init__.py`.""" # EXTRACT DEPENDENCIES FROM __init__.py - with open(INIT_PATH, "r", encoding="utf-8") as f: - init_content = f.read() + with open(INIT_PATH, "r", encoding="utf-8") as file: + init_content = file.read() init_deps = re.search(r'__dependencies__\s*=\s*\[(.*?)\]', init_content, re.DOTALL) # EXTRACT DEPENDENCIES FROM pyproject.toml - with open(PYPROJECT_PATH, "r", encoding="utf-8") as f: - pyproject_data = toml.load(f) + with open(PYPROJECT_PATH, "r", encoding="utf-8") as file: + pyproject_data = toml.load(file) pyproject_deps = pyproject_data.get("project", {}).get("dependencies", []) assert init_deps is not None, f"Could not find var '__dependencies__' in {INIT_PATH}" @@ -135,14 +135,14 @@ def test_dependencies_consistency(): def test_description_consistency(): """Verifies that the description in `pyproject.toml` matches `__description__` in `__init__.py`.""" # EXTRACT DESCRIPTION FROM __init__.py - with open(INIT_PATH, "r", encoding="utf-8") as f: - init_content = f.read() + with open(INIT_PATH, "r", encoding="utf-8") as file: + init_content = file.read() init_desc_match = re.search(r'^__description__\s*=\s*"([^"]+)"', init_content, re.MULTILINE) init_desc = init_desc_match.group(1) if init_desc_match else None # EXTRACT DESCRIPTION FROM pyproject.toml - with open(PYPROJECT_PATH, "r", encoding="utf-8") as f: - pyproject_data = toml.load(f) + with open(PYPROJECT_PATH, "r", encoding="utf-8") as file: + pyproject_data = toml.load(file) pyproject_desc = pyproject_data.get("project", {}).get("description", "") assert init_desc is not None, f"Could not find var '__description__' in {INIT_PATH}" From d985218a78eba4bc237a154ef7309464baf8e32f Mon Sep 17 00:00:00 2001 From: XulbuX Date: Tue, 6 Jan 2026 11:06:57 +0100 Subject: [PATCH 06/11] make `mypyc` compilation optional, if no matching built version is found on lib install --- .github/workflows/build-and-publish.yml | 12 ++++++++++++ pyproject.toml | 7 +------ setup.py | 20 +++++++++++++++++--- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 906dd98..3472b57 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -29,6 +29,18 @@ jobs: env: CIBW_BUILD: cp310-* cp311-* cp312-* cp313-* cp314-* CIBW_SKIP: "*-musllinux_*" + CIBW_BEFORE_BUILD: pip install mypy>=1.19.0 mypy-extensions>=1.1.0 types-regex types-keyboard prompt_toolkit>=3.0.41 + CIBW_ENVIRONMENT: XULBUX_USE_MYPYC=1 + + - name: Verify wheels were built + run: | + ls -la ./wheelhouse/ + if [ -z "$(ls -A ./wheelhouse/)" ]; then + echo "[ERROR] No wheels were built!" + exit 1 + fi + echo "[SUCCESS] Built $(ls ./wheelhouse/*.whl | wc -l) wheels." + shell: bash - uses: actions/upload-artifact@v6 with: diff --git a/pyproject.toml b/pyproject.toml index 3e03bf3..0163779 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,12 +2,7 @@ requires = [ "setuptools>=80.0.0", "wheel>=0.45.0", - "mypy>=1.19.0", - "mypy-extensions>=1.1.0", - # TYPES FOR MyPy - "types-regex", - "types-keyboard", - "prompt_toolkit>=3.0.41", + # OTHER BUILD-DEPS SPECIFIED IN CIBW_BEFORE_BUILD IN .github/workflows/build-and-publish.yml ] build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index 1984e31..b0d54fe 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ -from mypyc.build import mypycify from setuptools import setup from pathlib import Path +import os def find_python_files(directory: str) -> list[str]: @@ -10,9 +10,23 @@ def find_python_files(directory: str) -> list[str]: return python_files -source_files = find_python_files("src/xulbux") +# OPTIONALLY USE MYPYC COMPILATION +ext_modules = [] +if os.environ.get("XULBUX_USE_MYPYC", "1") == "1": + try: + from mypyc.build import mypycify + print("\n\nCompiling with mypyc...\n") + source_files = find_python_files("src/xulbux") + ext_modules = mypycify(source_files) + + except (ImportError, Exception) as e: + fmt_error = "\n ".join(str(e).splitlines()) + print( + f"[WARNING] mypyc compilation disabled (not available or failed):\n {fmt_error}" + "\n\nInstalling as pure Python package...\n" + ) setup( name="xulbux", - ext_modules=mypycify(source_files), + ext_modules=ext_modules, ) From e85d5cb8f794b65cf2ba65d1dfea6988bd8740a2 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Tue, 6 Jan 2026 20:40:07 +0100 Subject: [PATCH 07/11] fix release date & fix `C901 too complex` --- .github/workflows/build-and-publish.yml | 4 + CHANGELOG.md | 3 +- setup.py | 6 +- src/xulbux/__init__.py | 2 +- src/xulbux/file_sys.py | 161 +++++++++++++++--------- tests/test_file_sys.py | 22 ++-- tests/test_metadata_consistency.py | 32 ----- 7 files changed, 121 insertions(+), 109 deletions(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 3472b57..a509ce8 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -1,5 +1,9 @@ # THIS WORKFLOW WILL BUILD WHEELS FOR ALL MAJOR PLATFORMS AND UPLOAD THEM TO PYPI +# TO BUILD AND INSTALL LOCALLY FOR TESTING, RUN THE FOLLOWING COMMAND: +# pip install "/path/to/PythonLibraryXulbuX" --no-deps --no-cache-dir --force-reinstall --no-build-isolation + +# TO CREATE A NEW RELEASE, TAG A COMMIT WITH THE FOLLOWING FORMAT: # git tag v1.X.Y # git push origin v1.X.Y diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c06d7..1418d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ -## ... `v1.9.4` +## 06.01.2026 `v1.9.4` * Added a new base module `base.decorators` which contains custom decorators used throughout the library. * Made `mypy_extensions` an optional dependency by wrapping all uses of `mypy_extensions.mypyc_attr` in a custom decorator that acts as a no-op if `mypy_extensions` is not installed. @@ -29,6 +29,7 @@ * Renamed most `FileSys` methods to better describe their functionality: - `Path.extend()` is now `FileSys.extend_path()` - `Path.extend_or_make()` is now `FileSys.extend_or_make_path()` +* Renamed the param `use_closest_match` in `FileSys.extend_path()` and `FileSys.extend_or_make_path()` to `fuzzy_match`, since that name is more commonly used for that functionality. * Updated all library methods that work with paths to accept `pathlib.Path` objects additionally to strings, as path inputs. * Also, all library methods that return paths now return `pathlib.Path` objects instead of strings. diff --git a/setup.py b/setup.py index b0d54fe..5761a66 100644 --- a/setup.py +++ b/setup.py @@ -15,15 +15,15 @@ def find_python_files(directory: str) -> list[str]: if os.environ.get("XULBUX_USE_MYPYC", "1") == "1": try: from mypyc.build import mypycify - print("\n\nCompiling with mypyc...\n") + print("\nCompiling with mypyc...\n") source_files = find_python_files("src/xulbux") ext_modules = mypycify(source_files) except (ImportError, Exception) as e: fmt_error = "\n ".join(str(e).splitlines()) print( - f"[WARNING] mypyc compilation disabled (not available or failed):\n {fmt_error}" - "\n\nInstalling as pure Python package...\n" + f"\n[WARNING] mypyc compilation disabled (not available or failed):\n {fmt_error}\n" + "\nInstalling as pure Python package...\n" ) setup( diff --git a/src/xulbux/__init__.py b/src/xulbux/__init__.py index b75d828..2fd5608 100644 --- a/src/xulbux/__init__.py +++ b/src/xulbux/__init__.py @@ -8,7 +8,7 @@ __author__ = "XulbuX" __email__ = "xulbux.real@gmail.com" __license__ = "MIT" -__copyright__ = "Copyright (c) 2024-2026 XulbuX" +__copyright__ = "Copyright (c) 2024 XulbuX" __requires_python__ = ">=3.10.0" __dependencies__ = [ diff --git a/src/xulbux/file_sys.py b/src/xulbux/file_sys.py index 9070dff..f5ef031 100644 --- a/src/xulbux/file_sys.py +++ b/src/xulbux/file_sys.py @@ -53,24 +53,38 @@ def extend_path( cls, rel_path: Path | str, search_in: Optional[Path | str | PathsList] = None, + fuzzy_match: bool = False, raise_error: bool = False, - use_closest_match: bool = False, ) -> Optional[Path]: """Tries to resolve and extend a relative path to an absolute path.\n ------------------------------------------------------------------------------------------- - `rel_path` -⠀the relative path to extend - `search_in` -⠀a directory or a list of directories to search in, in addition to the predefined directories (see exact procedure below) + - `fuzzy_match` -⠀if true, it will try to find the closest matching file/folder + names in the `search_in` directories, allowing for typos in `rel_path` and `search_in` - `raise_error` -⠀if true, raises a `PathNotFoundError` if - the path couldn't be found (otherwise it returns `None`) - - `use_closest_match` -⠀if true, it will try to find the closest matching file/folder - names in the `search_in` directories, allowing for typos in `rel_path` and `search_in`\n + the path couldn't be found (otherwise it returns `None`)\n ------------------------------------------------------------------------------------------- If the `rel_path` couldn't be located in predefined directories, it will be searched in the `search_in` directory/s.
If the `rel_path` is still not found, it returns `None` or raises a `PathNotFoundError` if `raise_error` is true.""" search_dirs: list[Path] = [] + path: Path + + if isinstance(rel_path, str): + if rel_path == "": + if raise_error: + raise PathNotFoundError("Given 'rel_path' is an empty string.") + return None + else: + path = Path(rel_path) + else: + path = rel_path + + if path.is_absolute(): + return path if search_in is not None: if isinstance(search_in, (str, Path)): @@ -82,45 +96,13 @@ def extend_path( f"The 'search_in' parameter must be a string, Path, or a list of strings/Paths, got {type(search_in)}" ) - # CONVERT rel_path TO PATH - path = Path(str(rel_path)) if rel_path else None - - if not path or str(path) == "": - if raise_error: - raise PathNotFoundError("Path is empty.") - else: - return None - - # IF ALREADY ABSOLUTE, RETURN IT - if path.is_absolute(): - return path - - # EXPAND ENVIRONMENT VARIABLES AND NORMALIZE - path = Path(cls._expand_env_path(str(path))) - - if path.is_absolute(): - # SPLIT DRIVE AND PATH ON WINDOWS - if path.drive: - search_dirs.extend([Path(path.drive + _os.sep)]) - path = Path(*path.parts[1:]) # REMOVE DRIVE FROM PARTS - else: - search_dirs.extend([Path(_os.sep)]) - path = Path(*path.parts[1:]) # REMOVE ROOT FROM PARTS - else: - search_dirs.extend([Path.cwd(), cls.script_dir, Path.home(), Path(_tempfile.gettempdir())]) - - for search_dir in search_dirs: - full_path = search_dir / path - if full_path.exists(): - return full_path - if use_closest_match: - if (match := cls._find_path(search_dir, path.parts, use_closest_match)): - return match - - if raise_error: - raise PathNotFoundError(f"Path {rel_path!r} not found in specified directories.") - else: - return None + return _ExtendPathHelper( + cls, + rel_path=path, + search_dirs=search_dirs, + fuzzy_match=fuzzy_match, + raise_error=raise_error, + )() @classmethod def extend_or_make_path( @@ -128,7 +110,7 @@ def extend_or_make_path( rel_path: Path | str, search_in: Optional[Path | str | list[Path | str]] = None, prefer_script_dir: bool = True, - use_closest_match: bool = False, + fuzzy_match: bool = False, ) -> Path: """Tries to locate and extend a relative path to an absolute path, and if the `rel_path` couldn't be located, it generates a path, as if it was located.\n @@ -138,7 +120,7 @@ def extend_or_make_path( in addition to the predefined directories (see exact procedure below) - `prefer_script_dir` -⠀if true, the script directory is preferred when making a new path (otherwise the CWD is preferred) - - `use_closest_match` -⠀if true, it will try to find the closest matching file/folder + - `fuzzy_match` -⠀if true, it will try to find the closest matching file/folder names in the `search_in` directories, allowing for typos in `rel_path` and `search_in`\n ------------------------------------------------------------------------------------------- If the `rel_path` couldn't be located in predefined directories, @@ -153,7 +135,7 @@ def extend_or_make_path( rel_path=rel_path, search_in=search_in, raise_error=True, - use_closest_match=use_closest_match, + fuzzy_match=fuzzy_match, ) return result if result is not None else Path() @@ -189,35 +171,92 @@ def remove(cls, path: Path | str, only_content: bool = False) -> None: fmt_error = "\n ".join(str(e).splitlines()) raise Exception(f"Failed to delete {item!r}:\n {fmt_error}") from e + +class _ExtendPathHelper: + """Internal, callable helper class to extend a relative path to an absolute path.""" + + def __init__( + self, + cls: type[FileSys], + rel_path: Path, + search_dirs: list[Path], + fuzzy_match: bool, + raise_error: bool, + ): + self.cls = cls + self.rel_path = rel_path + self.search_dirs = search_dirs + self.fuzzy_match = fuzzy_match + self.raise_error = raise_error + + def __call__(self) -> Optional[Path]: + """Execute the path extension logic.""" + expanded_path = self.expand_env_vars(self.rel_path) + + if expanded_path.is_absolute(): + # ADD ROOT TO SEARCH DIRS + if expanded_path.drive: + self.search_dirs.extend([Path(expanded_path.drive + _os.sep)]) + else: + self.search_dirs.extend([Path(_os.sep)]) + # REMOVE ROOT FROM PATH PARTS FOR SEARCHING + expanded_path = Path(*expanded_path.parts[1:]) + else: + # ADD PREDEFINED SEARCH DIRS + self.search_dirs.extend([ + self.cls.cwd, + self.cls.home, + self.cls.script_dir, + Path(_tempfile.gettempdir()), + ]) + + return self.search_in_dirs(expanded_path) + @staticmethod - def _expand_env_path(path_str: str) -> str: - """Internal method that expands all environment variables in the given path string.""" - if "%" not in path_str: - return path_str + def expand_env_vars(path: Path) -> Path: + """Expand all environment variables in the given path.""" + if "%" not in (str_path := str(path)): + return path - for i in range(1, len(parts := path_str.split("%")), 2): + for i in range(1, len(parts := str_path.split("%")), 2): if parts[i].upper() in _os.environ: parts[i] = _os.environ[parts[i].upper()] - return "".join(parts) + return Path("".join(parts)) - @classmethod - def _find_path(cls, start_dir: Path, path_parts: tuple[str, ...], use_closest_match: bool) -> Optional[Path]: - """Internal method to find a path by traversing the given parts from - the start directory, optionally using closest matches for each part.""" - current_path: Path = start_dir + def search_in_dirs(self, path: Path) -> Optional[Path]: + """Search for the path in all configured directories.""" + for search_dir in self.search_dirs: + if (full_path := search_dir / path).exists(): + return full_path + elif self.fuzzy_match: + if (match := self.find_path( \ + base_dir=search_dir, + target_path=path, + fuzzy_match=self.fuzzy_match, + )) is not None: + return match + + if self.raise_error: + raise PathNotFoundError(f"Path {self.rel_path!r} not found in specified directories.") + return None + + def find_path(self, base_dir: Path, target_path: Path, fuzzy_match: bool) -> Optional[Path]: + """Find a path by traversing the given parts from the base directory, + optionally using closest matches for each part.""" + current_path: Path = base_dir - for part in path_parts: + for part in target_path.parts: if current_path.is_file(): return current_path - if (closest_match := cls._get_closest_match(current_path, part) if use_closest_match else part) is None: + elif (closest_match := self.get_closest_match(current_path, part) if fuzzy_match else part) is None: return None current_path = current_path / closest_match - return current_path if current_path.exists() and current_path != start_dir else None + return current_path if current_path.exists() and current_path != base_dir else None @staticmethod - def _get_closest_match(dir: Path, path_part: str) -> Optional[str]: + def get_closest_match(dir: Path, path_part: str) -> Optional[str]: """Internal method to get the closest matching file or folder name in the given directory for the given path part.""" try: diff --git a/tests/test_file_sys.py b/tests/test_file_sys.py index 74c7053..0444ee8 100644 --- a/tests/test_file_sys.py +++ b/tests/test_file_sys.py @@ -82,7 +82,7 @@ def test_extend(setup_test_environment): # EMPTY PATH assert FileSys.extend_path("") is None - with pytest.raises(PathNotFoundError, match="Path is empty."): + with pytest.raises(PathNotFoundError, match="Given 'rel_path' is an empty string."): FileSys.extend_path("", raise_error=True) # FOUND IN STANDARD LOCATIONS @@ -98,18 +98,18 @@ def test_extend(setup_test_environment): # NOT FOUND assert FileSys.extend_path("non_existent_file.xyz") is None - with pytest.raises(PathNotFoundError, match="'non_existent_file.xyz' not found"): + with pytest.raises( \ + PathNotFoundError, + match=r"Path [A-Za-z]*Path\('non_existent_file\.xyz'\) not found in specified directories\.", + ): FileSys.extend_path("non_existent_file.xyz", raise_error=True) # CLOSEST MATCH expected_typo = env["search_in"] / "TypoDir" / "file_in_typo.txt" - assert str(FileSys.extend_path("TypoDir/file_in_typo.txt", search_in=search_dir, - use_closest_match=False)) == str(expected_typo) - assert str(FileSys.extend_path("TypoDir/file_in_typo.txt", search_in=search_dir, - use_closest_match=True)) == str(expected_typo) - assert str(FileSys.extend_path("TypoDir/file_in_typx.txt", search_in=search_dir, - use_closest_match=True)) == str(expected_typo) - assert FileSys.extend_path("CompletelyWrong/no_file_here.dat", search_in=search_dir, use_closest_match=True) is None + assert str(FileSys.extend_path("TypoDir/file_in_typo.txt", search_in=search_dir, fuzzy_match=False)) == str(expected_typo) + assert str(FileSys.extend_path("TypoDir/file_in_typo.txt", search_in=search_dir, fuzzy_match=True)) == str(expected_typo) + assert str(FileSys.extend_path("TypoDir/file_in_typx.txt", search_in=search_dir, fuzzy_match=True)) == str(expected_typo) + assert FileSys.extend_path("CompletelyWrong/no_file_here.dat", search_in=search_dir, fuzzy_match=True) is None def test_extend_or_make(setup_test_environment): @@ -134,12 +134,12 @@ def test_extend_or_make(setup_test_environment): # USES CLOSEST MATCH WHEN FINDING expected_typo = env["search_in"] / "TypoDir" / "file_in_typo.txt" assert str(FileSys.extend_or_make_path("TypoDir/file_in_typx.txt", search_in=search_dir, - use_closest_match=True)) == str(expected_typo) + fuzzy_match=True)) == str(expected_typo) # MAKES PATH WHEN CLOSEST MATCH FAILS rel_path_wrong = "VeryWrong/made_up.file" expected_made = env["script_dir"] / rel_path_wrong - assert str(FileSys.extend_or_make_path(rel_path_wrong, search_in=search_dir, use_closest_match=True)) == str(expected_made) + assert str(FileSys.extend_or_make_path(rel_path_wrong, search_in=search_dir, fuzzy_match=True)) == str(expected_made) def test_remove(tmp_path): diff --git a/tests/test_metadata_consistency.py b/tests/test_metadata_consistency.py index 056349a..0dda227 100644 --- a/tests/test_metadata_consistency.py +++ b/tests/test_metadata_consistency.py @@ -67,38 +67,6 @@ def test_version_consistency(): f"Hardcoded lib-version in pyproject.toml ({pyproject_version}) does not match branch version ({expected_version})" -################################################## COPYRIGHT YEAR TEST ################################################## - - -def test_copyright_year(): - """Verifies that the copyright year in `__init__.py` ends with the current year.""" - current_year = datetime.now().year - - # EXTRACT COPYRIGHT YEAR (SINGLE/RANGE) FROM __init__.py - with open(INIT_PATH, "r", encoding="utf-8") as file: - content = file.read() - init_copyright_range = re.search(r'^__copyright__\s*=\s*"Copyright \(c\) (\d{4})-(\d{4}) .+"', content, re.MULTILINE) - init_copyright_single = re.search(r'^__copyright__\s*=\s*"Copyright \(c\) (\d{4}) .+"', content, re.MULTILINE) - - if init_copyright_range: - start_year = int(init_copyright_range.group(1)) - end_year = int(init_copyright_range.group(2)) - - assert end_year == current_year, \ - f"Copyright end year in src/xulbux/__init__.py ({end_year}) does not match current year ({current_year})" - assert start_year <= end_year, \ - f"Copyright start year ({start_year}) is greater than end year ({end_year}) in src/xulbux/__init__.py" - - elif init_copyright_single: - year = int(init_copyright_single.group(1)) - - assert year == current_year, \ - f"Copyright year in src/xulbux/__init__.py ({year}) does not match current year ({current_year})" - - else: - pytest.fail(f"Could not find var '__copyright__' with valid year format in {INIT_PATH}") - - ################################################## DEPENDENCIES CONSISTENCY TEST ################################################## From 63581eb24f4544c5571502db5cce7a88bc76cd27 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Tue, 6 Jan 2026 20:44:48 +0100 Subject: [PATCH 08/11] fix linting `E226` and `F401` --- src/xulbux/data.py | 4 ++-- tests/test_metadata_consistency.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/xulbux/data.py b/src/xulbux/data.py index 801c3ff..06d1c81 100644 --- a/src/xulbux/data.py +++ b/src/xulbux/data.py @@ -318,13 +318,13 @@ def get_value_by_path_id(cls, data: DataStructure, path_id: str, get_key: bool = elif isinstance(current_data, IndexIterableTypes): if i == len(path) - 1 and get_key: if parent is None or not isinstance(parent, dict): - raise ValueError(f"Cannot get key from a non-dict parent at path '{path[:i+1]}'") + raise ValueError(f"Cannot get key from a non-dict parent at path '{path[:i + 1]}'") return next(key for key, value in parent.items() if value is current_data) parent = current_data current_data = list(current_data)[path_idx] # CONVERT TO LIST FOR INDEXING else: - raise TypeError(f"Unsupported type '{type(current_data)}' at path '{path[:i+1]}'") + raise TypeError(f"Unsupported type '{type(current_data)}' at path '{path[:i + 1]}'") return current_data diff --git a/tests/test_metadata_consistency.py b/tests/test_metadata_consistency.py index 0dda227..8c0cde9 100644 --- a/tests/test_metadata_consistency.py +++ b/tests/test_metadata_consistency.py @@ -1,5 +1,4 @@ from typing import Optional -from datetime import datetime from pathlib import Path import subprocess import pytest From 50f071768b2f91c4af28b303e94c0ccc161f9ab6 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Tue, 6 Jan 2026 20:56:13 +0100 Subject: [PATCH 09/11] fix `build-and-publish` GitHub Actions workflow --- .github/workflows/build-and-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index a509ce8..1cbcc50 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -34,6 +34,7 @@ jobs: CIBW_BUILD: cp310-* cp311-* cp312-* cp313-* cp314-* CIBW_SKIP: "*-musllinux_*" CIBW_BEFORE_BUILD: pip install mypy>=1.19.0 mypy-extensions>=1.1.0 types-regex types-keyboard prompt_toolkit>=3.0.41 + CIBW_BUILD_FRONTEND: "pip; args: --no-build-isolation" CIBW_ENVIRONMENT: XULBUX_USE_MYPYC=1 - name: Verify wheels were built From e1665eab71a5fe8e3feb92212f84ce11d352bdc1 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Tue, 6 Jan 2026 21:08:23 +0100 Subject: [PATCH 10/11] fix `build-and-publish` GitHub Actions workflow (2) --- .github/workflows/build-and-publish.yml | 5 ++++- pyproject.toml | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 1cbcc50..1c0ffc6 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -6,6 +6,9 @@ # TO CREATE A NEW RELEASE, TAG A COMMIT WITH THE FOLLOWING FORMAT: # git tag v1.X.Y # git push origin v1.X.Y +# IF THE TAG v1.X.Y ALREADY EXISTS, RUN THE FOLLOWING COMMANDS FIRST: +# git tag -d v1.X.Y +# git push origin :refs/tags/v1.X.Y name: Build and Publish permissions: @@ -33,7 +36,7 @@ jobs: env: CIBW_BUILD: cp310-* cp311-* cp312-* cp313-* cp314-* CIBW_SKIP: "*-musllinux_*" - CIBW_BEFORE_BUILD: pip install mypy>=1.19.0 mypy-extensions>=1.1.0 types-regex types-keyboard prompt_toolkit>=3.0.41 + CIBW_BEFORE_BUILD: pip install setuptools>=80.0.0 wheel>=0.45.0 mypy>=1.19.0 mypy-extensions>=1.1.0 types-regex types-keyboard prompt_toolkit>=3.0.41 CIBW_BUILD_FRONTEND: "pip; args: --no-build-isolation" CIBW_ENVIRONMENT: XULBUX_USE_MYPYC=1 diff --git a/pyproject.toml b/pyproject.toml index 0163779..cd43574 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,14 @@ [build-system] +# SAME BUILD-DEPS ALSO NEED TO BE SPECIFIED IN CIBW_BEFORE_BUILD IN .github/workflows/build-and-publish.yml requires = [ "setuptools>=80.0.0", "wheel>=0.45.0", - # OTHER BUILD-DEPS SPECIFIED IN CIBW_BEFORE_BUILD IN .github/workflows/build-and-publish.yml + "mypy>=1.19.0", + "mypy-extensions>=1.1.0", + # TYPES FOR MyPy + "types-regex", + "types-keyboard", + "prompt_toolkit>=3.0.41", ] build-backend = "setuptools.build_meta" From 3628e7fe4f0326467c8b0c01c00a9e30ced400c8 Mon Sep 17 00:00:00 2001 From: XulbuX Date: Tue, 6 Jan 2026 21:28:52 +0100 Subject: [PATCH 11/11] name `upload_pypi` step in `build-and-publish` GitHub Actions workflow --- .github/workflows/build-and-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 1c0ffc6..c93f6b5 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -70,6 +70,7 @@ jobs: path: dist/*.tar.gz upload_pypi: + name: Upload to PyPI needs: [build_wheels, build_sdist] runs-on: ubuntu-latest if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')