feat: refactor assemblicon (1/3) sink and source establishment

This commit is contained in:
2026-09-05 22:56:14 +08:00
parent 49edad7d33
commit 43e3bd39ef
29 changed files with 777 additions and 377 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ def main() -> None:
# TODO: use explicit temporary directory initializer in prologue to do this. # TODO: use explicit temporary directory initializer in prologue to do this.
# create our special temporary subdirectory # create our special temporary subdirectory
env.temporary_artifact("component").mkdir(parents=True, exist_ok=True) env.__temporary_artifact("component").mkdir(parents=True, exist_ok=True)
try: try:
register(ctx) register(ctx)
@@ -0,0 +1,37 @@
import enum
class AddressDomain(enum.IntEnum):
Input = enum.auto()
Temporary = enum.auto()
Output = enum.auto()
FdOutput = enum.auto()
WinOutput = enum.auto()
MacOutput = enum.auto()
class Address:
"""The unique identity of an artifact slot (source/sink)."""
__domain: AddressDomain
__subdirectories: tuple[str, ...]
def __init__(self, domain: AddressDomain, *args: str) -> None:
self.__domain = domain
self.__subdirectories = tuple(args)
def __hash__(self) -> int:
return hash((self.__domain.value,) + self.__subdirectories)
def __eq__(self, other: object) -> bool:
if self is other:
return True
if not isinstance(other, Address):
return NotImplemented
return (
self.__domain == other.__domain
and self.__subdirectories == other.__subdirectories
)
@@ -1,63 +1,25 @@
import tempfile import tempfile
import os import os
import enum
from typing import Optional from typing import Optional
from types import TracebackType from types import TracebackType
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import TypeVar, Generic from typing import TypeVar, Generic, Callable
from .utils import Artifact, ArtifactFile, VART_HW, check_vart from ..lowlevel.environment import Prologue as LowPrologue, Environment as LowEnv
from ..logger import HIGHLEVEL_LOGGER as LOGGER from ..lowlevel.artrdr import SvgRenderKind
from ..lowlevel.artrdr import SvgRenderKind, SvgConfig, BlenderConfig from .platform import FdContext, WinCategory, MacCategory, FD_THUMBNAIL_HWS
from ..lowlevel import artio, artrdr
@dataclass(frozen=True) @dataclass(frozen=True)
class Prologue: class Prologue(LowPrologue):
input_directory: Path
output_directory: Path
svg_render: SvgRenderKind svg_render: SvgRenderKind
"""The render kind for SVG"""
def __post_init__(self) -> None: temp_dir_initializer: Callable[[Path], None] | None
if not self.input_directory.is_dir(): """
raise ValueError( The extra initializer for temporary directory.
f"Given input path {self.input_directory} is not an existing directory." The only argument in function is the path to temporary directory.
) ``None`` for no extra initializer.
if not self.output_directory.is_dir(): """
raise ValueError(
f"Given output path {self.output_directory} is not an existing directory."
)
P = TypeVar("P", bound=Prologue)
class FdContext(enum.StrEnum):
Actions = "actions"
Animations = "animations"
Applications = "apps"
Categories = "categories"
Devices = "devices"
Emblems = "emblems"
Emotes = "emotes"
International = "intl"
MimeTypes = "mimetypes"
Places = "places"
Status = "status"
class WinCategory(enum.StrEnum):
Application = "apps"
"""Icons for applications"""
Extension = "exts"
"""Icons for file extensions"""
class MacCategory(enum.StrEnum):
Application = "apps"
"""Icons for applications"""
Extension = "exts"
"""Icons for file extensions"""
_TEMP_USER_DIR: str = "user" _TEMP_USER_DIR: str = "user"
@@ -68,46 +30,51 @@ _TEMP_LIB_STABLE_DIR: str = "stable"
_ART_WIN: str = "windows" _ART_WIN: str = "windows"
_ART_MAC: str = "macos" _ART_MAC: str = "macos"
_DEFAULT_SVG_CONFIG = artrdr.SvgConfig(VART_HW, VART_HW) P = TypeVar("P", bound=Prologue)
_DEFAULT_BLD_CONFIG = artrdr.BlenderConfig(VART_HW, VART_HW)
# TODO: For easy testing, we only build 32x32 and 48x48.
# Enable full generation after testing.
_FD_THUMBNAIL_HWS: tuple[int, ...] = (32, 48)
# _FD_THUMBNAIL_HWS: tuple[int, ...] = (16, 32, 48, 64, 128, 256)
class Environment(Generic[P]): class Environment(Generic[P]):
__prologue: P __prologue: P
"""The prologue used for environment.""" """The prologue used for environment."""
__temp_dir: tempfile.TemporaryDirectory[str] __lowenv: LowEnv
"""The lowlevel environment managing temporary directory."""
def __init__(self, prologue: P) -> None: def __init__(self, prologue: P) -> None:
self.__prologue = prologue self.__prologue = prologue
self.__temp_dir = tempfile.TemporaryDirectory() self.__lowenv = LowEnv(self.__prologue)
# construct basic layout of temporary directory # construct basic layout of temporary directory
self.temporary_artifact(_TEMP_USER_DIR).mkdir(parents=True, exist_ok=True) self.__temporary_artifact(_TEMP_USER_DIR).mkdir(parents=True, exist_ok=True)
self.temporary_artifact(_TEMP_LIB_DIR, _TEMP_LIB_ALLOC_DIR).mkdir( self.__temporary_artifact(_TEMP_LIB_DIR, _TEMP_LIB_ALLOC_DIR).mkdir(
parents=True, exist_ok=True parents=True, exist_ok=True
) )
self.temporary_artifact(_TEMP_LIB_DIR, _TEMP_LIB_STABLE_DIR).mkdir( self.__temporary_artifact(_TEMP_LIB_DIR, _TEMP_LIB_STABLE_DIR).mkdir(
parents=True, exist_ok=True parents=True, exist_ok=True
) )
# and output directory # and output directory
for hw in _FD_THUMBNAIL_HWS: for hw in FD_THUMBNAIL_HWS:
for kind in FdContext: for kind in FdContext:
self.output_artifact(f"{hw}x{hw}", str(kind)).mkdir( self.__output_artifact(f"{hw}x{hw}", str(kind)).mkdir(
parents=True, exist_ok=True parents=True, exist_ok=True
) )
for kind in WinCategory: for kind in WinCategory:
self.output_artifact(_ART_WIN, str(kind)).mkdir(parents=True, exist_ok=True) self.__output_artifact(_ART_WIN, str(kind)).mkdir(
parents=True, exist_ok=True
)
for kind in MacCategory: for kind in MacCategory:
self.output_artifact(_ART_MAC, str(kind)).mkdir(parents=True, exist_ok=True) self.__output_artifact(_ART_MAC, str(kind)).mkdir(
parents=True, exist_ok=True
)
# construct user custom temporary layout of possible
if self.__prologue.temp_dir_initializer is not None:
temp_dir = self.__temporary_artifact()
self.__prologue.temp_dir_initializer(temp_dir)
# region: With Visitor Requirements # region: With Visitor Requirements
def __enter__(self) -> "Environment": def __enter__(self) -> "Environment":
self.__lowenv.__enter__()
return self return self
def __exit__( def __exit__(
@@ -116,25 +83,19 @@ class Environment(Generic[P]):
exc_value: BaseException | None, exc_value: BaseException | None,
traceback: TracebackType | None, traceback: TracebackType | None,
) -> None: ) -> None:
self.__temp_dir.cleanup() self.__lowenv.__exit__(exc_type, exc_value, traceback)
# endregion # endregion
# region: Prologue Visitor # region: Prologue Visitor
@property @property
def prologue(self) -> P: def prologue(self) -> Prologue:
return self.__prologue return self.__prologue
# endregion @property
def user_prologue(self) -> P:
# region: New Artifact return self.__prologue
def new_artifact(self) -> Artifact:
"""
Create new RGBA vanilla size (1024x1024) empty (#ffffff00) artifact.
"""
return artio.new_artifact((VART_HW, VART_HW))
# endregion # endregion
@@ -144,48 +105,20 @@ class Environment(Generic[P]):
""" """
Get the path to input artifact. Get the path to input artifact.
""" """
return self.__prologue.input_directory / Path(*args) return self.__lowenv.input_artifact(*args)
def load_input_bitmap_artifact(self, *args: str) -> ArtifactFile:
"""
Load given input bitmap asset.
"""
p = self.input_artifact(*args)
LOGGER.info("Loading input artifact: %s", p)
return artio.load_input_artifact(p)
# endregion # endregion
# region: Temporary Artifact # region: Temporary Artifact
def __user_temporary_artifact(self, *args: str) -> Path: def __temporary_artifact(self, *args: str) -> Path:
return self.temporary_artifact(_TEMP_USER_DIR, *args) return self.__lowenv.temporary_artifact(*args)
def user_temporary_artifact(self, *args: str) -> Path:
return self.__temporary_artifact(_TEMP_USER_DIR, *args)
def __lib_temporary_artifact(self, *args: str) -> Path: def __lib_temporary_artifact(self, *args: str) -> Path:
return self.temporary_artifact(_TEMP_LIB_DIR, *args) return self.__temporary_artifact(_TEMP_LIB_DIR, *args)
def temporary_artifact(self, *args: str) -> Path:
"""
Get the path to temporary artifact.
All existence of sub-directory should be ensured on your own.
"""
return Path(self.__temp_dir.name) / Path(*args)
def load_temporary_artifact(self, *args: str) -> ArtifactFile:
p = self.temporary_artifact(*args)
LOGGER.info("Loading temporary artifact: %s", p)
return artio.load_artifact(p)
def save_temporary_artifact(self, art: Artifact, *args: str) -> None:
"""
Save given artifact as temporary artifact.
All existence of sub-directory should be ensured on your own.
"""
p = self.temporary_artifact(*args)
LOGGER.info("Saving temporary artifact: %s", p)
artio.save_artifact(art, p)
def __allocate_lib_temporary_artifact(self, suffix: Optional[str] = None) -> Path: def __allocate_lib_temporary_artifact(self, suffix: Optional[str] = None) -> Path:
p = self.__lib_temporary_artifact(_TEMP_LIB_ALLOC_DIR) p = self.__lib_temporary_artifact(_TEMP_LIB_ALLOC_DIR)
@@ -196,209 +129,65 @@ class Environment(Generic[P]):
def __fetch_const_lib_temporary_artifact(self, name: str) -> Path: def __fetch_const_lib_temporary_artifact(self, name: str) -> Path:
return self.__lib_temporary_artifact(_TEMP_LIB_STABLE_DIR, name) return self.__lib_temporary_artifact(_TEMP_LIB_STABLE_DIR, name)
# endregion def allocate_lib_render_temporary_artifact(self) -> Path:
# region: Render Wrapper
def __intern_render_svg(
self, src: Path, dst: Optional[Path], cfg: SvgConfig
) -> Path:
""" """
Internal used function for rendering SVG. Allocated a path pointing to temporary render result.
:return: The path to destination storing render result. :return: The path to temporary PNG file.
Please note that this file is already presneted in file system,
so caller need to overwrite it.
""" """
if dst is None: return self.__allocate_lib_temporary_artifact(".png")
LOGGER.info("Rendering temporary SVG: %s", src)
dst = self.__allocate_lib_temporary_artifact(".png")
else:
LOGGER.info("Rendering SVG: %s -> %s", src, dst)
artrdr.render_svg(src, dst, self.__prologue.svg_render, cfg)
return dst
def render_svg( def fetch_lib_blender_script_temporary_artifact(self) -> Path:
self, src: Path, dst: Path, cfg: SvgConfig = _DEFAULT_SVG_CONFIG
) -> None:
""" """
Just render given SVG to PNG file. Allocated a path pointing to temporary script instructing Blender render.
:param src: The path to SVG file to be rendered. :return: The path to temporary Python file.
:param dst: The path to rendered PNG file. Please note that this file may be presneted in file system,
:param cfg: The configuration when rendering SVG file. Default for vanilla artifact. so caller may need to overwrite it.
All Blender render process share the same script file (this).
""" """
self.__intern_render_svg(src, dst, cfg) # TODO: Strip this "share" behavior to enable multi-threads build ability.
return self.__fetch_const_lib_temporary_artifact("blender.py")
def render_svg_then_load(
self,
src: Path,
dst: Optional[Path] = None,
cfg: SvgConfig = _DEFAULT_SVG_CONFIG,
) -> Artifact:
"""
Render given SVG to PNG file, then load it directly.
:param src: The path to SVG file to be rendered.
:param dst: The path to rendered PNG file.
``None`` if you don't need to store it in temporary or output directory.
:param cfg: The configuration when rendering SVG file. Default for vanilla artifact.
:return: The loaded built PNG file.
"""
dst = self.__intern_render_svg(src, dst, cfg)
return artio.load_artifact(dst)
def __intern_render_blender(
self, src: Path, dst: Optional[Path], cfg: BlenderConfig
) -> Path:
"""
Internal used function for rendering Blender composition.
:return: The path to destination storing render result.
"""
temp_script = self.__fetch_const_lib_temporary_artifact("blender.py")
if dst is None:
LOGGER.info("Rendering temporary Blender composition: %s", src)
dst = self.__allocate_lib_temporary_artifact(".png")
else:
LOGGER.info("Rendering Blender composition: %s -> %s", src, dst)
artrdr.render_blender(src, dst, temp_script, cfg)
return dst
def render_blender(
self, src: Path, dst: Path, cfg: BlenderConfig = _DEFAULT_BLD_CONFIG
) -> None:
"""
Just render given Blender composition to PNG file.
:param src: The path to Blender composition file to be rendered.
:param dst: The path to rendered PNG file.
:param cfg: The configuration when rendering Blender composition file. Default for vanilla artifact.
"""
self.__intern_render_blender(src, dst, cfg)
def render_blender_then_load(
self,
src: Path,
dst: Optional[Path] = None,
cfg: BlenderConfig = _DEFAULT_BLD_CONFIG,
) -> Artifact:
"""
Render given Blender composition to PNG file, then load it directly.
:param src: The path to Blender composition file to be rendered.
:param dst: The path to rendered PNG file.
``None`` if you don't need to store it in temporary or output directory.
:param cfg: The configuration when rendering Blender composition file. Default for vanilla artifact.
:return: The loaded built PNG file.
"""
dst = self.__intern_render_blender(src, dst, cfg)
return artio.load_artifact(dst)
# endregion # endregion
# region: Output Artifact # region: Output Artifact
def output_artifact(self, *args: str) -> Path: def __output_artifact(self, *args: str) -> Path:
return self.__prologue.output_directory / Path(*args)
# endregion
# region: Advanced Saver
def __fd_artifact(self, hw: int, kind: FdContext, name: str) -> Path:
return self.output_artifact(f"{hw}x{hw}", str(kind), f"{name}.png")
def save_fd_artifact(self, art: Artifact, kind: FdContext, name: str) -> None:
""" """
Save icon following FreeDesktop icon theme specification. Get the path to output artifact.
"""
return self.__lowenv.output_artifact(*args)
def fd_output_artifact(self, hw: int, kind: FdContext, name: str) -> Path:
"""
Get the path for saving FreeDesktop icon with given image size, FreeDesktop icon and name.
:param hw: The height or width of this FreeDesktop icon.
Required because FreeDesktop need to save icon with different size variant.
:param kind: The FreeDesktop kind of this icon.
:param name: The name of saved artifact. The file extension ".png" is not required. :param name: The name of saved artifact. The file extension ".png" is not required.
""" """
LOGGER.info( return self.__output_artifact(f"{hw}x{hw}", str(kind), f"{name}.png")
'Saving FreeDesktop artifact with kind "%s" and name "%s".', kind, name
)
check_vart(art) def win_output_artifact(self, kind: WinCategory, name: str) -> Path:
for hw, thumbnail in zip(
_FD_THUMBNAIL_HWS, artrdr.render_thumbnail(art, _FD_THUMBNAIL_HWS)
):
dst = self.__fd_artifact(hw, kind, name)
artio.save_artifact(thumbnail, dst)
def dup_fd_artifact(
self, src_kind: FdContext, src_name: str, dst_kind: FdContext, dst_name: str
) -> None:
LOGGER.info(
'Duplicating FreeDesktop artifact from kind "%s" name "%s" to kind "%s" name "%s".',
src_kind,
src_name,
dst_kind,
dst_name,
)
for hw in _FD_THUMBNAIL_HWS:
artio.dup_artifact(
self.__fd_artifact(hw, src_kind, src_name),
self.__fd_artifact(hw, dst_kind, dst_name),
)
def __win_artifact(self, kind: WinCategory, name: str) -> Path:
return self.output_artifact(_ART_WIN, str(kind), f"{name}.ico")
def save_win_artifact(self, art: Artifact, kind: WinCategory, name: str) -> None:
""" """
Save icon for Windows-only Get the path for saving Windows-only icon.
:param kind: The kind of this icon.
:param name: The name of saved artifact. The file extension ".ico" is not required. :param name: The name of saved artifact. The file extension ".ico" is not required.
""" """
LOGGER.info( return self.__output_artifact(_ART_WIN, str(kind), f"{name}.ico")
'Saving Windows-only artifact with kind "%s" and name "%s".', kind, name
)
check_vart(art)
artrdr.render_ico(art, self.__win_artifact(kind, name))
def dup_win_artifact( def mac_output_artifact(self, kind: MacCategory, name: str) -> Path:
self, src_kind: WinCategory, src_name: str, dst_kind: WinCategory, dst_name: str
) -> None:
LOGGER.info(
'Duplicating Windows-only artifact from kind "%s" name "%s" to kind "%s" name "%s".',
src_kind,
src_name,
dst_kind,
dst_name,
)
artio.dup_artifact(
self.__win_artifact(src_kind, src_name),
self.__win_artifact(dst_kind, dst_name),
)
def __mac_artifact(self, kind: MacCategory, name: str) -> Path:
return self.output_artifact(_ART_MAC, str(kind), f"{name}.icns")
def save_mac_artifact(self, art: Artifact, kind: MacCategory, name: str) -> None:
""" """
Save icon for macOS-only Get the path for saving macOS-only icon.
:param kind: The kind of this icon.
:param name: The name of saved artifact. The file extension ".icns" is not required. :param name: The name of saved artifact. The file extension ".icns" is not required.
""" """
LOGGER.info( return self.__output_artifact(_ART_MAC, str(kind), f"{name}.icns")
'Saving macOS-only artifact with kind "%s" and name "%s".', kind, name
)
check_vart(art)
artrdr.render_icns(art, self.__mac_artifact(kind, name))
def dup_mac_artifact(
self, src_kind: MacCategory, src_name: str, dst_kind: MacCategory, dst_name: str
) -> None:
LOGGER.info(
'Duplicating macOS-only artifact from kind "%s" name "%s" to kind "%s" name "%s".',
src_kind,
src_name,
dst_kind,
dst_name,
)
artio.dup_artifact(
self.__mac_artifact(src_kind, src_name),
self.__mac_artifact(dst_kind, dst_name),
)
# endregion # endregion
@@ -0,0 +1,35 @@
import enum
class FdContext(enum.StrEnum):
Actions = "actions"
Animations = "animations"
Applications = "apps"
Categories = "categories"
Devices = "devices"
Emblems = "emblems"
Emotes = "emotes"
International = "intl"
MimeTypes = "mimetypes"
Places = "places"
Status = "status"
class WinCategory(enum.StrEnum):
Application = "apps"
"""Icons for applications"""
Extension = "exts"
"""Icons for file extensions"""
class MacCategory(enum.StrEnum):
Application = "apps"
"""Icons for applications"""
Extension = "exts"
"""Icons for file extensions"""
# TODO: For easy testing, we only build 32x32 and 48x48.
# Enable full generation after testing.
FD_THUMBNAIL_HWS: tuple[int, ...] = (32, 48)
# FD_THUMBNAIL_HWS: tuple[int, ...] = (16, 32, 48, 64, 128, 256)
@@ -1,7 +1,7 @@
from .common import Sink from .common import Sink
from .tempdir_sink import TempDirSink from .tempart_sink import TempArtSink
__all__ = [ __all__ = [
"Sink", "Sink",
"TempDirSink" "TempArtSink"
] ]
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from ..utils import Artifact from ..address import Address
class Sink(ABC): class Sink(ABC):
@@ -7,15 +7,6 @@ class Sink(ABC):
Abstract base class for all sinks. Abstract base class for all sinks.
""" """
@property
@abstractmethod @abstractmethod
def push_artifact(self, art: Artifact) -> None: def address(self) -> Address: ...
"""Push given artifact into this sink."""
pass
@abstractmethod
def __hash__(self) -> int:
pass
@abstractmethod
def __eq__(self, other) -> bool:
pass
@@ -0,0 +1,42 @@
from dataclasses import dataclass
from .common import Sink
from ..address import Address, AddressDomain
from ..environment import Environment
from ..platform import FdContext, FD_THUMBNAIL_HWS
from ..utils import Artifact
from ...lowlevel import artio, artrdr
from ...logger import HIGHLEVEL_LOGGER as LOGGER
@dataclass(frozen=True)
class FdArtSinkHint:
kind: FdContext
name: str
class FdArtSink(Sink):
__kind: FdContext
__name: str
def __init__(self, kind: FdContext, name: str) -> None:
self.__kind = kind
self.__name = name
@property
def address(self) -> Address:
return Address(AddressDomain.FdOutput, str(self.__kind), self.__name)
def push(self, env: Environment, art: Artifact) -> None:
LOGGER.info(
'Saving FreeDesktop artifact with kind "%s" and name "%s".',
self.__kind,
self.__name,
)
for hw, thumbnail in zip(
FD_THUMBNAIL_HWS, artrdr.render_thumbnail(art, FD_THUMBNAIL_HWS)
):
dst = env.fd_output_artifact(hw, self.__kind, self.__name)
artio.save_artifact(thumbnail, dst)
def push_hint(self) -> FdArtSinkHint:
return FdArtSinkHint(self.__kind, self.__name)
@@ -0,0 +1,38 @@
from dataclasses import dataclass
from .common import Sink
from ..address import Address, AddressDomain
from ..environment import Environment
from ..platform import MacCategory
from ..utils import Artifact
from ...lowlevel import artrdr
from ...logger import HIGHLEVEL_LOGGER as LOGGER
@dataclass(frozen=True)
class MacArtSinkHint:
kind: MacCategory
name: str
class MacArtSink(Sink):
__kind: MacCategory
__name: str
def __init__(self, kind: MacCategory, name: str) -> None:
self.__kind = kind
self.__name = name
@property
def address(self) -> Address:
return Address(AddressDomain.MacOutput, str(self.__kind), self.__name)
def push(self, env: Environment, art: Artifact) -> None:
LOGGER.info(
'Saving macOS-only artifact with kind "%s" and name "%s".',
self.__kind,
self.__name,
)
artrdr.render_ico(art, env.mac_output_artifact(self.__kind, self.__name))
def push_hint(self) -> MacArtSinkHint:
return MacArtSinkHint(self.__kind, self.__name)
@@ -0,0 +1,32 @@
from pathlib import Path
from .common import Sink
from ..address import Address, AddressDomain
from ..environment import Environment
from ..utils import Artifact
from ...lowlevel import artio
from ...logger import HIGHLEVEL_LOGGER as LOGGER
class TempArtSink(Sink):
"""
The sink to artifact (PNG-only) located in temporary directory.
All existence of sub-directory should be ensured on your own.
"""
__comps: tuple[str, ...]
def __init__(self, *args: str) -> None:
self.__comps = tuple(args)
@property
def address(self) -> Address:
return Address(AddressDomain.Temporary, *self.__comps)
def push(self, env: Environment, art: Artifact) -> None:
p = self.push_hint(env)
LOGGER.info("Saving temporary artifact: %s", p)
artio.save_artifact(art, p)
def push_hint(self, env: Environment) -> Path:
return env.user_temporary_artifact(*self.__comps)
@@ -1,24 +0,0 @@
from ..utils import Artifact
from .common import Sink
class TempDirSink(Sink):
__path_comps: tuple[str, ...]
def __init__(self, *args: str) -> None:
self.__path_comps = tuple(args)
def push_artifact(self, art: Artifact) -> None:
pass
def __hash__(self) -> int:
return hash(self.__path_comps)
def __eq__(self, other) -> bool:
if self is other:
return True
if not isinstance(other, TempDirSink):
return NotImplemented
return self.__path_comps == other.__path_comps
@@ -0,0 +1,38 @@
from dataclasses import dataclass
from .common import Sink
from ..address import Address, AddressDomain
from ..environment import Environment
from ..platform import WinCategory
from ..utils import Artifact
from ...lowlevel import artrdr
from ...logger import HIGHLEVEL_LOGGER as LOGGER
@dataclass(frozen=True)
class WinArtSinkHint:
kind: WinCategory
name: str
class WinArtSink(Sink):
__kind: WinCategory
__name: str
def __init__(self, kind: WinCategory, name: str) -> None:
self.__kind = kind
self.__name = name
@property
def address(self) -> Address:
return Address(AddressDomain.WinOutput, str(self.__kind), self.__name)
def push(self, env: Environment, art: Artifact) -> None:
LOGGER.info(
'Saving Windows-only artifact with kind "%s" and name "%s".',
self.__kind,
self.__name,
)
artrdr.render_ico(art, env.win_output_artifact(self.__kind, self.__name))
def push_hint(self) -> WinArtSinkHint:
return WinArtSinkHint(self.__kind, self.__name)
@@ -1,7 +1,7 @@
from .common import Source from .common import Source
from .tempdir_source import TempDirSource from .tempart_source import TempArtSource
__all__ = [ __all__ = [
"Source", "Source",
"TempDirSource" "TempArtSource"
] ]
@@ -0,0 +1,58 @@
from pathlib import Path
from typing import Optional
from .common import Source
from ..address import Address
from ..environment import Environment
from ..sink.tempart_sink import TempArtSink
from ..utils import Artifact, VART_HW
from ...lowlevel import artio, artrdr
from ...lowlevel.artrdr import BlenderConfig
from ...logger import HIGHLEVEL_LOGGER as LOGGER
DEFAULT_BLD_CONFIG = artrdr.BlenderConfig(VART_HW, VART_HW)
class BlenderSource(Source):
"""The source to Blender composition located in input directory."""
__comps: tuple[str, ...]
def __init__(self, *args: str) -> None:
self.__comps = tuple(args)
@property
def address(self) -> Address | None:
return None
def pull(
self, env: Environment, cfg: BlenderConfig = DEFAULT_BLD_CONFIG
) -> Artifact:
dst = self.__render(env, None, cfg)
return artio.load_artifact(dst)
def pull_to_temp(
self,
env: Environment,
temp_sink: TempArtSink,
cfg: BlenderConfig = DEFAULT_BLD_CONFIG,
) -> None:
self.__render(env, temp_sink.push_hint(env), cfg)
def __render(
self, env: Environment, dst: Optional[Path], cfg: BlenderConfig
) -> Path:
"""
Internal used function for rendering Blender composition.
:return: The path to destination storing render result.
"""
src = env.input_artifact(*self.__comps)
temp_script = env.fetch_lib_blender_script_temporary_artifact()
if dst is None:
LOGGER.info("Rendering temporary Blender composition: %s", src)
dst = env.allocate_lib_render_temporary_artifact()
else:
LOGGER.info("Rendering Blender composition: %s -> %s", src, dst)
artrdr.render_blender(src, dst, temp_script, cfg)
return dst
@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from ..utils import Artifact from ..address import Address
class Source(ABC): class Source(ABC):
@@ -7,15 +7,14 @@ class Source(ABC):
Abstract base class for all sources. Abstract base class for all sources.
""" """
@property
@abstractmethod @abstractmethod
def pull_artifact(self) -> Artifact: def address(self) -> Address | None:
"""Pull artifact from this source.""" """
pass Get the address representation of this source.
@abstractmethod :return: The address representing this source.
def __hash__(self) -> int: ``None`` if there is no producer for this source.
pass It means that this source do not depend anything.
"""
@abstractmethod ...
def __eq__(self, other) -> bool:
pass
@@ -0,0 +1,35 @@
from .common import Source
from ..address import Address, AddressDomain
from ..environment import Environment
from ..sink.fdart_sink import FdArtSink
from ..platform import FdContext, FD_THUMBNAIL_HWS
from ...lowlevel import artio
from ...logger import HIGHLEVEL_LOGGER as LOGGER
class FdOutArtSource(Source):
__kind: FdContext
__name: str
def __init__(self, kind: FdContext, name: str) -> None:
self.__kind = kind
self.__name = name
@property
def address(self) -> Address:
return Address(AddressDomain.FdOutput, str(self.__kind), self.__name)
def pull_to_fd(self, env: Environment, fd_sink: FdArtSink) -> None:
sink_hint = fd_sink.push_hint()
LOGGER.info(
'Duplicating FreeDesktop artifact from kind "%s" name "%s" to kind "%s" name "%s".',
sink_hint.kind,
sink_hint.name,
self.__kind,
self.__name,
)
for hw in FD_THUMBNAIL_HWS:
artio.link_artifact(
env.fd_output_artifact(hw, sink_hint.kind, sink_hint.name),
env.fd_output_artifact(hw, self.__kind, self.__name),
)
@@ -0,0 +1,24 @@
from .common import Source
from ..address import Address
from ..environment import Environment
from ..utils import Artifact
from ...lowlevel import artio
from ...logger import HIGHLEVEL_LOGGER as LOGGER
class InArtSource(Source):
"""The source to bimap artifact (PNG, JPEG or BMP) located in input directory."""
__comps: tuple[str, ...]
def __init__(self, *args: str) -> None:
self.__comps = tuple(args)
@property
def address(self) -> Address | None:
return None
def pull(self, env: Environment) -> Artifact:
p = env.input_artifact(*self.__comps)
LOGGER.info("Loading input artifact: %s", p)
return artio.load_input_artifact(p)
@@ -0,0 +1,34 @@
from .common import Source
from ..address import Address, AddressDomain
from ..environment import Environment
from ..sink.macart_sink import MacArtSink
from ..platform import MacCategory
from ...lowlevel import artio
from ...logger import HIGHLEVEL_LOGGER as LOGGER
class MacOutArtSource(Source):
__kind: MacCategory
__name: str
def __init__(self, kind: MacCategory, name: str) -> None:
self.__kind = kind
self.__name = name
@property
def address(self) -> Address:
return Address(AddressDomain.MacOutput, str(self.__kind), self.__name)
def pull_to_fd(self, env: Environment, mac_sink: MacArtSink) -> None:
sink_hint = mac_sink.push_hint()
LOGGER.info(
'Duplicating Macdows-only artifact from kind "%s" name "%s" to kind "%s" name "%s".',
sink_hint.kind,
sink_hint.name,
self.__kind,
self.__name,
)
artio.link_artifact(
env.mac_output_artifact(sink_hint.kind, sink_hint.name),
env.mac_output_artifact(self.__kind, self.__name),
)
@@ -0,0 +1,16 @@
from .common import Source
from ..address import Address
from ..utils import Artifact, VART_HW
from ...lowlevel import artio
class NewArtSource(Source):
def __init__(self) -> None:
pass
@property
def address(self) -> Address | None:
return None
def pull(self) -> Artifact:
return artio.new_artifact((VART_HW, VART_HW))
@@ -0,0 +1,52 @@
from pathlib import Path
from typing import Optional
from .common import Source
from ..address import Address
from ..environment import Environment
from ..sink.tempart_sink import TempArtSink
from ..utils import Artifact, VART_HW
from ...lowlevel import artio, artrdr
from ...lowlevel.artrdr import SvgConfig
from ...logger import HIGHLEVEL_LOGGER as LOGGER
DEFAULT_SVG_CONFIG = SvgConfig(VART_HW, VART_HW)
class SvgSource(Source):
"""The source to SVG located in input directory."""
__comps: tuple[str, ...]
def __init__(self, *args: str) -> None:
self.__comps = tuple(args)
@property
def address(self) -> Address | None:
return None
def pull(self, env: Environment, cfg: SvgConfig = DEFAULT_SVG_CONFIG) -> Artifact:
dst = self.__render(env, None, cfg)
return artio.load_artifact(dst)
def pull_to_temp(
self,
env: Environment,
temp_sink: TempArtSink,
cfg: SvgConfig = DEFAULT_SVG_CONFIG,
) -> None:
self.__render(env, temp_sink.push_hint(env), cfg)
def __render(self, env: Environment, dst: Optional[Path], cfg: SvgConfig) -> Path:
"""
Internal used function for rendering SVG.
:return: The path to destination storing render result.
"""
src = env.input_artifact(*self.__comps)
if dst is None:
LOGGER.info("Rendering temporary SVG: %s", src)
dst = env.allocate_lib_render_temporary_artifact()
else:
LOGGER.info("Rendering SVG: %s -> %s", src, dst)
artrdr.render_svg(src, dst, env.prologue.svg_render, cfg)
return dst
@@ -0,0 +1,24 @@
from .common import Source
from ..address import Address, AddressDomain
from ..environment import Environment
from ..utils import Artifact
from ...lowlevel import artio
from ...logger import HIGHLEVEL_LOGGER as LOGGER
class TempArtSource(Source):
"""The source to artifact (PNG-only) located in temporary directory."""
__comps: tuple[str, ...]
def __init__(self, *args: str) -> None:
self.__comps = tuple(args)
@property
def address(self) -> Address | None:
return Address(AddressDomain.Temporary, *self.__comps)
def pull(self, env: Environment) -> Artifact:
p = env.user_temporary_artifact(*self.__comps)
LOGGER.info("Loading temporary artifact: %s", p)
return artio.load_artifact(p)
@@ -1,24 +0,0 @@
from ..utils import Artifact
from .common import Source
class TempDirSource(Source):
__path_comps: tuple[str, ...]
def __init__(self, *args: str) -> None:
self.__path_comps = tuple(args)
def pull_artifact(self) -> Artifact:
pass
def __hash__(self) -> int:
return hash(self.__path_comps)
def __eq__(self, other) -> bool:
if self is other:
return True
if not isinstance(other, TempDirSource):
return NotImplemented
return self.__path_comps == other.__path_comps
@@ -0,0 +1,34 @@
from .common import Source
from ..address import Address, AddressDomain
from ..environment import Environment
from ..sink.winart_sink import WinArtSink
from ..platform import WinCategory
from ...lowlevel import artio
from ...logger import HIGHLEVEL_LOGGER as LOGGER
class WinOutArtSource(Source):
__kind: WinCategory
__name: str
def __init__(self, kind: WinCategory, name: str) -> None:
self.__kind = kind
self.__name = name
@property
def address(self) -> Address:
return Address(AddressDomain.WinOutput, str(self.__kind), self.__name)
def pull_to_fd(self, env: Environment, win_sink: WinArtSink) -> None:
sink_hint = win_sink.push_hint()
LOGGER.info(
'Duplicating Windows-only artifact from kind "%s" name "%s" to kind "%s" name "%s".',
sink_hint.kind,
sink_hint.name,
self.__kind,
self.__name,
)
artio.link_artifact(
env.win_output_artifact(sink_hint.kind, sink_hint.name),
env.win_output_artifact(self.__kind, self.__name),
)
@@ -1,4 +1,4 @@
from ..lowlevel.utils import Artifact, ArtifactFile from ..lowlevel.utils import Artifact
VART_HW: int = 1024 VART_HW: int = 1024
"""The height or width of Vanilla Artifact.""" """The height or width of Vanilla Artifact."""
@@ -1,10 +1,24 @@
import shutil import shutil
import os
import sys
import enum
from pathlib import Path from pathlib import Path
import PIL.Image import PIL.Image
from .utils import Artifact, ArtifactFile from .utils import Artifact
from ..logger import LOWLEVEL_LOGGER as LOGGER from ..logger import LOWLEVEL_LOGGER as LOGGER
_LINK_MODE_ENV: str = "ASSEMBLICON_LINK_MODE"
"""The environment variable selecting how artifacts are linked."""
class LinkMode(enum.StrEnum):
"""All valid values of the ``ASSEMBLICON_LINK_MODE`` environment variable."""
Copy = "copy"
Symlink = "symlink"
def new_artifact(size: tuple[int, int]) -> Artifact: def new_artifact(size: tuple[int, int]) -> Artifact:
""" """
Create new RGBA empty (#ffffff00) artifact with given size. Create new RGBA empty (#ffffff00) artifact with given size.
@@ -12,7 +26,7 @@ def new_artifact(size: tuple[int, int]) -> Artifact:
return PIL.Image.new("RGBA", size, "#ffffff00") return PIL.Image.new("RGBA", size, "#ffffff00")
def load_input_artifact(p: Path) -> ArtifactFile: def load_input_artifact(p: Path) -> Artifact:
""" """
Load bitmap artifact (PNG, JPEG or BMP) which usually is located in input directory. Load bitmap artifact (PNG, JPEG or BMP) which usually is located in input directory.
""" """
@@ -20,7 +34,7 @@ def load_input_artifact(p: Path) -> ArtifactFile:
return PIL.Image.open(p, "r", ["PNG", "JPEG", "BMP"]) return PIL.Image.open(p, "r", ["PNG", "JPEG", "BMP"])
def load_artifact(p: Path) -> ArtifactFile: def load_artifact(p: Path) -> Artifact:
""" """
Load bitmap artifact (PNG-only) saved previously as the intermediary. Load bitmap artifact (PNG-only) saved previously as the intermediary.
""" """
@@ -36,3 +50,93 @@ def save_artifact(art: Artifact, p: Path) -> None:
def dup_artifact(src: Path, dst: Path) -> None: def dup_artifact(src: Path, dst: Path) -> None:
LOGGER.debug("Duplicating artifact: %s -> %s", src, dst) LOGGER.debug("Duplicating artifact: %s -> %s", src, dst)
shutil.copyfile(src, dst) shutil.copyfile(src, dst)
def link_artifact(src: Path, dst: Path) -> None:
"""
Make given artifact reachable from another path, either via a
relative symlink or a content copy.
The linking mode is controlled by the ``ASSEMBLICON_LINK_MODE``
environment variable: ``symlink`` (default) creates a symlink with a
path relative to the destination; ``copy`` duplicates the artifact
content. An existing destination is only removed after it is proven
to be a regular file, or a symlink whose target is a file path
(the target is allowed to be missing), and only when it shares the
same filename suffix as the source. Symlink creation failure falls
back to copying.
:param src: The path to source artifact file.
:param dst: The path to destination which should reach the artifact.
:raises ValueError: If the link mode is unknown, the destination
exists but is not safely deletable (see above), its suffix
differs from the source one, or no common directory exists
between source and destination.
"""
try:
mode = LinkMode(os.getenv(_LINK_MODE_ENV, LinkMode.Symlink))
except ValueError:
raise ValueError(
f"Invalid link mode for env {_LINK_MODE_ENV}. "
f"Valid modes are: {', '.join(m.value for m in LinkMode)}."
)
LOGGER.debug("Linking artifact: %s -> %s (mode: %s)", src, dst, mode)
# SAFETY: the code below deletes an existing destination file. These
# checks are the only guards against accidental data deletion, so
# never relax them. A destination is deletable only when it is a
# regular file, or a symlink whose target is a file path (the target
# itself is allowed to be missing, i.e. a broken symlink is
# deletable). Additionally, the source and the destination must
# share the same filename suffix.
if os.path.lexists(dst):
if dst.is_symlink():
# a symlink is deletable unless it provably points to a
# non-file, e.g. an existing directory
if dst.exists() and not dst.is_file():
raise ValueError(
f'Refusing to remove existing destination "{dst}": '
f"it is a symlink not pointing to a file."
)
elif not dst.is_file():
raise ValueError(
f'Refusing to remove existing destination "{dst}": '
f"it is not a regular file."
)
if src.suffix != dst.suffix:
raise ValueError(
f'Refusing to remove existing destination "{dst}": its '
f'suffix "{dst.suffix}" differs from source suffix "{src.suffix}".'
)
dst.unlink()
if mode == LinkMode.Copy:
shutil.copyfile(src, dst)
return
# symlinks are always created with a path relative to the
# destination, so that the whole artifact directory stays relocatable
try:
link_target = src.relative_to(dst.parent, walk_up=True)
except ValueError:
raise ValueError(
f'No common directory between "{src}" and "{dst}", '
f"can not create relative symlink."
)
try:
dst.symlink_to(link_target)
except OSError as e:
LOGGER.warning(
"Fail to create symlink for artifact %s: %s. Falling back to copy.",
dst,
e,
)
if sys.platform == "win32":
LOGGER.warning(
"Symlink creation on Windows usually requires administrator "
"privileges. Consider rerunning as administrator, enabling "
"Developer Mode, or setting %s=copy.",
_LINK_MODE_ENV,
)
shutil.copyfile(src, dst)
@@ -0,0 +1,68 @@
import tempfile
from dataclasses import dataclass
from types import TracebackType
from pathlib import Path
@dataclass(frozen=True)
class Prologue:
input_directory: Path
"""The path to input directory."""
output_directory: Path
"""The path to output directory."""
def __post_init__(self) -> None:
if not self.input_directory.is_dir():
raise ValueError(
f"Given input path {self.input_directory} is not an existing directory."
)
if not self.output_directory.is_dir():
raise ValueError(
f"Given output path {self.output_directory} is not an existing directory."
)
class Environment:
__prologue: Prologue
"""The prologue used for environment."""
__temp_dir: tempfile.TemporaryDirectory[str]
"""Allocated temporary directory."""
def __init__(self, prologue: Prologue) -> None:
self.__prologue = prologue
self.__temp_dir = tempfile.TemporaryDirectory()
def __enter__(self) -> "Environment":
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
self.__temp_dir.cleanup()
def input_artifact(self, *args: str) -> Path:
"""
Get the path to input artifact.
:return: The built absolute path to input artifact.
"""
return self.__prologue.input_directory / Path(*args)
def temporary_artifact(self, *args: str) -> Path:
"""
Get the path to temporary artifact.
:return: The built absolute path to temporary artifact.
"""
return Path(self.__temp_dir.name) / Path(*args)
def output_artifact(self, *args: str) -> Path:
"""
Get the path to output artifact.
:return: The built absolute path to output artifact.
"""
return self.__prologue.output_directory / Path(*args)
@@ -1,5 +1,3 @@
from PIL.Image import Image from PIL.Image import Image
from PIL.ImageFile import ImageFile
type Artifact = Image type Artifact = Image
type ArtifactFile = ImageFile
@@ -24,7 +24,7 @@ IMAGE_INNER_RECT = Rectangle(Point(20 * 4, 52 * 4), Point(236 * 4, 204 * 4))
def build_image_base_icon(env: AuroraEnvironment) -> None: def build_image_base_icon(env: AuroraEnvironment) -> None:
env.render_svg( env.render_svg(
env.input_artifact("component", "image-base.svg"), env.input_artifact("component", "image-base.svg"),
env.temporary_artifact("component", "image-base.png"), env.__temporary_artifact("component", "image-base.png"),
) )
@@ -14,7 +14,7 @@ def register(ctx: AuroraContext) -> None:
def build_generic_background(env: AuroraEnvironment) -> None: def build_generic_background(env: AuroraEnvironment) -> None:
env.render_svg( env.render_svg(
env.input_artifact("component", "generic-background.svg"), env.input_artifact("component", "generic-background.svg"),
env.temporary_artifact("component", "generic-background.png"), env.__temporary_artifact("component", "generic-background.png"),
) )
@@ -13,7 +13,7 @@ def build_dialog_infomation_icon(env: AuroraEnvironment) -> None:
# save dialog icon with FreeDesktop icon and temp icon for reuse. # save dialog icon with FreeDesktop icon and temp icon for reuse.
art = env.render_svg_then_load( art = env.render_svg_then_load(
env.input_artifact("dialog-infomation.svg"), env.input_artifact("dialog-infomation.svg"),
env.temporary_artifact("dialog-infomation.png"), env.__temporary_artifact("dialog-infomation.png"),
) )
with art: with art:
env.save_fd_artifact(art, FdContext.Status, "dialog-infomation") env.save_fd_artifact(art, FdContext.Status, "dialog-infomation")
@@ -23,7 +23,7 @@ def build_dialog_warning_icon(env: AuroraEnvironment) -> None:
# save dialog icon with FreeDesktop icon and temp icon for reuse. # save dialog icon with FreeDesktop icon and temp icon for reuse.
art = env.render_svg_then_load( art = env.render_svg_then_load(
env.input_artifact("dialog-warning.svg"), env.input_artifact("dialog-warning.svg"),
env.temporary_artifact("dialog-warning.png"), env.__temporary_artifact("dialog-warning.png"),
) )
with art: with art:
env.save_fd_artifact(art, FdContext.Status, "dialog-warning") env.save_fd_artifact(art, FdContext.Status, "dialog-warning")
@@ -33,7 +33,7 @@ def build_dialog_error_icon(env: AuroraEnvironment) -> None:
# save dialog icon with FreeDesktop icon and temp icon for reuse. # save dialog icon with FreeDesktop icon and temp icon for reuse.
art = env.render_svg_then_load( art = env.render_svg_then_load(
env.input_artifact("dialog-error.svg"), env.input_artifact("dialog-error.svg"),
env.temporary_artifact("dialog-error.png"), env.__temporary_artifact("dialog-error.png"),
) )
with art: with art:
env.save_fd_artifact(art, FdContext.Status, "dialog-error") env.save_fd_artifact(art, FdContext.Status, "dialog-error")