diff --git a/comfy/cmd/main_pre.py b/comfy/cmd/main_pre.py index d022e24d6..3bc3e9f0d 100644 --- a/comfy/cmd/main_pre.py +++ b/comfy/cmd/main_pre.py @@ -14,8 +14,11 @@ import os import shutil import warnings +import fsspec + from .. import options from ..app import logger +from ..component_model import package_filesystem os.environ['TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL'] = '1' os.environ["OPENCV_IO_ENABLE_OPENEXR"] = "1" @@ -155,8 +158,15 @@ def _configure_logging(): logging_level = args.logging_level logger.setup_logger(logging_level) +def _register_fsspec_fs(): + fsspec.register_implementation( + package_filesystem.PkgResourcesFileSystem.protocol, + package_filesystem.PkgResourcesFileSystem, + ) + _configure_logging() _fix_pytorch_240() +_register_fsspec_fs() tracer = _create_tracer() __all__ = ["args", "tracer"] diff --git a/comfy/component_model/package_filesystem.py b/comfy/component_model/package_filesystem.py new file mode 100644 index 000000000..683f98ae1 --- /dev/null +++ b/comfy/component_model/package_filesystem.py @@ -0,0 +1,117 @@ +import importlib.resources +from fsspec.spec import AbstractFileSystem +from fsspec.registry import register_implementation + + +class PkgResourcesFileSystem(AbstractFileSystem): + """ + An fsspec filesystem for reading Python package resources. + + Paths are expected in the format: + pkg:///path/to/resource.txt + """ + + protocol = "pkg" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._traversables = {} + + def _get_traversable(self, package_name): + """Get or cache the root Traversable for a package.""" + if package_name not in self._traversables: + try: + # Get the Traversable object for the root of the package + self._traversables[package_name] = importlib.resources.files(package_name) + except ModuleNotFoundError as e: + raise FileNotFoundError(f"Package '{package_name}' not found.") from e + return self._traversables[package_name] + + def _resolve_path(self, path): + """Split a pkg:// path into package name and resource path.""" + # Remove protocol and leading slashes + path_no_proto = self._strip_protocol(path).lstrip('/') + + if not path_no_proto: + raise ValueError("Path must include a package name.") + + parts = path_no_proto.split('/', 1) + package_name = parts[0] + + resource_path = parts[1] if len(parts) > 1 else "" + + root = self._get_traversable(package_name) + + # Resolve the final resource Traversable + if resource_path: + resource = root.joinpath(resource_path) + else: + resource = root + + return resource + + def _open( + self, + path, + mode="rb", + **kwargs, + ): + """Open a file for reading.""" + if "w" in mode or "a" in mode or "x" in mode: + raise NotImplementedError("Only read mode is supported.") + + try: + resource = self._resolve_path(path) + if not resource.is_file(): + raise FileNotFoundError(f"Path is not a file: {path}") + return resource.open("rb") + except (ModuleNotFoundError, FileNotFoundError): + raise FileNotFoundError(f"Resource not found: {path}") + except Exception as e: + raise IOError(f"Failed to open resource {path}: {e}") from e + + def ls(self, path, detail=False, **kwargs): + """List contents of a package directory.""" + try: + resource = self._resolve_path(path) + if not resource.is_dir(): + # If it's a file, 'ls' should return info on that file + return [self.info(path)] if detail else [path] + + items = [] + for item in resource.iterdir(): + item_path = f"{path.rstrip('/')}/{item.name}" + if detail: + items.append(self.info(item_path)) + else: + items.append(item_path) + return items + except (ModuleNotFoundError, FileNotFoundError): + raise FileNotFoundError(f"Resource path not found: {path}") + + def info(self, path, **kwargs): + """Get info about a resource.""" + try: + resource = self._resolve_path(path) + resource_type = "directory" if resource.is_dir() else "file" + + size = None + if resource_type == 'file': + # This is inefficient but demonstrates the principle + try: + with resource.open('rb') as f: + size = len(f.read()) + except Exception: + size = None # Could fail for some reason + + return { + "name": path, + "type": resource_type, + "size": size, + } + except (ModuleNotFoundError, FileNotFoundError): + raise FileNotFoundError(f"Resource not found: {path}") + + +# Register the filesystem with fsspec +register_implementation(PkgResourcesFileSystem.protocol, PkgResourcesFileSystem) diff --git a/comfy/fonts/OFL.md b/comfy/fonts/OFL.md new file mode 100644 index 000000000..e6f10ec71 --- /dev/null +++ b/comfy/fonts/OFL.md @@ -0,0 +1,107 @@ +Copyright 2022-2024 The Tiny5 Project Authors (https://github.com/Gissio/font_tiny5) + +This Font Software is licensed under the SIL Open Font License, Version 1.1 . This license is copied below, and is also available with a FAQ at: https://openfontlicense.org + +  + +\---------------------------------------------------------------------- + +#### SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 + +\---------------------------------------------------------------------- + +  + +PREAMBLE +----------- + +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +----------- + +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +----------- + +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, + in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, + redistributed and/or sold with any software, provided that each copy + contains the above copyright notice and this license. These can be + included either as stand-alone text files, human-readable headers or + in the appropriate machine-readable metadata fields within text or + binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font + Name(s) unless explicit written permission is granted by the corresponding + Copyright Holder. This restriction only applies to the primary font name as + presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font + Software shall not be used to promote, endorse or advertise any + Modified Version, except to acknowledge the contribution(s) of the + Copyright Holder(s) and the Author(s) or with their explicit written + permission. + +5) The Font Software, modified or unmodified, in part or in whole, + must be distributed entirely under this license, and must not be + distributed under any other license. The requirement for fonts to + remain under this license does not apply to any document created + using the Font Software. + +TERMINATION +----------- + +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +----------- + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + diff --git a/comfy/fonts/Tiny5-Regular.ttf b/comfy/fonts/Tiny5-Regular.ttf new file mode 100644 index 000000000..635fe079f Binary files /dev/null and b/comfy/fonts/Tiny5-Regular.ttf differ diff --git a/comfy/fonts/__init__.py b/comfy/fonts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyproject.toml b/pyproject.toml index bd338cdca..4a1ca30f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,7 @@ dependencies = [ # doesn't support linux correctly yet "stringzilla<4.2.0", "requests_cache", + "universal_pathlib", ] [build-system] diff --git a/tests/unit/fsspec_tests/__init__.py b/tests/unit/fsspec_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/fsspec_tests/files/__init__.py b/tests/unit/fsspec_tests/files/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/fsspec_tests/files/b.txt b/tests/unit/fsspec_tests/files/b.txt new file mode 100644 index 000000000..a0aba9318 --- /dev/null +++ b/tests/unit/fsspec_tests/files/b.txt @@ -0,0 +1 @@ +OK \ No newline at end of file diff --git a/tests/unit/fsspec_tests/files/subdir/a.txt b/tests/unit/fsspec_tests/files/subdir/a.txt new file mode 100644 index 000000000..a0aba9318 --- /dev/null +++ b/tests/unit/fsspec_tests/files/subdir/a.txt @@ -0,0 +1 @@ +OK \ No newline at end of file diff --git a/tests/unit/fsspec_tests/test_package_filesystem.py b/tests/unit/fsspec_tests/test_package_filesystem.py new file mode 100644 index 000000000..694ca4d37 --- /dev/null +++ b/tests/unit/fsspec_tests/test_package_filesystem.py @@ -0,0 +1,114 @@ +import pytest +import fsspec +from comfy.component_model import package_filesystem +import os + + +# Ensure the filesystem is registered once for all tests +@pytest.fixture(scope="module", autouse=True) +def setup_package_filesystem(): + if "pkg" not in fsspec.available_protocols(): + fsspec.register_implementation( + package_filesystem.PkgResourcesFileSystem.protocol, + package_filesystem.PkgResourcesFileSystem, + ) + # Yield to allow tests to run, then teardown if necessary (though not needed here) + yield + + +@pytest.fixture +def pkg_fs(): + return fsspec.filesystem("pkg") + + +def test_open_file_in_package(pkg_fs): + """Test opening a file directly within a package.""" + with pkg_fs.open("pkg://tests.unit.fsspec_tests.files/b.txt", "rb") as f: + content = f.read() + assert content == b"OK" + + +def test_open_file_in_text_mode(pkg_fs): + """Test opening a file in text mode.""" + with pkg_fs.open("pkg://tests.unit.fsspec_tests.files/b.txt", "r") as f: + content = f.read() + assert content == "OK" + + +def test_open_file_in_subdir(pkg_fs): + """Test opening a file in a subdirectory of a package.""" + with pkg_fs.open("pkg://tests.unit.fsspec_tests.files/subdir/a.txt", "rb") as f: + content = f.read() + assert content == b"OK" + + +def test_file_not_found(pkg_fs): + """Test that opening a non-existent file raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError): + pkg_fs.open("pkg://tests.unit.fsspec_tests.files/nonexistent.txt") + + +def test_package_not_found(pkg_fs): + """Test that using a non-existent package raises FileNotFoundError.""" + with pytest.raises(FileNotFoundError): + pkg_fs.open("pkg://non.existent.package/resource.txt") + + +def test_ls_package_root(pkg_fs): + """Test listing the contents of a package.""" + contents = pkg_fs.ls("pkg://tests.unit.fsspec_tests.files", detail=False) + expected_items = { + "pkg://tests.unit.fsspec_tests.files/b.txt", + "pkg://tests.unit.fsspec_tests.files/subdir", + "pkg://tests.unit.fsspec_tests.files/__init__.py", + } + # Use a subset assertion to be resilient to __pycache__ + normalized_contents = {os.path.normpath(p.split('@')[0]) for p in contents} + normalized_expected = {os.path.normpath(p) for p in expected_items} + assert normalized_expected.issubset(normalized_contents) + + +def test_ls_subdir(pkg_fs): + """Test listing the contents of a subdirectory.""" + contents = pkg_fs.ls("pkg://tests.unit.fsspec_tests.files/subdir", detail=False) + normalized_contents = [os.path.normpath(p.split('@')[0]) for p in contents] + assert os.path.normpath("pkg://tests.unit.fsspec_tests.files/subdir/a.txt") in normalized_contents + + +def test_info_file(pkg_fs): + """Test getting info for a file.""" + info = pkg_fs.info("pkg://tests.unit.fsspec_tests.files/b.txt") + assert info["type"] == "file" + assert info["name"] == "pkg://tests.unit.fsspec_tests.files/b.txt" + assert info["size"] == 2 + + +def test_info_directory(pkg_fs): + """Test getting info for a directory.""" + info = pkg_fs.info("pkg://tests.unit.fsspec_tests.files/subdir") + assert info["type"] == "directory" + assert info["name"] == "pkg://tests.unit.fsspec_tests.files/subdir" + # Directories typically don't have a size in this context, or it might be 0 + assert "size" in info # Ensure size key exists + assert info["size"] is None or info["size"] == 0 + + +def test_load_font_with_upath(pkg_fs): + """Test that a font can be loaded from the pkg filesystem using UPath.""" + from upath import UPath + from PIL import ImageFont, features + + # This test requires Pillow with FreeType support + if not features.check("freetype2"): + pytest.skip("Pillow FreeType support not available") + + # UPath will use the registered fsspec filesystem for "pkg" + font_path = UPath("pkg://comfy.fonts/Tiny5-Regular.ttf") + + # ImageFont.truetype can take a file-like object. + # UPath.open() provides one using the underlying fsspec filesystem. + with font_path.open("rb") as f: + font = ImageFont.truetype(f, 10) + + assert font is not None + assert isinstance(font, ImageFont.FreeTypeFont)