feat: refactor assemblicon (2/3) context job topology

This commit is contained in:
2026-09-06 13:35:47 +08:00
parent 43e3bd39ef
commit f650d3f98a
14 changed files with 182 additions and 85 deletions
@@ -35,3 +35,6 @@ class Address:
self.__domain == other.__domain
and self.__subdirectories == other.__subdirectories
)
def __repr__(self) -> str:
return f"Address({self.__domain.name}, {'/'.join(self.__subdirectories)})"
@@ -1,27 +1,37 @@
from collections import deque
from dataclasses import dataclass
from typing import Callable, Iterable, TypeVar, Generic
from typing import Iterable, TypeVar, Generic, Protocol, Callable, cast
from .environment import Environment, Prologue
from .address import Address
from .source import Source
from .sink import Sink
from ..logger import HIGHLEVEL_LOGGER as LOGGER
P = TypeVar("P", bound=Prologue)
class Executor[*Ts](Protocol):
def __call__(self, *args: *Ts) -> None: ...
@dataclass(frozen=True)
class Job(Generic[P]):
"""The class represents a job."""
class Job:
"""
The class represents a type-erased job.
Precise typing lives only in register's signature.
"""
name: str
"""The name of this job."""
dependencies: set[str]
"""The dependencies of this job. Each item is a job name. Empty for no dependencies."""
executor: Callable[[Environment[P]], None]
ios: tuple[Source | Sink, ...]
"""The sources and sinks of this job."""
executor: Callable[..., None]
"""The executor of this job."""
class Context(Generic[P]):
__jobs: dict[str, Job[P]]
__jobs: dict[str, Job]
"""The dict storing all jobs. Key is job name and value is job payload."""
__env: Environment[P]
"""The shared environment passed to every job executor."""
@@ -39,26 +49,76 @@ class Context(Generic[P]):
executed sequentially with the shared environment. The chain is
aborted on the first failing executor.
Job dependencies are not declared explicitly. They are inferred
from the job IO declarations instead: a job depends on the
producer of every address required by one of its sources.
:param selected: Only build jobs matching these given names.
Their dependencies are always built first. None for building
all jobs.
:raises ValueError: If a selected job name is unknown, some
address is produced more than once (by one or several
jobs), some required address has no producer at all, or the
inferred dependency graph contains a cycle (including
self-dependencies).
"""
# Step 1: resolve the selection closure. Collect the selected jobs
# themselves plus all their transitive dependencies into "needed".
needed: set[str] = set()
if selected is None:
needed = set(self.__jobs.keys())
else:
pending = list(selected)
while pending:
name = pending.pop()
if name in needed:
# Step 0: build the producer table over all registered jobs,
# mapping every produced address to its producing job name.
# An address produced more than once is rejected right here,
# even if it is never actually required: the producers may be
# several jobs, or a single job declaring several identical
# sinks.
producers: dict[Address, str] = {}
for name, job in self.__jobs.items():
for item in job.ios:
if not isinstance(item, Sink):
continue
job = self.__jobs.get(name)
if job is None:
raise ValueError(f'Unknown job name: "{name}"')
needed.add(name)
pending.extend(job.dependencies)
addr = item.address
prev = producers.get(addr)
if prev is not None and prev == name:
raise ValueError(
f'Job "{name}" declares multiple sinks '
f"producing the same address {addr!r}."
)
if prev is not None:
raise ValueError(
f"Address {addr!r} is produced by both job "
f'"{prev}" and "{name}".'
)
producers[addr] = name
# Step 1: resolve the selection closure. Collect the selected
# jobs themselves plus, transitively, every job producing an
# address one of their sources requires. A source whose address
# is ``None`` depends on nothing.
needed: set[str] = set()
pending = list(self.__jobs.keys()) if selected is None else list(selected)
while pending:
name = pending.pop()
if name in needed:
continue
job = self.__jobs.get(name)
if job is None:
raise ValueError(f'Unknown job name: "{name}"')
needed.add(name)
for item in job.ios:
if not isinstance(item, Source):
continue
addr = item.address
if addr is None:
continue
producer = producers.get(addr)
if producer is None:
raise ValueError(
f'Job "{name}" requires resource {addr!r} '
f"but no registered job produces it."
)
if producer == name:
raise ValueError(
f'Job "{name}" requires resource {addr!r} '
f"which is produced by itself."
)
pending.append(producer)
# Step 2: Kahn topological sort over the sub-graph induced by "needed".
# "in_degree": key is a job name, value is how many of its (needed)
@@ -68,7 +128,17 @@ class Context(Generic[P]):
in_degree: dict[str, int] = {name: 0 for name in needed}
dependents: dict[str, list[str]] = {name: [] for name in needed}
for name in needed:
for dep in self.__jobs[name].dependencies:
# "deps": the names of jobs producing resources this job
# requires, deduplicated
deps: set[str] = set()
for item in self.__jobs[name].ios:
if not isinstance(item, Source):
continue
addr = item.address
if addr is None:
continue
deps.add(producers[addr])
for dep in deps:
dependents[dep].append(name)
in_degree[name] += 1
queue = deque(
@@ -86,29 +156,35 @@ class Context(Generic[P]):
cyclic = sorted(needed - set(order))
raise ValueError(f"Dependency cycle detected among jobs: {cyclic}")
# Step 3: execute the build chain in resolved order.
# Step 3: execute the build chain in resolved order, feeding
# the shared environment and the declared IO items to each
# executor.
for name in order:
LOGGER.info('Running job "%s"...', name)
self.__jobs[name].executor(self.__env)
self.__jobs[name].executor(self.__env, *self.__jobs[name].ios)
def register(
def register[*Ts](
self,
name: str,
deps: Iterable[str],
exec: Callable[[Environment[P]], None],
ios: tuple[*Ts],
executor: Executor[Environment[P], *Ts],
) -> None:
"""
Register a new job with given name, dependencies and executor.
:param name: The name of job.
:param deps: An iterable whose each item is the name of dependencies of this job.
:param ios: A tuple declares ths sources and sinks of this job.
These sources and sinks will be directly passed to executor.
:param exec: The executor of this job.
"""
# check job name collision
if name in self.__jobs:
raise ValueError(f'Can not register 2 jobs with same name: "{name}"')
# check the type of sources and sinks
for item in ios:
if not isinstance(item, (Source, Sink)):
raise TypeError(
"passes source or sink instance must be the subclass of Source or Sink"
)
job_deps = set(deps)
if name in job_deps:
raise ValueError(f'Self-dependency is not allowed in job: "{name}"')
self.__jobs[name] = Job(name, job_deps, exec)
self.__jobs[name] = Job(name, cast(tuple[Source | Sink, ...], ios), executor)
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import TypeVar, Generic, Callable
from ..lowlevel.environment import Prologue as LowPrologue, Environment as LowEnv
from ..lowlevel.artrdr import SvgRenderKind
from .platform import FdContext, WinCategory, MacCategory, FD_THUMBNAIL_HWS
from .utils import FdContext, WinCategory, MacCategory, FD_THUMBNAIL_HWS
@dataclass(frozen=True)
@@ -1,35 +0,0 @@
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,13 @@
from .common import Sink
from .tempart_sink import TempArtSink
from .fdart_sink import FdArtSink
from .winart_sink import WinArtSink
from .macart_sink import MacArtSink
__all__ = [
"Sink",
"TempArtSink"
"TempArtSink",
"FdArtSink",
"WinArtSink",
"MacArtSink"
]
@@ -2,8 +2,7 @@ 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 ..utils import Artifact, FdContext, FD_THUMBNAIL_HWS
from ...lowlevel import artio, artrdr
from ...logger import HIGHLEVEL_LOGGER as LOGGER
@@ -2,8 +2,7 @@ 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 ..utils import Artifact, MacCategory
from ...lowlevel import artrdr
from ...logger import HIGHLEVEL_LOGGER as LOGGER
@@ -2,8 +2,7 @@ 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 ..utils import Artifact, WinCategory
from ...lowlevel import artrdr
from ...logger import HIGHLEVEL_LOGGER as LOGGER
@@ -1,7 +1,22 @@
from .common import Source
from .newart_source import NewArtSource
from .bitmap_source import BitmapSource
from .svg_source import SvgSource
from .blender_source import BlenderSource
from .tempart_source import TempArtSource
from .fdart_source import FdArtSource
from .winart_source import WinArtSource
from .macart_source import MacArtSource
__all__ = [
"Source",
"TempArtSource"
"NewArtSource",
"BitmapSource",
"SvgSource",
"BlenderSource",
"TempArtSource",
"FdArtSource",
"WinArtSource",
"MacArtSource",
]
@@ -6,7 +6,7 @@ from ...lowlevel import artio
from ...logger import HIGHLEVEL_LOGGER as LOGGER
class InArtSource(Source):
class BitmapSource(Source):
"""The source to bimap artifact (PNG, JPEG or BMP) located in input directory."""
__comps: tuple[str, ...]
@@ -2,12 +2,12 @@ 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 ..utils import FdContext, FD_THUMBNAIL_HWS
from ...lowlevel import artio
from ...logger import HIGHLEVEL_LOGGER as LOGGER
class FdOutArtSource(Source):
class FdArtSource(Source):
__kind: FdContext
__name: str
@@ -2,12 +2,12 @@ from .common import Source
from ..address import Address, AddressDomain
from ..environment import Environment
from ..sink.macart_sink import MacArtSink
from ..platform import MacCategory
from ..utils import MacCategory
from ...lowlevel import artio
from ...logger import HIGHLEVEL_LOGGER as LOGGER
class MacOutArtSource(Source):
class MacArtSource(Source):
__kind: MacCategory
__name: str
@@ -2,12 +2,12 @@ from .common import Source
from ..address import Address, AddressDomain
from ..environment import Environment
from ..sink.winart_sink import WinArtSink
from ..platform import WinCategory
from ..utils import WinCategory
from ...lowlevel import artio
from ...logger import HIGHLEVEL_LOGGER as LOGGER
class WinOutArtSource(Source):
class WinArtSource(Source):
__kind: WinCategory
__name: str
@@ -1,3 +1,4 @@
import enum
from ..lowlevel.utils import Artifact
VART_HW: int = 1024
@@ -9,3 +10,37 @@ def check_vart(art: Artifact) -> None:
raise ValueError(
f"The size of general highlevel used artifact must be {VART_HW}x{VART_HW}, got {art.width}x{art.height} instead"
)
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)