hatch_ci.fileos

src/hatch_ci/fileos.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
"""various file/dir related functions"""

from __future__ import annotations

import os
import types
from pathlib import Path
from typing import Any, overload


class FileOSError(Exception):
    pass


class FileOSModuleNotFoundError(FileOSError):
    pass


class FileOSMInvalidModuleError(FileOSError):
    pass


def rmtree(path: Path):
    """universal (win|*nix) rmtree"""

    from os import name
    from shutil import rmtree
    from stat import S_IWUSR

    if name == "nt":
        for p in path.rglob("*"):
            p.chmod(S_IWUSR)
    rmtree(path, ignore_errors=True)
    if path.exists():
        raise RuntimeError(f"cannot remove {path=}")


def mkdir(path: Path) -> Path:
    """make a path directory and returns if it has been created"""
    path.mkdir(exist_ok=True, parents=True)
    return path


def touch(path: Path) -> Path:
    """touch a new empty file"""
    mkdir(path.parent)
    path.write_text("")
    return path


@overload
def which(exe: Path | str, kind: type[list], abort: bool = True) -> list[Path]: ...


@overload
def which(exe: Path | str, kind: None, abort: bool = True) -> Path | None: ...


def which(
    exe: Path | str, kind: type[list] | None = None, abort: bool = True
) -> list[Path] | Path | None:
    candidates: list[Path] = []
    for srcdir in os.environ.get("PATH", "").split(os.pathsep):
        for ext in os.environ.get("PATHEXT", "").split(os.pathsep):
            path = srcdir / Path(exe).with_suffix(ext)
            if not path.exists():
                continue
            if kind is None:
                return path
            candidates.append(path)
    return candidates


def loadmod(path: Path | str, suffix: str | None = "") -> types.ModuleType:
    import inspect
    from importlib import machinery, util

    if isinstance(path, str):
        if not Path(path).is_absolute():
            path = Path(inspect.stack()[1].filename).parent / path
        path = Path(path)

    if suffix is not None:
        machinery.SOURCE_SUFFIXES.append(suffix)
    try:
        spec = util.spec_from_file_location(Path(path).name, Path(path))
        if not spec:
            raise FileOSModuleNotFoundError(f"cannot find module for {path=}")
        module = util.module_from_spec(spec)
        if not spec.loader:
            raise FileOSMInvalidModuleError(f"invalid module in {path=}")
        spec.loader.exec_module(module)
    finally:
        if suffix is not None:
            machinery.SOURCE_SUFFIXES.pop()
    return module


### FILE UTILITIES


def zextract(path: Path | str, items: list[str] | None = None) -> dict[str, Any]:
    """extracts from path (a zipfile/tarball) all data in a dictionary"""
    from tarfile import TarFile, is_tarfile
    from zipfile import ZipFile, is_zipfile

    path = Path(path)
    result = {}
    if is_tarfile(path):
        with TarFile.open(path) as tfp:
            for member in tfp.getmembers():
                fp = tfp.extractfile(member)
                if not fp:
                    continue
                result[member.name] = str(fp.read(), encoding="utf-8")
    elif is_zipfile(path):
        with ZipFile(path) as tfp:
            for zinfo in tfp.infolist():
                if items and zinfo.filename not in items:
                    continue
                with tfp.open(zinfo.filename) as fp:
                    result[zinfo.filename] = str(fp.read(), encoding="utf-8").replace(
                        "\r", ""
                    )

    return result


def backup(path: Path, ext: str, overwrite: bool = False, abort: bool = True) -> Path:
    """creates a backup of path"""
    from shutil import copyfile, copymode

    path2 = path.parent / f"{path.name}{ext}"
    if path2.exists() and not overwrite:
        if abort:
            raise FileOSError(f"backup file present {path2}")
        return path2

    copyfile(path, path2)
    copymode(path, path2)
    return path2


def unbackup(path: Path, ext: str, abort: bool = True) -> Path:
    """restores from a backup of path"""
    from shutil import move

    path2 = path.parent / f"{path.name}{ext}"
    if abort and not path2.exists():
        raise FileOSError(f"cannot find backup file {path2} for {path=}")
    if path2.exists():
        move(str(path2), str(path))
    return path2