"""unidep - Unified Conda and Pip requirements management.
This module provides utility functions used throughout the package.
"""
from __future__ import annotations
import codecs
import platform
import re
import sys
import warnings
from collections import defaultdict
from pathlib import Path
from typing import Any, NamedTuple, cast
from unidep._version import __version__
from unidep.platform_definitions import (
PEP508_MARKERS,
Platform,
Selector,
platforms_from_selector,
validate_selector,
)
try: # pragma: no cover
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
HAS_TOML = True
except ImportError: # pragma: no cover
HAS_TOML = False
[docs]
def escape_unicode(string: str) -> str:
"""Escape unicode characters."""
return codecs.decode(string, "unicode_escape")
[docs]
def is_pip_installable(folder: str | Path) -> bool: # pragma: no cover
"""Determine if the project is pip installable.
Checks for existence of setup.py or [build-system] in pyproject.toml.
If the `toml` library is available, it is used to parse the `pyproject.toml` file.
If the `toml` library is not available, the function checks for the existence of
a line starting with "[build-system]". This does not handle the case where
[build-system] is inside of a multi-line literal string.
"""
path = Path(folder)
if (path / "setup.py").exists():
return True
pyproject_path = path / "pyproject.toml"
if pyproject_path.exists():
if HAS_TOML:
with pyproject_path.open("rb") as file:
pyproject_data = tomllib.load(file)
return "build-system" in pyproject_data
else:
with pyproject_path.open("r") as file:
for line in file:
if line.strip().startswith("[build-system]"):
return True
return False
[docs]
def build_pep508_environment_marker(
platforms: list[Platform | tuple[Platform, ...]],
) -> str:
"""Generate a PEP 508 selector for a list of platforms."""
sorted_platforms = tuple(sorted(platforms))
if sorted_platforms in PEP508_MARKERS:
return PEP508_MARKERS[sorted_platforms] # type: ignore[index]
environment_markers = [
PEP508_MARKERS[platform]
for platform in sorted(sorted_platforms)
if platform in PEP508_MARKERS
]
return " or ".join(environment_markers)
[docs]
class ParsedPackageStr(NamedTuple):
"""A package name and version pinning."""
name: str
pin: str | None = None
# can be of type `Selector` but also space separated string of `Selector`s
selector: str | None = None
[docs]
def parse_package_str(package_str: str) -> ParsedPackageStr:
"""Splits a string into package name, version pinning, and platform selector."""
# Regex to match package name, version pinning, and optionally platform selector
# Note: the name_pattern currently allows for paths and extras, however,
# paths cannot contain spaces or contain brackets.
name_pattern = r"[a-zA-Z0-9_.\-/]+(\[[a-zA-Z0-9_.,\-]+\])?"
version_pin_pattern = r".*?"
selector_pattern = r"[a-z0-9\s]+"
pattern = rf"({name_pattern})\s*({version_pin_pattern})?(:({selector_pattern}))?$"
match = re.match(pattern, package_str)
if match:
package_name = match.group(1).strip()
version_pin = match.group(3).strip() if match.group(3) else None
selector = match.group(5).strip() if match.group(5) else None
if selector is not None:
for s in selector.split():
validate_selector(cast(Selector, s))
return ParsedPackageStr(
package_name,
version_pin,
selector,
)
msg = f"Invalid package string: '{package_str}'"
raise ValueError(msg)
def _simple_warning_format(
message: Warning | str,
category: type[Warning], # noqa: ARG001
filename: str,
lineno: int,
line: str | None = None, # noqa: ARG001
) -> str: # pragma: no cover
"""Format warnings without code context."""
return (
f"---------------------\n"
f"⚠️ *** WARNING *** ⚠️\n"
f"{message}\n"
f"Location: {filename}:{lineno}\n"
f"---------------------\n"
)
[docs]
def warn(
message: str | Warning,
category: type[Warning] = UserWarning,
stacklevel: int = 1,
) -> None:
"""Emit a warning with a custom format specific to this package."""
original_format = warnings.formatwarning
warnings.formatwarning = _simple_warning_format
try:
warnings.warn(message, category, stacklevel=stacklevel + 1)
finally:
warnings.formatwarning = original_format
[docs]
def split_path_and_extras(input_str: str | Path) -> tuple[Path, list[str]]:
"""Parse a string of the form `path/to/file[extra1,extra2]` into parts.
Returns a tuple of the `pathlib.Path` and a list of extras
"""
if isinstance(input_str, Path):
input_str = str(input_str)
if not input_str: # Check for empty string
return Path(), []
pattern = r"^(.+?)(?:\[([^\[\]]+)\])?$"
match = re.search(pattern, input_str)
if match is None: # pragma: no cover
# I don't think this is possible, but just in case
return Path(), []
path = Path(match.group(1))
extras = match.group(2)
if not extras:
return path, []
extras = [extra.strip() for extra in extras.split(",")]
return path, extras
[docs]
def parse_folder_or_filename(folder_or_file: str | Path) -> PathWithExtras:
"""Get the path to `requirements.yaml` or `pyproject.toml` file."""
folder_or_file, extras = split_path_and_extras(folder_or_file)
path = Path(folder_or_file)
if path.is_dir():
fname_yaml = path / "requirements.yaml"
if fname_yaml.exists():
return PathWithExtras(fname_yaml, extras)
fname_toml = path / "pyproject.toml"
if fname_toml.exists() and unidep_configured_in_toml(fname_toml):
return PathWithExtras(fname_toml, extras)
msg = (
f"File `{fname_yaml}` or `{fname_toml}` (with unidep configuration)"
f" not found in `{folder_or_file}`."
)
raise FileNotFoundError(msg)
if not path.exists():
msg = f"File `{path}` not found."
raise FileNotFoundError(msg)
return PathWithExtras(path, extras)
[docs]
def defaultdict_to_dict(d: defaultdict | Any) -> dict:
"""Convert (nested) defaultdict to (nested) dict."""
if isinstance(d, defaultdict):
d = {key: defaultdict_to_dict(value) for key, value in d.items()}
return d
[docs]
def get_package_version(package_name: str) -> str | None:
"""Returns the version of the given package.
Parameters
----------
package_name
The name of the package to find the version of.
Returns
-------
The version of the package, or None if the package is not found.
"""
if sys.version_info >= (3, 8):
import importlib.metadata
try:
return importlib.metadata.version(package_name)
except importlib.metadata.PackageNotFoundError:
return None
else: # pragma: no cover
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
import pkg_resources
try:
return pkg_resources.get_distribution(package_name).version
except pkg_resources.DistributionNotFound:
return None