# -*- coding: UTF-8 -*-
'''
Module
files.py
Copyright
Copyright (C) 2017 - 2026 Vladimir Roncevic <elektron.ronca@gmail.com>
ats_utilities is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
ats_utilities is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see <http://www.gnu.org/licenses/>.
Info
Defines factory file utility functions.
'''
from __future__ import annotations
from collections.abc import Sequence, Mapping
from fnmatch import fnmatch
from pathlib import Path, PurePosixPath
from re import compile, escape, Match, IGNORECASE
from ats_utilities.exceptions import ATSValueError
from ats_utilities.validation.context_error import raise_error
from ats_utilities.validation.check_type import istype
__author__ = r'Vladimir Roncevic'
__copyright__ = r'(C) 2026, https://vroncevic.github.io/ats_utilities'
__credits__ = [r'Vladimir Roncevic', r'Python Software Foundation']
__license__ = r'https://github.com/vroncevic/ats_utilities/blob/dev/LICENSE'
__version__ = r'3.4.3'
__maintainer__ = r'Vladimir Roncevic'
__email__ = r'elektron.ronca@gmail.com'
__status__ = r'Development'
[docs]
def check_file_exists(
file_path: str,
exc_message: str | None = None,
exception_class: type[BaseException] = ATSValueError
) -> None:
'''
Checks if a file exists.
:param file_path: Path to the file.
:type file_path: <str>
:param exc_message: Message to include in the exception message.
:type exc_message: <str | None>
:param exception_class: The exception class to raise if value is None.
:type exception_class: <type[Exception]> (default ATSValueError)
:exceptions:
| ATSTypeError: Parameter type validation failed.
| Dynamically raises the provided exception_class (e.g., ATSValueError).
'''
if not file_path:
raise_error(
fallback_prefix=r'factory_file_utils::check_file_exists',
fallback_msg=r'file path must be provided',
exc_message=exc_message,
exception_class=exception_class,
depth=3
)
istype(file_path, str, exc_message)
if not Path(file_path).exists():
raise_error(
fallback_prefix=r'factory_file_utils::check_file_exists',
fallback_msg=f'file at the provided path does not exist: {file_path}',
exc_message=exc_message,
exception_class=exception_class,
depth=3
)
[docs]
def normalize_path(
file_path: str,
exc_message: str | None = None,
exception_class: type[BaseException] = ATSValueError
) -> str:
'''
Normalizes file paths and strips leading directory prefixes.
:param file_path: The original path to clean up.
:type file_path: <str>
:param exc_message: Message to include in the exception message.
:type exc_message: <str | None>
:param exception_class: The exception class to raise if file_path is None.
:type exception_class: <type[Exception]> (default ATSValueError)
:return: The cleaned up relative path.
:rtype: <str>
:exceptions:
| ATSTypeError: Parameter type validation failed.
| Dynamically raises the provided exception_class (e.g., ATSValueError).
'''
istype(file_path, str, exc_message)
if not file_path:
raise_error(
fallback_prefix=r'factory_file_utils::normalize_path',
fallback_msg=r'file path must be provided',
exc_message=exc_message,
exception_class=exception_class,
depth=3
)
path_obj = PurePosixPath(file_path.replace('\\', '/'))
if path_obj.drive:
path_obj = PurePosixPath(*path_obj.parts[1:])
clean_file_path = path_obj.as_posix()
if clean_file_path.startswith('/'):
clean_file_path = clean_file_path[1:]
return clean_file_path
[docs]
def resolve_relative_path(
normalized_name: str,
source_dir_clean: str,
exc_message: str | None = None,
exception_class: type[BaseException] = ATSValueError
) -> str | None:
'''
Calculates relative path to the specified source directory.
:param normalized_name: The cleaned name of the archive member.
:type normalized_name: <str>
:param source_dir_clean: Cleaned source directory name.
:type source_dir_clean: <str>
:param exc_message: Message to include in the exception message.
:type exc_message: <str | None>
:param exception_class: The exception class to raise if value is None.
:type exception_class: <type[Exception]> (default ATSValueError)
:return: The relative path inside the source dir, or None if not matching.
:rtype: <str | None>
:exceptions:
| ATSTypeError: Parameter type validation failed.
| Dynamically raises the provided exception_class (e.g., ATSValueError).
'''
istype(normalized_name, str, exc_message)
istype(source_dir_clean, str, exc_message)
if not normalized_name:
raise_error(
fallback_prefix=r'factory_file_utils::resolve_relative_path',
fallback_msg=r'normalized_name must be provided',
exc_message=exc_message,
exception_class=exception_class,
depth=3
)
if not source_dir_clean:
raise_error(
fallback_prefix=r'factory_file_utils::resolve_relative_path',
fallback_msg=r'source_dir_clean must be provided',
exc_message=exc_message,
exception_class=exception_class,
depth=3
)
if normalized_name == source_dir_clean:
return ''
try:
target = PurePosixPath(normalized_name)
base = PurePosixPath(source_dir_clean)
return target.relative_to(base).as_posix()
except ValueError:
return None
[docs]
def is_excluded_path(
rel_path: str,
exclude_patterns: Sequence[str],
exc_message: str | None = None,
exception_class: type[BaseException] = ATSValueError
) -> bool:
'''
Checks if a relative path matches any exclusion patterns.
:param rel_path: The relative path to inspect.
:type rel_path: <str>
:param exclude_patterns: Sequence of glob patterns to exclude.
:type exclude_patterns: <Sequence[str]>
:param exc_message: Message to include in the exception message.
:type exc_message: <str | None>
:param exception_class: The exception class to raise if value is None.
:type exception_class: <type[Exception]> (default ATSValueError)
:return: True if the path should be excluded, False otherwise.
:rtype: <bool>
:exceptions:
| ATSTypeError: Parameter type validation failed.
| Dynamically raises the provided exception_class (e.g., ATSValueError).
'''
istype(rel_path, str, exc_message)
istype(exclude_patterns, Sequence, exc_message)
if not rel_path:
raise_error(
fallback_prefix=r'factory_file_utils::is_excluded_path',
fallback_msg=r'rel_path must be provided',
exc_message=exc_message,
exception_class=exception_class,
depth=3
)
if not exclude_patterns:
raise_error(
fallback_prefix=r'factory_file_utils::is_excluded_path',
fallback_msg=r'exclude_patterns must be provided',
exc_message=exc_message,
exception_class=exception_class,
depth=3
)
path_obj = Path(rel_path)
parts = path_obj.parts
for pattern in exclude_patterns:
posix_path = path_obj.as_posix()
if fnmatch(posix_path, pattern) or any(fnmatch(part, pattern) for part in parts):
return True
return False
[docs]
def write_content(
file_path: str,
content: str | bytes,
exc_message: str | None = None,
exception_class: type[BaseException] = ATSValueError
) -> None:
'''
Writes string or bytes content to a file.
:param file_path: Path to the target file.
:type file_path: <str>
:param content: Text string or raw bytes to write.
:type content: <str | bytes>
:param exc_message: Message to include in the exception message.
:type exc_message: <str | None>
:param exception_class: The exception class to raise if value is None.
:type exception_class: <type[Exception]> (default ATSValueError)
:exceptions:
| ATSTypeError: Parameter type validation failed.
| Dynamically raises the provided exception_class (e.g., ATSValueError).
'''
istype(file_path, str, exc_message)
istype(content, (str, bytes), exc_message)
if not file_path:
raise_error(
fallback_prefix=r'factory_file_utils::write_content',
fallback_msg=r'file_path must be provided',
exc_message=exc_message,
exception_class=exception_class,
depth=3
)
if not content:
raise_error(
fallback_prefix=r'factory_file_utils::write_content',
fallback_msg=r'content must be provided',
exc_message=exc_message,
exception_class=exception_class,
depth=3
)
target_path = Path(file_path)
target_path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(content, str):
target_path.write_text(content, encoding='utf-8')
else:
target_path.write_bytes(content)
[docs]
def apply_path_replacements(
rel_path: str,
path_replacements: Mapping[str, str],
vals: Mapping[str, str],
exc_message: str | None = None,
exception_class: type[BaseException] = ATSValueError
) -> str:
'''
Applies path replacements to a relative path using casing heuristics.
:param rel_path: The original relative path.
:type rel_path: <str>
:param path_replacements: String replacements mapping.
:type path_replacements: <Mapping[str, str]>
:param vals: Computed template values.
:type vals: <Mapping[str, str]>
:param exc_message: Message to include in the exception message.
:type exc_message: <str | None>
:param exception_class: The exception class to raise if value is None.
:type exception_class: <type[Exception]> (default ATSValueError)
:return: The replaced relative path.
:rtype: <str>
:exceptions:
| ATSTypeError: Parameter type validation failed.
| Dynamically raises the provided exception_class (e.g., ATSValueError).
'''
istype(rel_path, str, exc_message)
istype(path_replacements, Mapping, exc_message)
istype(vals, Mapping, exc_message)
if not rel_path:
raise_error(
fallback_prefix=r'factory_file_utils::apply_path_replacements',
fallback_msg=r'rel_path must be provided',
exc_message=exc_message,
exception_class=exception_class,
depth=3
)
if not path_replacements:
raise_error(
fallback_prefix=r'factory_file_utils::apply_path_replacements',
fallback_msg=r'path_replacements must be provided',
exc_message=exc_message,
exception_class=exception_class,
depth=3
)
if not vals:
raise_error(
fallback_prefix=r'factory_file_utils::apply_path_replacements',
fallback_msg=r'vals must be provided',
exc_message=exc_message,
exception_class=exception_class,
depth=3
)
dest_rel_path = rel_path
for old_str, var_name in path_replacements.items():
replacement_val = vals.get(var_name)
if replacement_val is None:
continue
words = [w for w in old_str.replace('-', '_').split('_') if w]
if not words:
dest_rel_path = dest_rel_path.replace(old_str, replacement_val)
continue
pattern_str = r'[-_]?'.join(escape(w) for w in words)
pattern = compile(rf'\b{pattern_str}\b', IGNORECASE)
def replace_match(match: Match) -> str:
clean_str = match.group(0).lstrip('-_')
return format_casing_by_match(
clean_str=clean_str,
default_val=replacement_val,
upper_val=vals.get(f'{var_name}_upper', replacement_val.upper()),
camel_val=vals.get(f'{var_name}_camel', replacement_val),
dashed_val=vals.get(f'{var_name}_dashed', replacement_val.replace('_', '-'))
)
dest_rel_path = pattern.sub(replace_match, dest_rel_path)
return dest_rel_path