Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
_dduf.py387 linesDownload Raw Back to serialization
1import json2import logging3import mmap4import os5import shutil6import zipfile7from collections.abc import Generator, Iterable8from contextlib import contextmanager9from dataclasses import dataclass, field10from pathlib import Path11from typing import Any12 13from ..errors import DDUFCorruptedFileError, DDUFExportError, DDUFInvalidEntryNameError14 15 16logger = logging.getLogger(__name__)17 18DDUF_ALLOWED_ENTRIES = {19    # Allowed file extensions in a DDUF file20    ".json",21    ".model",22    ".safetensors",23    ".txt",24}25 26DDUF_FOLDER_REQUIRED_ENTRIES = {27    # Each folder must contain at least one of these entries28    "config.json",29    "tokenizer_config.json",30    "preprocessor_config.json",31    "scheduler_config.json",32}33 34 35@dataclass36class DDUFEntry:37    """Object representing a file entry in a DDUF file.38 39    See [`read_dduf_file`] for how to read a DDUF file.40 41    Attributes:42        filename (str):43            The name of the file in the DDUF archive.44        offset (int):45            The offset of the file in the DDUF archive.46        length (int):47            The length of the file in the DDUF archive.48        dduf_path (str):49            The path to the DDUF archive (for internal use).50    """51 52    filename: str53    length: int54    offset: int55 56    dduf_path: Path = field(repr=False)57 58    @contextmanager59    def as_mmap(self) -> Generator[bytes, None, None]:60        """Open the file as a memory-mapped file.61 62        Useful to load safetensors directly from the file.63 64        Example:65            ```py66            >>> import safetensors.torch67            >>> with entry.as_mmap() as mm:68            ...     tensors = safetensors.torch.load(mm)69            ```70        """71        with self.dduf_path.open("rb") as f:72            with mmap.mmap(f.fileno(), length=0, access=mmap.ACCESS_READ) as mm:73                yield mm[self.offset : self.offset + self.length]74 75    def read_text(self, encoding: str = "utf-8") -> str:76        """Read the file as text.77 78        Useful for '.txt' and '.json' entries.79 80        Example:81            ```py82            >>> import json83            >>> index = json.loads(entry.read_text())84            ```85        """86        with self.dduf_path.open("rb") as f:87            f.seek(self.offset)88            return f.read(self.length).decode(encoding=encoding)89 90 91def read_dduf_file(dduf_path: os.PathLike | str) -> dict[str, DDUFEntry]:92    """93    Read a DDUF file and return a dictionary of entries.94 95    Only the metadata is read, the data is not loaded in memory.96 97    Args:98        dduf_path (`str` or `os.PathLike`):99            The path to the DDUF file to read.100 101    Returns:102        `dict[str, DDUFEntry]`:103            A dictionary of [`DDUFEntry`] indexed by filename.104 105    Raises:106        - [`DDUFCorruptedFileError`]: If the DDUF file is corrupted (i.e. doesn't follow the DDUF format).107 108    Example:109        ```python110        >>> import json111        >>> import safetensors.torch112        >>> from huggingface_hub import read_dduf_file113 114        # Read DDUF metadata115        >>> dduf_entries = read_dduf_file("FLUX.1-dev.dduf")116 117        # Returns a mapping filename <> DDUFEntry118        >>> dduf_entries["model_index.json"]119        DDUFEntry(filename='model_index.json', offset=66, length=587)120 121        # Load model index as JSON122        >>> json.loads(dduf_entries["model_index.json"].read_text())123        {'_class_name': 'FluxPipeline', '_diffusers_version': '0.32.0.dev0', '_name_or_path': 'black-forest-labs/FLUX.1-dev', ...124 125        # Load VAE weights using safetensors126        >>> with dduf_entries["vae/diffusion_pytorch_model.safetensors"].as_mmap() as mm:127        ...     state_dict = safetensors.torch.load(mm)128        ```129    """130    entries = {}131    dduf_path = Path(dduf_path)132    logger.info(f"Reading DDUF file {dduf_path}")133    with zipfile.ZipFile(str(dduf_path), "r") as zf:134        for info in zf.infolist():135            logger.debug(f"Reading entry {info.filename}")136            if info.compress_type != zipfile.ZIP_STORED:137                raise DDUFCorruptedFileError("Data must not be compressed in DDUF file.")138 139            try:140                _validate_dduf_entry_name(info.filename)141            except DDUFInvalidEntryNameError as e:142                raise DDUFCorruptedFileError(f"Invalid entry name in DDUF file: {info.filename}") from e143 144            offset = _get_data_offset(zf, info)145 146            entries[info.filename] = DDUFEntry(147                filename=info.filename, offset=offset, length=info.file_size, dduf_path=dduf_path148            )149 150    # Consistency checks on the DDUF file151    if "model_index.json" not in entries:152        raise DDUFCorruptedFileError("Missing required 'model_index.json' entry in DDUF file.")153    index = json.loads(entries["model_index.json"].read_text())154    _validate_dduf_structure(index, entries.keys())155 156    logger.info(f"Done reading DDUF file {dduf_path}. Found {len(entries)} entries")157    return entries158 159 160def export_entries_as_dduf(dduf_path: str | os.PathLike, entries: Iterable[tuple[str, str | Path | bytes]]) -> None:161    """Write a DDUF file from an iterable of entries.162 163    This is a lower-level helper than [`export_folder_as_dduf`] that allows more flexibility when serializing data.164    In particular, you don't need to save the data on disk before exporting it in the DDUF file.165 166    Args:167        dduf_path (`str` or `os.PathLike`):168            The path to the DDUF file to write.169        entries (`Iterable[tuple[str, Union[str, Path, bytes]]]`):170            An iterable of entries to write in the DDUF file. Each entry is a tuple with the filename and the content.171            The filename should be the path to the file in the DDUF archive.172            The content can be a string or a pathlib.Path representing a path to a file on the local disk or directly the content as bytes.173 174    Raises:175        - [`DDUFExportError`]: If anything goes wrong during the export (e.g. invalid entry name, missing 'model_index.json', etc.).176 177    Example:178        ```python179        # Export specific files from the local disk.180        >>> from huggingface_hub import export_entries_as_dduf181        >>> export_entries_as_dduf(182        ...     dduf_path="stable-diffusion-v1-4-FP16.dduf",183        ...     entries=[ # List entries to add to the DDUF file (here, only FP16 weights)184        ...         ("model_index.json", "path/to/model_index.json"),185        ...         ("vae/config.json", "path/to/vae/config.json"),186        ...         ("vae/diffusion_pytorch_model.fp16.safetensors", "path/to/vae/diffusion_pytorch_model.fp16.safetensors"),187        ...         ("text_encoder/config.json", "path/to/text_encoder/config.json"),188        ...         ("text_encoder/model.fp16.safetensors", "path/to/text_encoder/model.fp16.safetensors"),189        ...         # ... add more entries here190        ...     ]191        ... )192        ```193 194        ```python195        # Export state_dicts one by one from a loaded pipeline196        >>> from diffusers import DiffusionPipeline197        >>> from typing import Generator, Tuple198        >>> import safetensors.torch199        >>> from huggingface_hub import export_entries_as_dduf200        >>> pipe = DiffusionPipeline.from_pretrained("CompVis/stable-diffusion-v1-4")201        ... # ... do some work with the pipeline202 203        >>> def as_entries(pipe: DiffusionPipeline) -> Generator[tuple[str, bytes], None, None]:204        ...     # Build a generator that yields the entries to add to the DDUF file.205        ...     # The first element of the tuple is the filename in the DDUF archive (must use UNIX separator!). The second element is the content of the file.206        ...     # Entries will be evaluated lazily when the DDUF file is created (only 1 entry is loaded in memory at a time)207        ...     yield "vae/config.json", pipe.vae.to_json_string().encode()208        ...     yield "vae/diffusion_pytorch_model.safetensors", safetensors.torch.save(pipe.vae.state_dict())209        ...     yield "text_encoder/config.json", pipe.text_encoder.config.to_json_string().encode()210        ...     yield "text_encoder/model.safetensors", safetensors.torch.save(pipe.text_encoder.state_dict())211        ...     # ... add more entries here212 213        >>> export_entries_as_dduf(dduf_path="stable-diffusion-v1-4.dduf", entries=as_entries(pipe))214        ```215    """216    logger.info(f"Exporting DDUF file '{dduf_path}'")217    filenames = set()218    index = None219    with zipfile.ZipFile(str(dduf_path), "w", zipfile.ZIP_STORED) as archive:220        for filename, content in entries:221            if filename in filenames:222                raise DDUFExportError(f"Can't add duplicate entry: {filename}")223            filenames.add(filename)224 225            if filename == "model_index.json":226                try:227                    index = json.loads(_load_content(content).decode())228                except json.JSONDecodeError as e:229                    raise DDUFExportError("Failed to parse 'model_index.json'.") from e230 231            try:232                filename = _validate_dduf_entry_name(filename)233            except DDUFInvalidEntryNameError as e:234                raise DDUFExportError(f"Invalid entry name: {filename}") from e235            logger.debug(f"Adding entry '{filename}' to DDUF file")236            _dump_content_in_archive(archive, filename, content)237 238    # Consistency checks on the DDUF file239    if index is None:240        raise DDUFExportError("Missing required 'model_index.json' entry in DDUF file.")241    try:242        _validate_dduf_structure(index, filenames)243    except DDUFCorruptedFileError as e:244        raise DDUFExportError("Invalid DDUF file structure.") from e245 246    logger.info(f"Done writing DDUF file {dduf_path}")247 248 249def export_folder_as_dduf(dduf_path: str | os.PathLike, folder_path: str | os.PathLike) -> None:250    """251    Export a folder as a DDUF file.252 253    AUses [`export_entries_as_dduf`] under the hood.254 255    Args:256        dduf_path (`str` or `os.PathLike`):257            The path to the DDUF file to write.258        folder_path (`str` or `os.PathLike`):259            The path to the folder containing the diffusion model.260 261    Example:262        ```python263        >>> from huggingface_hub import export_folder_as_dduf264        >>> export_folder_as_dduf(dduf_path="FLUX.1-dev.dduf", folder_path="path/to/FLUX.1-dev")265        ```266    """267    folder_path = Path(folder_path)268 269    def _iterate_over_folder() -> Iterable[tuple[str, Path]]:270        for path in Path(folder_path).glob("**/*"):271            if not path.is_file():272                continue273            if path.suffix not in DDUF_ALLOWED_ENTRIES:274                logger.debug(f"Skipping file '{path}' (file type not allowed)")275                continue276            path_in_archive = path.relative_to(folder_path)277            if len(path_in_archive.parts) >= 3:278                logger.debug(f"Skipping file '{path}' (nested directories not allowed)")279                continue280            yield path_in_archive.as_posix(), path281 282    export_entries_as_dduf(dduf_path, _iterate_over_folder())283 284 285def _dump_content_in_archive(archive: zipfile.ZipFile, filename: str, content: str | os.PathLike | bytes) -> None:286    with archive.open(filename, "w", force_zip64=True) as archive_fh:287        if isinstance(content, (str, Path)):288            content_path = Path(content)289            with content_path.open("rb") as content_fh:290                shutil.copyfileobj(content_fh, archive_fh, 1024 * 1024 * 8)  # type: ignore[misc]291        elif isinstance(content, bytes):292            archive_fh.write(content)293        else:294            raise DDUFExportError(f"Invalid content type for {filename}. Must be str, Path or bytes.")295 296 297def _load_content(content: str | Path | bytes) -> bytes:298    """Load the content of an entry as bytes.299 300    Used only for small checks (not to dump content into archive).301    """302    if isinstance(content, (str, Path)):303        return Path(content).read_bytes()304    elif isinstance(content, bytes):305        return content306    else:307        raise DDUFExportError(f"Invalid content type. Must be str, Path or bytes. Got {type(content)}.")308 309 310def _validate_dduf_entry_name(entry_name: str) -> str:311    if "." + entry_name.split(".")[-1] not in DDUF_ALLOWED_ENTRIES:312        raise DDUFInvalidEntryNameError(f"File type not allowed: {entry_name}")313    if "\\" in entry_name:314        raise DDUFInvalidEntryNameError(f"Entry names must use UNIX separators ('/'). Got {entry_name}.")315    entry_name = entry_name.strip("/")316    if entry_name.count("/") > 1:317        raise DDUFInvalidEntryNameError(f"DDUF only supports 1 level of directory. Got {entry_name}.")318    return entry_name319 320 321def _validate_dduf_structure(index: Any, entry_names: Iterable[str]) -> None:322    """323    Consistency checks on the DDUF file structure.324 325    Rules:326    - The 'model_index.json' entry is required and must contain a dictionary.327    - Each folder name must correspond to an entry in 'model_index.json'.328    - Each folder must contain at least a config file ('config.json', 'tokenizer_config.json', 'preprocessor_config.json', 'scheduler_config.json').329 330    Args:331        index (Any):332            The content of the 'model_index.json' entry.333        entry_names (Iterable[str]):334            The list of entry names in the DDUF file.335 336    Raises:337        - [`DDUFCorruptedFileError`]: If the DDUF file is corrupted (i.e. doesn't follow the DDUF format).338    """339    if not isinstance(index, dict):340        raise DDUFCorruptedFileError(f"Invalid 'model_index.json' content. Must be a dictionary. Got {type(index)}.")341 342    dduf_folders = {entry.split("/")[0] for entry in entry_names if "/" in entry}343    for folder in dduf_folders:344        if folder not in index:345            raise DDUFCorruptedFileError(f"Missing required entry '{folder}' in 'model_index.json'.")346        if not any(f"{folder}/{required_entry}" in entry_names for required_entry in DDUF_FOLDER_REQUIRED_ENTRIES):347            raise DDUFCorruptedFileError(348                f"Missing required file in folder '{folder}'. Must contains at least one of {DDUF_FOLDER_REQUIRED_ENTRIES}."349            )350 351 352def _get_data_offset(zf: zipfile.ZipFile, info: zipfile.ZipInfo) -> int:353    """354    Calculate the data offset for a file in a ZIP archive.355 356    Args:357        zf (`zipfile.ZipFile`):358            The opened ZIP file. Must be opened in read mode.359        info (`zipfile.ZipInfo`):360            The file info.361 362    Returns:363        int: The offset of the file data in the ZIP archive.364    """365    if zf.fp is None:366        raise DDUFCorruptedFileError("ZipFile object must be opened in read mode.")367 368    # Step 1: Get the local file header offset369    header_offset = info.header_offset370 371    # Step 2: Read the local file header372    zf.fp.seek(header_offset)373    local_file_header = zf.fp.read(30)  # Fixed-size part of the local header374 375    if len(local_file_header) < 30:376        raise DDUFCorruptedFileError("Incomplete local file header.")377 378    # Step 3: Parse the header fields to calculate the start of file data379    # Local file header: https://en.wikipedia.org/wiki/ZIP_(file_format)#File_headers380    filename_len = int.from_bytes(local_file_header[26:28], "little")381    extra_field_len = int.from_bytes(local_file_header[28:30], "little")382 383    # Data offset is after the fixed header, filename, and extra fields384    data_offset = header_offset + 30 + filename_len + extra_field_len385 386    return data_offset387 
codekingpro/portable-devtools · Team Ai