mirror of
https://github.com/Heretek-AI/Asset-Manager.git
synced 2026-11-07 10:45:57 -10:00
Initial OSS recreation: action host, Python API, CLI, docs
Clean-room reimplementation of observed interfaces from Anchorpoint v1.38.0 static analysis (manifest schema, automation API surface, CLI shapes). No vendor code or assets. 14 tests passing.
This commit is contained in:
commit
00f00682fe
20 files changed
+1486
No files matched your search
@@ -0,0 +1,3 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Heretek-AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Asset-Manager
|
||||
|
||||
An independent, open-source asset manager for creative teams: a local-first
|
||||
project hub with an **action plug-in host**, **git-first version control**
|
||||
and a **Python automation API**.
|
||||
|
||||
> **Clean-room notice.** Asset-Manager is an original implementation written
|
||||
> from observed interface facts (file formats, CLI shapes, public API
|
||||
> signatures). It contains no vendor code, binaries, artwork or text, is not
|
||||
> affiliated with Anchorpoint Software GmbH, and "Anchorpoint" remains their
|
||||
> trademark. The `anchorpoint` Python module name is kept only so community
|
||||
> plug-ins written against the documented automation API keep importing.
|
||||
|
||||
## What works in v0.1 (all verified by tests)
|
||||
|
||||
- **Action host** — `*.yaml` manifest discovery/validation, 10 registration
|
||||
triggers, `;`-separated file filters, `${var}` substitution, `python` and
|
||||
`command` dispatch with per-action `Context` injection.
|
||||
- **Python API** — `import anchorpoint` (`Context`, `UI`, `Dialog`,
|
||||
`Progress`, VC enums, local helpers) and `import apsync` (project markers,
|
||||
file ops, versioning, sidecar attributes, thumbnail via ffmpeg/ImageMagick).
|
||||
- **CLI** — `assetmgr actions|validate|info|project-init|run|status`.
|
||||
- **Local projects** — `*.approj` JSON markers, `.meta.json` sidecars, offline
|
||||
`git status`.
|
||||
- Server-backed collaboration (locks, channel notifications, membership) is
|
||||
an explicit boundary: those calls raise `ServerRequiredError`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -e ".[test]"
|
||||
pytest
|
||||
assetmgr validate --root actions
|
||||
assetmgr actions --root actions
|
||||
assetmgr project-init ./demo-film --name demo-film
|
||||
assetmgr run example::hello-world --path ./demo-film
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Contents |
|
||||
|---|---|
|
||||
| `assetmgr/` | host: manifests, dispatch, project store, CLI |
|
||||
| `anchorpoint/` | automation API bridge (UI/context side) |
|
||||
| `apsync/` | local project/file engine (store side) |
|
||||
| `actions/` | bundled example actions (`<name>/<name>.yaml` + `.py`) |
|
||||
| `tests/` | pytest suite (run: `pytest`) |
|
||||
| `docs/` | reverse-engineering ledger, schemas, roadmap |
|
||||
|
||||
## Docs
|
||||
|
||||
- `docs/REVERSE_ENGINEERING.md` — evidence ledger: what was observed, how,
|
||||
and what stays unknown.
|
||||
- `docs/ARCHITECTURE.md` — system map and module responsibilities.
|
||||
- `docs/ACTION_YAML.md` — manifest schema reference.
|
||||
- `docs/API.md` — Python API reference and server boundary.
|
||||
- `docs/GAPS.md` — open questions and how to close them.
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Example action: greets the current selection via the host bridge."""
|
||||
|
||||
import anchorpoint
|
||||
|
||||
ctx = anchorpoint.get_context()
|
||||
target = ctx.path or ctx.folder or "(nothing selected)"
|
||||
anchorpoint.UI().show_success("Hello World", f"Hello from {target}")
|
||||
@@ -0,0 +1,15 @@
|
||||
version: "1.0"
|
||||
action:
|
||||
name: Hello World
|
||||
id: example::hello-world
|
||||
version: 1
|
||||
author: Heretek-AI
|
||||
category: examples
|
||||
type: python
|
||||
description: Minimal action used by the test-suite and as a scaffold example.
|
||||
script: hello_world.py
|
||||
register:
|
||||
file:
|
||||
enable: true
|
||||
folder:
|
||||
enable: true
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Headless UI/context bridge for action scripts (``import anchorpoint``).
|
||||
|
||||
The desktop shell injects one :class:`Context` per action run; scripts use
|
||||
``anchorpoint.get_context()`` to reach it. Outside a run (plain ``python``),
|
||||
:func:`get_context` raises — exactly like the reference host, where the
|
||||
bridge only exists inside the application process.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
|
||||
_local = threading.local()
|
||||
|
||||
|
||||
class ServerRequiredError(RuntimeError):
|
||||
"""Operation needs the vendor sync server (locks, notifications, ...)."""
|
||||
|
||||
|
||||
class ActionButtonType(IntEnum):
|
||||
Primary = 0
|
||||
Secondary = 1
|
||||
SecondaryText = 2
|
||||
Danger = 3
|
||||
|
||||
|
||||
class BrowseType(IntEnum):
|
||||
File = 1
|
||||
Folder = 2
|
||||
|
||||
|
||||
class ChangeSource(IntEnum):
|
||||
You = 0
|
||||
Other = 1
|
||||
Action = 2
|
||||
|
||||
|
||||
class TimelineChannelModuleState(IntEnum):
|
||||
NONE = 0
|
||||
UNLOADED = 1
|
||||
UNSYNCED = 2
|
||||
CONFLICTED = 3
|
||||
|
||||
|
||||
class VCConflictHandling(IntEnum):
|
||||
TakeOurs = 0
|
||||
TakeTheirs = 1
|
||||
External = 2
|
||||
Cancel = 3
|
||||
|
||||
|
||||
class VCFileStatus(IntEnum):
|
||||
Unknown = 0
|
||||
New = 1
|
||||
Deleted = 2
|
||||
Modified = 3
|
||||
Renamed = 4
|
||||
Conflicted = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class VCChange:
|
||||
path: str
|
||||
status: VCFileStatus = VCFileStatus.Unknown
|
||||
|
||||
|
||||
@dataclass
|
||||
class Context:
|
||||
"""Values the host populates for one action invocation."""
|
||||
|
||||
type: str = ""
|
||||
path: str = ""
|
||||
folder: str = ""
|
||||
filename: str = ""
|
||||
suffix: str = ""
|
||||
selected_files: list[str] = field(default_factory=list)
|
||||
selected_folders: list[str] = field(default_factory=list)
|
||||
selected_tasks: list[str] = field(default_factory=list)
|
||||
project_id: str = ""
|
||||
project_path: str = ""
|
||||
workspace_id: str = ""
|
||||
yaml_dir: str = ""
|
||||
module_path: str = ""
|
||||
app_dir: str = ""
|
||||
username: str = ""
|
||||
email: str = ""
|
||||
user_id: str = ""
|
||||
inputs: dict = field(default_factory=dict)
|
||||
icon: str = ""
|
||||
icon_color: str = ""
|
||||
|
||||
|
||||
def get_context() -> Context:
|
||||
ctx = getattr(_local, "context", None)
|
||||
if ctx is None:
|
||||
raise RuntimeError("no action context: code runs outside an action dispatch")
|
||||
return ctx
|
||||
|
||||
|
||||
def _push_context(ctx: Context) -> None:
|
||||
_local.context = ctx
|
||||
|
||||
|
||||
def _pop_context() -> None:
|
||||
_local.context = None
|
||||
|
||||
|
||||
class UI:
|
||||
"""Headless stand-in: records toasts, prints to stdout."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.toasts: list[tuple[str, str, str]] = []
|
||||
|
||||
def _toast(self, kind: str, title: str, description: str = "") -> None:
|
||||
self.toasts.append((kind, title, description))
|
||||
print(f"[assetmgr:{kind}] {title} {description}".rstrip())
|
||||
|
||||
def show_success(self, title: str, description: str = "") -> None:
|
||||
self._toast("success", title, description)
|
||||
|
||||
def show_info(self, title: str, description: str = "") -> None:
|
||||
self._toast("info", title, description)
|
||||
|
||||
def show_error(self, title: str, description: str = "") -> None:
|
||||
self._toast("error", title, description)
|
||||
|
||||
|
||||
class Progress:
|
||||
"""Console progress job usable as a context manager."""
|
||||
|
||||
def __init__(self, title: str, text: str = "", infinite: bool = True,
|
||||
cancelable: bool = False) -> None:
|
||||
self.title = title
|
||||
self.text = text
|
||||
self.canceled = False
|
||||
self._value = 0.0
|
||||
|
||||
def __enter__(self) -> "Progress":
|
||||
print(f"[assetmgr:progress:start] {self.title}")
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> None:
|
||||
self.finish()
|
||||
|
||||
def report_progress(self, value: float) -> None:
|
||||
self._value = max(0.0, min(1.0, float(value)))
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
self.text = text
|
||||
|
||||
def finish(self) -> None:
|
||||
print(f"[assetmgr:progress:done] {self.title}")
|
||||
|
||||
def stop_progress(self) -> None:
|
||||
self.finish()
|
||||
|
||||
|
||||
class DialogEntry:
|
||||
"""Fluent dialog builder (headless: records rows, returns defaults)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.rows: list[dict] = []
|
||||
|
||||
def _add(self, kind: str, **kwargs: object) -> "DialogEntry":
|
||||
self.rows.append({"kind": kind, **kwargs})
|
||||
return self
|
||||
|
||||
def add_text(self, text: str, **kwargs: object) -> "DialogEntry":
|
||||
return self._add("text", text=text, **kwargs)
|
||||
|
||||
def add_input(self, default: str = "", placeholder: str = "",
|
||||
var: str = "", **kwargs: object) -> "DialogEntry":
|
||||
return self._add("input", default=default, placeholder=placeholder,
|
||||
var=var, **kwargs)
|
||||
|
||||
def add_checkbox(self, default: bool = False, text: str = "",
|
||||
var: str = "", **kwargs: object) -> "DialogEntry":
|
||||
return self._add("checkbox", default=default, text=text, var=var, **kwargs)
|
||||
|
||||
def add_button(self, text: str, var: str = "",
|
||||
**kwargs: object) -> "DialogEntry":
|
||||
return self._add("button", text=text, var=var, **kwargs)
|
||||
|
||||
def add_dropdown(self, default: str = "", values: list | None = None,
|
||||
var: str = "", **kwargs: object) -> "DialogEntry":
|
||||
return self._add("dropdown", default=default,
|
||||
values=values or [], var=var, **kwargs)
|
||||
|
||||
def get_value(self, var: str) -> object:
|
||||
for row in self.rows:
|
||||
if row.get("var") == var:
|
||||
return row.get("default")
|
||||
return None
|
||||
|
||||
|
||||
class Dialog(DialogEntry):
|
||||
def __init__(self, title: str = "") -> None:
|
||||
super().__init__()
|
||||
self.title = title
|
||||
|
||||
def show(self) -> dict:
|
||||
"""Headless show: returns {var: default} without blocking."""
|
||||
return {r["var"]: r.get("default") for r in self.rows if r.get("var")}
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_cache: dict[str, object] = {}
|
||||
|
||||
|
||||
def get_api(): # lazy import breaks no cycles
|
||||
import apsync
|
||||
return apsync
|
||||
|
||||
|
||||
def temp_dir() -> str:
|
||||
return tempfile.mkdtemp(prefix="assetmgr-")
|
||||
|
||||
|
||||
def get_application_dir() -> str:
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def get_config() -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
def check_application(path: str, info: str = "", name: str | None = None) -> bool:
|
||||
return os.path.exists(path)
|
||||
|
||||
|
||||
def get_alternative_name(path: str) -> str:
|
||||
"""Return a free sibling path by appending `` (n)`` before the suffix."""
|
||||
base, ext = os.path.splitext(path)
|
||||
candidate, n = path, 1
|
||||
while os.path.exists(candidate):
|
||||
n += 1
|
||||
candidate = f"{base} ({n}){ext}"
|
||||
return candidate
|
||||
|
||||
|
||||
def is_network_drive(path: str) -> bool: # network-share detection is OS-specific
|
||||
return False
|
||||
|
||||
|
||||
def log_error(message: str) -> None:
|
||||
print(f"[assetmgr:error] {message}")
|
||||
|
||||
|
||||
def log_success(message: str) -> None:
|
||||
print(f"[assetmgr:ok] {message}")
|
||||
|
||||
|
||||
def get_cache(key: str) -> object:
|
||||
return _cache.get(key)
|
||||
|
||||
|
||||
def set_cache(key: str, value: object) -> None:
|
||||
_cache[key] = value
|
||||
|
||||
|
||||
def has_cache(key: str) -> bool:
|
||||
return key in _cache
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
_cache.clear()
|
||||
|
||||
|
||||
def join_project_path(*parts: str) -> str:
|
||||
return os.path.join(*parts)
|
||||
|
||||
|
||||
def show_in_explorer(path: str) -> None:
|
||||
print(f"[assetmgr:reveal] {path}")
|
||||
|
||||
|
||||
def copy_files_to_clipboard(file_paths: list[str]) -> None:
|
||||
print(f"[assetmgr:clipboard] {len(file_paths)} path(s)")
|
||||
|
||||
|
||||
def create_commit_deeplink(channel_entry_id: str, channel_id: str,
|
||||
branch: str | None = None,
|
||||
module: str | None = None) -> str:
|
||||
target = f"{channel_id}/{channel_entry_id}"
|
||||
if branch:
|
||||
target += f"?branch={branch}"
|
||||
return f"ap://hook/{target}"
|
||||
|
||||
|
||||
def _server_only(*args: object, **kwargs: object):
|
||||
raise ServerRequiredError("this operation requires the sync server")
|
||||
|
||||
|
||||
# Collaboration / server-backed surface: same names, explicit boundary.
|
||||
lock = _server_only
|
||||
unlock = _server_only
|
||||
get_locks = _server_only
|
||||
update_locks = _server_only
|
||||
evaluate_locks = _server_only
|
||||
locks_disabled = _server_only
|
||||
locks_readonly_enabled = _server_only
|
||||
schedule_channel_notification = _server_only
|
||||
schedule_custom_notification = _server_only
|
||||
get_channel_info = _server_only
|
||||
update_channel_info = _server_only
|
||||
get_unsynced_files = _server_only
|
||||
update_unsynced_files = _server_only
|
||||
remove_unsynced_files = _server_only
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Local project/file engine (``import apsync``).
|
||||
|
||||
Implements the offline-capable subset against plain folders: project
|
||||
markers, file operations, ``${var}`` templates, next-version paths and
|
||||
JSON sidecar attributes. Everything server-backed raises
|
||||
:exc:`ServerRequiredError` instead of failing silently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from assetmgr import store
|
||||
from assetmgr.errors import DependencyMissingError, ServerRequiredError
|
||||
|
||||
__all__ = [
|
||||
"ServerRequiredError",
|
||||
"is_project",
|
||||
"create_project_file",
|
||||
"find_project_root",
|
||||
"copy_file",
|
||||
"move_file",
|
||||
"copy_folder",
|
||||
"move_folder",
|
||||
"resolve_variables",
|
||||
"create_next_version",
|
||||
"get_next_version_path",
|
||||
"get_attribute",
|
||||
"set_attribute",
|
||||
"generate_thumbnail",
|
||||
]
|
||||
|
||||
_VARIABLE_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
|
||||
def _server_only(*args: object, **kwargs: object):
|
||||
raise ServerRequiredError("this operation requires the sync server")
|
||||
|
||||
|
||||
def is_project(path: str, recursive: bool = True) -> bool:
|
||||
return store.is_project(path, recursive=recursive)
|
||||
|
||||
|
||||
def find_project_root(path: str) -> str | None:
|
||||
return store.find_project_root(path)
|
||||
|
||||
|
||||
def create_project_file(path: str, project_id: str, workspace_id: str = "",
|
||||
force: bool = False) -> str:
|
||||
name = os.path.basename(os.path.abspath(path)) or "project"
|
||||
return store.init_project(path, name, project_id, workspace_id, force=force)
|
||||
|
||||
|
||||
def copy_file(source: str, destination: str) -> str:
|
||||
return str(shutil.copy2(source, destination))
|
||||
|
||||
|
||||
def move_file(source: str, destination: str) -> str:
|
||||
return str(shutil.move(source, destination))
|
||||
|
||||
|
||||
def copy_folder(source: str, destination: str) -> str:
|
||||
return str(shutil.copytree(source, destination))
|
||||
|
||||
|
||||
def move_folder(source: str, destination: str) -> str:
|
||||
return str(shutil.move(source, destination))
|
||||
|
||||
|
||||
def resolve_variables(text: str, variables: dict[str, str]) -> str:
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
return str(variables.get(match.group(1), match.group(0)))
|
||||
|
||||
return _VARIABLE_RE.sub(_replace, text)
|
||||
|
||||
|
||||
_VERSION_RE = re.compile(r"^(?P<stem>.*?)(?:_v(?P<num>\d+))?$")
|
||||
|
||||
|
||||
def get_next_version_path(path: str) -> str:
|
||||
"""Insert or bump a ``_vNNN`` suffix before the extension."""
|
||||
directory, filename = os.path.split(path)
|
||||
stem, ext = os.path.splitext(filename)
|
||||
match = _VERSION_RE.match(stem)
|
||||
assert match is not None
|
||||
number = int(match.group("num") or 0) + 1
|
||||
return os.path.join(directory, f"{match.group('stem')}_v{number:03d}{ext}")
|
||||
|
||||
|
||||
def create_next_version(path: str) -> str:
|
||||
"""Copy *path* to its next version; returns the new path."""
|
||||
target = get_next_version_path(path)
|
||||
if os.path.isdir(path):
|
||||
copy_folder(path, target)
|
||||
else:
|
||||
copy_file(path, target)
|
||||
return target
|
||||
|
||||
|
||||
def get_attribute(target: str, name: str, default: object = None) -> object:
|
||||
return store.read_sidecar(target).get(name, default)
|
||||
|
||||
|
||||
def set_attribute(target: str, name: str, value: object) -> str:
|
||||
data = store.read_sidecar(target)
|
||||
data[name] = value
|
||||
return store.write_sidecar(target, data)
|
||||
|
||||
|
||||
def generate_thumbnail(source: str, destination: str) -> str:
|
||||
"""Render a preview image; needs ffmpeg or ImageMagick on PATH."""
|
||||
for tool, args in (
|
||||
("ffmpeg", ["-y", "-i", source, "-vframes", "1", destination]),
|
||||
("magick", [source + "[0]", "-thumbnail", "512x512>", destination]),
|
||||
):
|
||||
if shutil.which(tool) is None:
|
||||
continue
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(args, capture_output=True, text=True)
|
||||
if result.returncode == 0 and os.path.exists(destination):
|
||||
return destination
|
||||
raise RuntimeError(f"{tool} failed: {result.stderr.strip()[:300]}")
|
||||
raise DependencyMissingError("generate_thumbnail needs ffmpeg or ImageMagick")
|
||||
|
||||
|
||||
# Server-backed surface (membership, locks, timeline channels, trailers).
|
||||
get_projects = _server_only
|
||||
get_users = _server_only
|
||||
add_timeline_entry = _server_only
|
||||
get_timeline_channel = _server_only
|
||||
create_commit_trailer = _server_only
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Asset-Manager core package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Action manifest discovery, validation and trigger matching.
|
||||
|
||||
Implements the ``*.yaml`` action manifest format as observed in shipped
|
||||
action packs (see docs/ACTION_YAML.md):
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
version: "1.0"
|
||||
action:
|
||||
name: Batch Rename
|
||||
id: ap::rename
|
||||
version: 1
|
||||
author: Example Author
|
||||
category: user
|
||||
type: python
|
||||
description: Renames a set of selected files
|
||||
icon:
|
||||
path: icons/rename.svg
|
||||
script: batch_rename.py
|
||||
register:
|
||||
file:
|
||||
enable: true
|
||||
filter: "*.png;*.jpg"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
SCHEMA_VERSIONS = {"1.0", "1"}
|
||||
ACTION_TYPES = {"python", "command", "package"}
|
||||
|
||||
# Registration points observed in shipped manifests.
|
||||
REGISTER_TRIGGERS = {
|
||||
"file",
|
||||
"folder",
|
||||
"new_file",
|
||||
"new_folder",
|
||||
"new_task",
|
||||
"task",
|
||||
"sidebar",
|
||||
"workspace_overview",
|
||||
"join_project_files",
|
||||
"drive",
|
||||
}
|
||||
|
||||
_VARIABLE_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
|
||||
class ManifestError(ValueError):
|
||||
"""An action manifest is missing, malformed or invalid."""
|
||||
|
||||
|
||||
def substitute_variables(text: str, variables: dict[str, str]) -> str:
|
||||
"""Expand ``${name}`` placeholders; unknown names are left intact."""
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
key = match.group(1)
|
||||
return str(variables.get(key, match.group(0)))
|
||||
|
||||
return _VARIABLE_RE.sub(_replace, text)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionManifest:
|
||||
"""One validated action manifest."""
|
||||
|
||||
source_path: str
|
||||
manifest_id: str
|
||||
name: str
|
||||
action_type: str
|
||||
version: int = 1
|
||||
author: str = ""
|
||||
category: str = ""
|
||||
description: str = ""
|
||||
enable: Any = None
|
||||
icon_path: str = ""
|
||||
icon_color: str = ""
|
||||
script: str = ""
|
||||
command: str = ""
|
||||
arguments: str = ""
|
||||
working_directory: str = ""
|
||||
detach: bool = False
|
||||
environment: dict[str, str] = field(default_factory=dict)
|
||||
inputs: dict[str, Any] = field(default_factory=dict)
|
||||
settings: str = ""
|
||||
dependencies: list[str] = field(default_factory=list)
|
||||
platforms: list[str] = field(default_factory=list)
|
||||
members: list[str] = field(default_factory=list)
|
||||
register: dict[str, Any] = field(default_factory=dict)
|
||||
toast_error: str = ""
|
||||
|
||||
@property
|
||||
def yaml_dir(self) -> str:
|
||||
return os.path.dirname(os.path.abspath(self.source_path))
|
||||
|
||||
@property
|
||||
def is_placeholder(self) -> bool:
|
||||
"""Manifests with ``script: none`` reserve an id for host wiring."""
|
||||
return self.action_type == "python" and self.script.strip().lower() == "none"
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str) -> "ActionManifest":
|
||||
with open(path, encoding="utf-8-sig") as handle:
|
||||
data = yaml.safe_load(handle)
|
||||
return cls.from_dict(path, data or {})
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, path: str, data: dict[str, Any]) -> "ActionManifest":
|
||||
if not isinstance(data, dict):
|
||||
raise ManifestError(f"{path}: top level must be a mapping")
|
||||
schema = str(data.get("version", "1.0"))
|
||||
if schema not in SCHEMA_VERSIONS:
|
||||
raise ManifestError(f"{path}: unsupported manifest version {schema!r}")
|
||||
node = data.get("action")
|
||||
if not isinstance(node, dict):
|
||||
raise ManifestError(f"{path}: missing 'action' mapping")
|
||||
for key in ("name", "id", "type"):
|
||||
if not node.get(key):
|
||||
raise ManifestError(f"{path}: action.{key} is required")
|
||||
action_type = node["type"]
|
||||
if action_type not in ACTION_TYPES:
|
||||
raise ManifestError(f"{path}: unknown type {action_type!r}")
|
||||
if action_type == "package" and not node.get("actions"):
|
||||
raise ManifestError(f"{path}: package actions need an 'actions' member list")
|
||||
if action_type == "command" and not node.get("command"):
|
||||
raise ManifestError(f"{path}: command actions need a 'command'")
|
||||
if action_type == "python" and not node.get("script"):
|
||||
raise ManifestError(f"{path}: python actions need a 'script'")
|
||||
|
||||
register = node.get("register", {}) or {}
|
||||
if not isinstance(register, dict):
|
||||
raise ManifestError(f"{path}: 'register' must be a mapping")
|
||||
for trigger in register:
|
||||
if trigger not in REGISTER_TRIGGERS:
|
||||
raise ManifestError(f"{path}: unknown register trigger {trigger!r}")
|
||||
|
||||
icon = node.get("icon", {}) or {}
|
||||
toast = node.get("toast", {}) or {}
|
||||
return cls(
|
||||
source_path=os.path.abspath(path),
|
||||
manifest_id=str(node["id"]),
|
||||
name=str(node["name"]),
|
||||
action_type=action_type,
|
||||
version=int(node.get("version", 1)),
|
||||
author=str(node.get("author", "")),
|
||||
category=str(node.get("category", "")),
|
||||
description=str(node.get("description", "")),
|
||||
enable=node.get("enable"),
|
||||
icon_path=str(icon.get("path", "")),
|
||||
icon_color=str(icon.get("color", "")),
|
||||
script=str(node.get("script", "")),
|
||||
command=str(node.get("command", "")),
|
||||
arguments=str(node.get("arguments", "")),
|
||||
working_directory=str(node.get("workingDirectory", "")),
|
||||
detach=bool(node.get("detach", False)),
|
||||
environment=dict(node.get("environment", {}) or {}),
|
||||
inputs=dict(node.get("inputs", {}) or {}),
|
||||
settings=str(node.get("settings", "")),
|
||||
dependencies=list(node.get("dependencies", []) or []),
|
||||
platforms=list(node.get("platforms", []) or []),
|
||||
members=list(node.get("actions", []) or []),
|
||||
register=register,
|
||||
toast_error=str((toast.get("error", {}) or {}).get("message", "")),
|
||||
)
|
||||
|
||||
def enabled_for_trigger(self, trigger: str) -> bool:
|
||||
"""Whether this action shows up for a registration point.
|
||||
|
||||
``enable`` accepts a bool or a hook-module name. A hook name means
|
||||
dynamic visibility evaluated by the host; without hook support it
|
||||
counts as visible so hosts fail open instead of hiding actions.
|
||||
"""
|
||||
node = self.register.get(trigger)
|
||||
if node is None:
|
||||
return False
|
||||
if isinstance(node, bool):
|
||||
return node
|
||||
if isinstance(node, dict):
|
||||
flag = node.get("enable", False)
|
||||
return True if isinstance(flag, str) else bool(flag)
|
||||
return False
|
||||
|
||||
def matches_file(self, trigger: str, filename: str) -> bool:
|
||||
"""Trigger gate plus ``;``-separated wildcard ``filter`` matching."""
|
||||
if not self.enabled_for_trigger(trigger):
|
||||
return False
|
||||
node = self.register.get(trigger)
|
||||
filt = ""
|
||||
if isinstance(node, dict):
|
||||
filt = str(node.get("filter", "") or "")
|
||||
if not filt:
|
||||
return True
|
||||
base = os.path.basename(filename).lower()
|
||||
patterns = [p.strip().lower() for p in filt.split(";") if p.strip()]
|
||||
return any(fnmatch.fnmatchcase(base, pattern) for pattern in patterns)
|
||||
|
||||
|
||||
def discover(root: str) -> tuple[list[ActionManifest], list[str]]:
|
||||
"""Walk *root* for action manifests.
|
||||
|
||||
Returns ``(found, errors)``; found is sorted by manifest id so hosts get
|
||||
deterministic menus. Invalid files are reported, never fatal.
|
||||
"""
|
||||
found: list[ActionManifest] = []
|
||||
errors: list[str] = []
|
||||
for dirpath, _dirnames, filenames in os.walk(root):
|
||||
for filename in filenames:
|
||||
if not filename.endswith((".yaml", ".yml")):
|
||||
continue
|
||||
path = os.path.join(dirpath, filename)
|
||||
try:
|
||||
found.append(ActionManifest.from_file(path))
|
||||
except Exception as exc: # invalid manifests are skipped with a note
|
||||
errors.append(f"{path}: {exc}")
|
||||
found.sort(key=lambda manifest: manifest.manifest_id)
|
||||
return found, errors
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
"""``assetmgr`` command line: reference-CLI-inspired, local-only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from assetmgr import __version__
|
||||
from assetmgr.actions import discover
|
||||
from assetmgr.host import DispatchError, run_action
|
||||
from assetmgr.store import find_project_root, init_project
|
||||
|
||||
|
||||
def _cmd_actions(args: argparse.Namespace) -> int:
|
||||
found, errors = discover(args.root)
|
||||
scope = args.scope
|
||||
for manifest in found:
|
||||
if scope and not manifest.enabled_for_trigger(scope):
|
||||
continue
|
||||
flag = manifest.enable
|
||||
print(f"{manifest.manifest_id} [{manifest.action_type}] "
|
||||
f"{manifest.name} (enable={flag})")
|
||||
for problem in errors:
|
||||
print(f"warning: {problem}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_validate(args: argparse.Namespace) -> int:
|
||||
_found, errors = discover(args.root)
|
||||
for problem in errors:
|
||||
print(problem, file=sys.stderr)
|
||||
if errors:
|
||||
print(f"{len(errors)} invalid manifest(s)", file=sys.stderr)
|
||||
return 1
|
||||
print("all manifests valid")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_info(_args: argparse.Namespace) -> int:
|
||||
print(f"asset-manager {__version__} (headless host, local-only)")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_project_init(args: argparse.Namespace) -> int:
|
||||
marker = init_project(args.directory, args.name, force=args.force)
|
||||
print(f"project initialised: {marker}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_run(args: argparse.Namespace) -> int:
|
||||
found, _errors = discover(args.root)
|
||||
wanted = [m for m in found if m.manifest_id == args.action_id]
|
||||
if not wanted:
|
||||
print(f"unknown action: {args.action_id}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
return run_action(wanted[0], path=args.path or "")
|
||||
except DispatchError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_status(args: argparse.Namespace) -> int:
|
||||
target = os.path.abspath(args.path or os.getcwd())
|
||||
root = find_project_root(target) or target
|
||||
completed = subprocess.run(
|
||||
["git", "-C", root, "status", "--porcelain=v1", "--branch"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
print(f"not a git checkout: {root}", file=sys.stderr)
|
||||
return 1
|
||||
print(completed.stdout.rstrip() or "(clean)")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(prog="assetmgr",
|
||||
description="OSS asset manager (local host)")
|
||||
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p = sub.add_parser("actions", help="list discovered actions")
|
||||
p.add_argument("--root", default="actions")
|
||||
p.add_argument("--scope", default="",
|
||||
help="only actions registered for this trigger")
|
||||
p.set_defaults(func=_cmd_actions)
|
||||
|
||||
p = sub.add_parser("validate", help="validate action manifests")
|
||||
p.add_argument("--root", default="actions")
|
||||
p.set_defaults(func=_cmd_validate)
|
||||
|
||||
p = sub.add_parser("info", help="host info")
|
||||
p.set_defaults(func=_cmd_info)
|
||||
|
||||
p = sub.add_parser("project-init", help="create a local project marker")
|
||||
p.add_argument("directory")
|
||||
p.add_argument("--name", required=True)
|
||||
p.add_argument("--force", action="store_true")
|
||||
p.set_defaults(func=_cmd_project_init)
|
||||
|
||||
p = sub.add_parser("run", help="run one action by id")
|
||||
p.add_argument("action_id")
|
||||
p.add_argument("--root", default="actions")
|
||||
p.add_argument("--path", default="")
|
||||
p.set_defaults(func=_cmd_run)
|
||||
|
||||
p = sub.add_parser("status", help="git status of a project (offline)")
|
||||
p.add_argument("--path", default="")
|
||||
p.set_defaults(func=_cmd_status)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
return int(args.func(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Shared error types for the Asset-Manager plug-in host."""
|
||||
|
||||
|
||||
class ServerRequiredError(RuntimeError):
|
||||
"""Raised when an operation needs the vendor sync server.
|
||||
|
||||
Locking, channel notifications, membership management and other
|
||||
collaboration features are server-backed by design. Local git
|
||||
workflows work fully offline; only these methods raise.
|
||||
"""
|
||||
|
||||
|
||||
class DependencyMissingError(RuntimeError):
|
||||
"""Raised when an optional external tool (ffmpeg, git, ...) is absent."""
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Action dispatch: run python/command manifests with an injected Context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import runpy
|
||||
import subprocess
|
||||
|
||||
import anchorpoint
|
||||
|
||||
from .actions import ActionManifest, substitute_variables
|
||||
|
||||
|
||||
class DispatchError(RuntimeError):
|
||||
"""The action cannot run in this host."""
|
||||
|
||||
|
||||
def build_context(action: ActionManifest, path: str = "",
|
||||
extra_inputs: dict | None = None) -> anchorpoint.Context:
|
||||
path = os.path.abspath(path) if path else ""
|
||||
variables = {"path": path, "yaml_dir": action.yaml_dir}
|
||||
merged = {k: substitute_variables(str(v), variables)
|
||||
for k, v in action.inputs.items()}
|
||||
if extra_inputs:
|
||||
merged.update(extra_inputs)
|
||||
folder, filename = (os.path.split(path) + ("",))[:2] if path else ("", "")
|
||||
return anchorpoint.Context(
|
||||
type=action.category,
|
||||
path=path,
|
||||
folder=folder,
|
||||
filename=filename,
|
||||
suffix=os.path.splitext(filename)[1].lstrip("."),
|
||||
project_path="",
|
||||
yaml_dir=action.yaml_dir,
|
||||
module_path=action.source_path,
|
||||
app_dir=anchorpoint.get_application_dir(),
|
||||
inputs=merged,
|
||||
icon=action.icon_path,
|
||||
icon_color=action.icon_color,
|
||||
)
|
||||
|
||||
|
||||
def run_action(action: ActionManifest, path: str = "",
|
||||
extra_inputs: dict | None = None) -> int:
|
||||
"""Dispatch one action; returns the process exit code (0 = ok)."""
|
||||
if action.action_type == "package":
|
||||
raise DispatchError(f"{action.manifest_id}: packages list members, they do not run")
|
||||
if action.is_placeholder:
|
||||
raise DispatchError(f"{action.manifest_id}: placeholder (script: none)")
|
||||
ctx = build_context(action, path, extra_inputs)
|
||||
if action.action_type == "command":
|
||||
variables = {"path": path, "yaml_dir": action.yaml_dir,
|
||||
"PATH": os.environ.get("PATH", "")}
|
||||
cmd = substitute_variables(action.command, variables)
|
||||
args = substitute_variables(action.arguments, variables) if action.arguments else ""
|
||||
env = dict(os.environ)
|
||||
for key, value in action.environment.items():
|
||||
env[key] = substitute_variables(value, variables)
|
||||
argv = (cmd + " " + args).strip().split()
|
||||
cwd = substitute_variables(action.working_directory, variables) or None
|
||||
try:
|
||||
completed = subprocess.run(argv, cwd=cwd, env=env)
|
||||
except FileNotFoundError:
|
||||
if action.toast_error:
|
||||
print(f"[assetmgr:error] {action.toast_error}")
|
||||
raise DispatchError(f"command not found: {cmd}")
|
||||
return completed.returncode
|
||||
script = os.path.join(action.yaml_dir, action.script)
|
||||
if not os.path.isfile(script):
|
||||
raise DispatchError(f"script missing: {script}")
|
||||
anchorpoint._push_context(ctx)
|
||||
try:
|
||||
runpy.run_path(script, run_name="__main__")
|
||||
finally:
|
||||
anchorpoint._pop_context()
|
||||
return 0
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Local project store: ``*.approj`` markers, metadata and sidecars.
|
||||
|
||||
A project is any folder containing (or nested under a folder containing)
|
||||
a ``<name>.approj`` JSON marker, e.g. ``my-film/my-film.approj``::
|
||||
|
||||
{"project_id": "...", "workspace_id": "...", "name": "..."}
|
||||
|
||||
Per-file metadata (attributes, tags) lives in ``<file>.meta.json`` sidecars
|
||||
next to the file, so projects stay plain folders fully usable with git.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
MARKER_SUFFIX = ".approj"
|
||||
|
||||
|
||||
def find_project_root(start: str) -> str | None:
|
||||
"""Walk up from *start* to the nearest folder holding a marker."""
|
||||
current = os.path.abspath(start)
|
||||
if os.path.isfile(current):
|
||||
current = os.path.dirname(current)
|
||||
while True:
|
||||
try:
|
||||
entries = os.listdir(current)
|
||||
except OSError:
|
||||
return None
|
||||
if any(e.endswith(MARKER_SUFFIX) for e in entries):
|
||||
return current
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
return None
|
||||
current = parent
|
||||
|
||||
|
||||
def is_project(path: str, recursive: bool = False) -> bool:
|
||||
"""Whether *path* is (or is inside) a project folder."""
|
||||
if recursive:
|
||||
return find_project_root(path) is not None
|
||||
candidate = os.path.abspath(path)
|
||||
if not os.path.isdir(candidate):
|
||||
return False
|
||||
try:
|
||||
entries = os.listdir(candidate)
|
||||
except OSError:
|
||||
return False
|
||||
return any(e.endswith(MARKER_SUFFIX) for e in entries)
|
||||
|
||||
|
||||
def read_marker(project_root: str) -> dict:
|
||||
for entry in sorted(os.listdir(project_root)):
|
||||
if entry.endswith(MARKER_SUFFIX):
|
||||
with open(os.path.join(project_root, entry), encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("project marker must hold a JSON object")
|
||||
return data
|
||||
raise FileNotFoundError(f"no *{MARKER_SUFFIX} marker in {project_root}")
|
||||
|
||||
|
||||
def init_project(directory: str, name: str, project_id: str = "",
|
||||
workspace_id: str = "", force: bool = False) -> str:
|
||||
"""Create a project marker; returns the marker path."""
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
marker = os.path.join(os.path.abspath(directory), f"{name}{MARKER_SUFFIX}")
|
||||
if os.path.exists(marker) and not force:
|
||||
raise FileExistsError(marker)
|
||||
payload = {"name": name, "project_id": project_id, "workspace_id": workspace_id}
|
||||
with open(marker, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2)
|
||||
return marker
|
||||
|
||||
|
||||
def sidecar_path(target: str) -> str:
|
||||
return f"{os.path.abspath(target)}.meta.json"
|
||||
|
||||
|
||||
def read_sidecar(target: str) -> dict:
|
||||
path = sidecar_path(target)
|
||||
if not os.path.exists(path):
|
||||
return {}
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def write_sidecar(target: str, data: dict) -> str:
|
||||
path = sidecar_path(target)
|
||||
with open(path, "w", encoding="utf-8") as handle:
|
||||
json.dump(data, handle, indent=2)
|
||||
return path
|
||||
@@ -0,0 +1,57 @@
|
||||
# Action manifest schema (`*.yaml`)
|
||||
|
||||
Every key below was seen in shipped manifests (101 files surveyed). The host
|
||||
accepts exactly these; unknown `register` triggers are rejected, everything
|
||||
else forwards untouched.
|
||||
|
||||
```yaml
|
||||
version: "1.0" # manifest schema; "1.0" or 1
|
||||
action:
|
||||
name: Hello World # required, display string
|
||||
id: example::hello # required, namespaced (ap:: / git:: / <domain>::)
|
||||
version: 1 # action version (int)
|
||||
author: Heretek-AI
|
||||
category: examples # free-form path: user, vc, git, video, image, ...
|
||||
type: python # required: python | command | package
|
||||
description: What it does (may contain HTML links)
|
||||
enable: true # bool, or hook-module name for dynamic visibility
|
||||
icon: # optional
|
||||
path: icons/hi.svg # :/icons/** builtin | :icons/** alias | relative path
|
||||
color: white # optional tint key
|
||||
script: hello.py # python: .py file, or "none" (host-wired placeholder)
|
||||
command: code # command: executable
|
||||
arguments: ${path} # command: args template
|
||||
workingDirectory: ${path} # command: cwd template
|
||||
detach: true # command: standalone lifetime
|
||||
environment: # command: VAR map, ${VAR} expansion incl. ${PATH}
|
||||
MY_VAR: value
|
||||
inputs: # literal defaults, or prompt-maps:
|
||||
fps: "25" # {message, default, browse: file|folder,
|
||||
shot: # store: user|action|project}
|
||||
message: Pick a shot
|
||||
browse: folder
|
||||
store: project
|
||||
settings: gui.py # optional settings-module .py
|
||||
dependencies: [util.py] # extra files staged with the action
|
||||
platforms: [win] # optional OS restriction
|
||||
actions: [a::one] # package: member action ids
|
||||
toast: # optional
|
||||
error:
|
||||
message: Could not launch editor
|
||||
register: # where the action appears (bool or {enable, filter})
|
||||
file: {enable: true, filter: "*.png;*.jpg"}
|
||||
folder: {enable: true}
|
||||
new_file: {enable: true}
|
||||
new_folder: {enable: true}
|
||||
new_task: {enable: true}
|
||||
task: {enable: true}
|
||||
sidebar: {enable: true}
|
||||
workspace_overview: {enable: true}
|
||||
join_project_files: {enable: true}
|
||||
drive: {enable: false}
|
||||
```
|
||||
|
||||
`${name}` variables expand in `arguments`, `workingDirectory`, `environment`,
|
||||
`command` and `inputs` values. Unknown names are left intact. Observed:
|
||||
`${path}`, `${yaml_dir}`, `${username}`, `${command_to_run}`, `${PATH}`,
|
||||
`${custom_path}` plus environment passthrough.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Python API reference
|
||||
|
||||
Two modules, mirroring the observed automation surface. Everything listed
|
||||
here is implemented; server-backed names exist with identical signatures and
|
||||
raise `ServerRequiredError`.
|
||||
|
||||
## `anchorpoint` — UI/context bridge
|
||||
|
||||
```python
|
||||
import anchorpoint as ap
|
||||
|
||||
ctx = ap.get_context() # Context for this action run (raises outside one)
|
||||
ap.UI().show_success("Done", "renamed 12 files")
|
||||
with ap.Progress("Rendering", cancelable=True) as job:
|
||||
job.report_progress(0.5)
|
||||
dlg = ap.Dialog("Rename")
|
||||
dlg.add_input("shot", var="name").add_checkbox(False, "Keep extension", var="keep")
|
||||
values = dlg.show() # headless: {var: default}; GUI shell blocks
|
||||
```
|
||||
|
||||
- **Enums** — `ActionButtonType`, `BrowseType`, `ChangeSource`,
|
||||
`TimelineChannelModuleState`, `VCConflictHandling`, `VCFileStatus`.
|
||||
- **Data** — `Context` (path/folder/filename/suffix, selection lists,
|
||||
project/workspace ids, `inputs`, `yaml_dir`, …), `VCChange`.
|
||||
- **Local helpers** — `temp_dir`, `get_application_dir`, `get_config`,
|
||||
`check_application`, `get_alternative_name`, `is_network_drive`,
|
||||
`log_error/log_success`, `get/set/has/reset_cache`, `join_project_path`,
|
||||
`show_in_explorer`, `copy_files_to_clipboard`, `create_commit_deeplink`.
|
||||
- **Server boundary (raise)** — `lock/unlock/get_locks/update_locks/`,
|
||||
`evaluate_locks`, `locks_disabled/readonly`, `schedule_*_notification`,
|
||||
`get/update_channel_info`, `get/update/remove_unsynced_files`.
|
||||
|
||||
## `apsync` — local project/file engine
|
||||
|
||||
```python
|
||||
import apsync
|
||||
|
||||
apsync.is_project("./demo-film", recursive=True)
|
||||
apsync.create_project_file("./demo-film", project_id="p1")
|
||||
apsync.copy_file("a.exr", "b.exr")
|
||||
apsync.create_next_version("plate_v001.exr") # -> plate_v002.exr
|
||||
apsync.set_attribute("asset.png", "rating", 4)
|
||||
apsync.resolve_variables("${yaml_dir}/tool", {"yaml_dir": "/x"})
|
||||
apsync.generate_thumbnail("clip.mov", "thumb.png") # needs ffmpeg/ImageMagick
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
`assetmgr actions [--root] [--scope file]` · `validate` · `info` ·
|
||||
`project-init DIR --name N [--force]` · `run <action-id> [--path P]` ·
|
||||
`status [--path P]` (offline `git status`).
|
||||
@@ -0,0 +1,43 @@
|
||||
# Architecture
|
||||
|
||||
```
|
||||
actions/<pack>/<action>.yaml + .py # plug-ins (this repo: examples/)
|
||||
│ discover/validate/match
|
||||
▼
|
||||
assetmgr/actions.py ──► assetmgr/host.py ──► anchorpoint.Context
|
||||
manifests, triggers, dispatch (runpy / subprocess) injected per run
|
||||
${var} substitution │
|
||||
│ ├── anchorpoint/ UI/Dialog/Progress,
|
||||
│ │ enums, local helpers
|
||||
│ └── apsync/ project store, file ops,
|
||||
▼ versions, attributes
|
||||
assetmgr/store.py ── *.approj markers, .meta.json sidecars (plain folders + git)
|
||||
assetmgr/cli.py ── actions|validate|info|project-init|run|status
|
||||
```
|
||||
|
||||
## Module responsibilities
|
||||
|
||||
- `assetmgr/actions.py` — manifest parsing/validation (`ManifestError` on any
|
||||
deviation), `discover()` directory walk, trigger + `;`-filter matching,
|
||||
`${var}` substitution. No I/O beyond reading manifests.
|
||||
- `assetmgr/host.py` — `build_context()` (path → folder/filename/suffix,
|
||||
inputs merge) and `run_action()` (python via `runpy` with thread-local
|
||||
context; command via `subprocess` with env/cwd templates).
|
||||
- `assetmgr/store.py` — project markers, `find_project_root` walk-up,
|
||||
JSON sidecars. The only on-disk formats v0.1 defines.
|
||||
- `anchorpoint/` — automation bridge: `get_context()`, `Context`, `UI`,
|
||||
`Dialog/DialogEntry`, `Progress`, VC/display enums, local helpers.
|
||||
Server-backed names exist and raise `ServerRequiredError`.
|
||||
- `apsync/` — local engine: markers, `shutil` file ops, `resolve_variables`,
|
||||
`_vNNN` versioning, sidecar attributes, external-tool thumbnails.
|
||||
- `assetmgr/cli.py` — thin argparse over the above; `status` shells to `git`.
|
||||
|
||||
## Qt shell (roadmap, not v0.1)
|
||||
|
||||
The reference app is Qt6/QML (477 compiled-in modules, 16 `UI_*` libraries:
|
||||
App/Assets/Attributes/Blocks/Buttons/ContextMenus/Dialogs/ElementViews/
|
||||
General/Inputs/Popups/Sidebars/Styles/Texts/Utils/Values over Quick,
|
||||
QmlModels, QuickControls2, Multimedia, Sql, WebEngine). The OSS shell will be
|
||||
a PySide6/QML browser (`ElementView` grid/list/tree + attribute panels)
|
||||
driving this same host — the headless `UI`/`Dialog` classes define the exact
|
||||
seam the graphical front-end must implement.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Open gaps and how to close them
|
||||
|
||||
1. **`apsync.*` native signatures** — 40+ classes live only in the compiled
|
||||
binding; reimplemented here from the stub + samples. Close with
|
||||
`pybind11` introspection (`dir()/help()`) on a Windows install.
|
||||
2. **Daemon wire schemas** (`connect-req`, `action-dispatch` JSON) — needs a
|
||||
traffic capture of the local IPC loop; no network claims made.
|
||||
3. **Vendor REST/gRPC paths** — only hosts observed (`cloud.`, `grpc--cloud.`);
|
||||
server features are out of scope by design (see server boundary).
|
||||
4. **`enable:` hook contract** — hook-script arg/return convention for dynamic
|
||||
visibility; host currently fails open (hook name ⇒ visible).
|
||||
5. **`store:` scopes** (`user|action|project`) — persistence backend for
|
||||
prompted inputs; defaults to in-run values in v0.1.
|
||||
6. **Thumbnail job schema** — preview/detail pixel policy beyond the
|
||||
documented 2048 cap; external tools used as black boxes.
|
||||
7. **Graphical shell** — PySide6/QML browser per `docs/ARCHITECTURE.md`;
|
||||
headless `UI`/`Dialog` define the seam.
|
||||
8. **OAuth client ids** seen in config constants are upstream secrets and are
|
||||
deliberately NOT shipped; the OSS build uses user-supplied credentials.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Reverse-engineering ledger — observed facts only
|
||||
|
||||
Target: `AnchorpointSetup.exe` v1.38.0, Squirrel installer, downloaded
|
||||
2026-10-25 from `https://s3.eu-central-1.amazonaws.com/releases.anchorpoint.app/RELEASE/AnchorpointSetup.exe`.
|
||||
|
||||
Every conclusion below separates **observation** (tool output), **inference**
|
||||
(reasoned from observations) and **unknown** (not established). Nothing here
|
||||
claims runtime behaviour beyond what static evidence plus file layout show.
|
||||
|
||||
## Artifact identity
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| `AnchorpointSetup.exe` SHA-256 | `bdcc3a0f…a2d193758f370b3` (381 MB, PE32 i386 GUI, Squirrel self-extractor) |
|
||||
| `Anchorpoint-1.38.0-full.nupkg` | ZIP, 7,749 members, nuspec id `Anchorpoint`, version `1.38.0`, author `Anchorpoint Software GmbH` |
|
||||
| `Anchorpoint.exe` SHA-256 | `c60ba7a3…a9e2e2b0aca1950402f973b7b` (33.8 MB, PE32+ x86-64 GUI) |
|
||||
| Signer | `Anchorpoint Software GmbH`, Certum Code Signing 2021 CA (REA `inspect-pe-certificates` Evidence) |
|
||||
| Internal codename | `twain` (`ap-desktop` repo path baked into `ap.exe` PDB string) |
|
||||
|
||||
## REA Evidence (offline, no engine required)
|
||||
|
||||
| Evidence file | Method | Finding |
|
||||
|---|---|---|
|
||||
| `evidence/anchorpoint-exe-inspect-pe-imports.json` | `rea inspect-pe-imports` | Qt6 stack: Core(734) Gui Qml Quick Widgets Network Sql OpenGL WebEngineQuick; plus `Core.dll`(266) `sync.dll` `Plugin.dll` sentry `libmpv-2`; Log4Qt |
|
||||
| `evidence/anchorpoint-exe-inspect-pe-certificates.json` | `rea inspect-pe-certificates` | Certum chain, signer as above; no signature verification performed |
|
||||
| `evidence/anchorpoint-exe-inspect-pe-resources.json` | `rea inspect-pe-resources` | Icon/version resource tree |
|
||||
| `evidence/anchorpoint-exe-inspect-native-exports.json` | `rea inspect-native-exports` | 1,320 exports incl. `apsync::BaseObject`, Log4Qt appenders, `qml_register_types_UI_*` (16 QML modules) |
|
||||
| `evidence/ap-exe-inspect-*.json` | same on `ap.exe` | CLI11 + `twain::CommandLineIPC` (`connectIPC`, `connectPlugin`, `commit/pull/push/revert/sync`, `responseCommand`) |
|
||||
| `evidence/setup-certs.json` | `rea inspect-pe-certificates` on Setup.exe | **failed, recorded**: 381 MB exceeds 64 MiB REA input budget (`max_file_bytes`); carved + nupkg used instead |
|
||||
| `evidence/anchorpoint-strings.txt` | `strings -a` (5.3 MB) | 477 compiled-in QML module names, `qsTr` UI text, endpoint hosts, CLI topics |
|
||||
|
||||
Supporting files: `carved/` (Squirrel payload: `Anchorpoint.exe`, `Update.exe`,
|
||||
`ap.exe`, `ThumbnailGen.exe`, `SubstanceRender.exe`, …), `full/lib_extract/`
|
||||
(full nupkg tree).
|
||||
|
||||
## Key observations (→ design decisions in this repo)
|
||||
|
||||
1. **Qt6/QML app, Python-embedded.** `plugins/python/` ships CPython 3.13 +
|
||||
`anchorpoint/__init__.pyi` (3,990 lines: the entire public automation
|
||||
surface) + native `apsync…pyd`. → `anchorpoint/` + `apsync/` packages here.
|
||||
2. **Action packs.** `scripts/ap-actions/*/` = `<id>.yaml` + `.py` + `.svg`;
|
||||
101 manifests use `version/action{name,id,version,author,category,type,
|
||||
description,icon,script|command,inputs,settings,dependencies,platforms,
|
||||
actions,register,environment,toast}` with 10 register triggers and
|
||||
`;`-separated filters. → `assetmgr/actions.py` enforces exactly this.
|
||||
3. **Project markers.** DCC-side plug-in walks up to `*.approj`.
|
||||
→ `assetmgr/store.py` uses `<name>.approj` JSON markers.
|
||||
4. **Thumbnail pipeline.** `ThumbnailGen.exe` (stdin-JSON jobs, 21 format
|
||||
plug-ins) + `scripts/thumbnail.py` (OLE) + `scripts/schdoc.py` (Altium).
|
||||
→ `apsync.generate_thumbnail` shells out to ffmpeg/ImageMagick.
|
||||
5. **Git engine.** Bundled Git-for-Windows + scalar; ~26 `git::*` actions;
|
||||
free-tier gate on non-GitHub remotes; file locks independent of LFS.
|
||||
→ offline `git status` in CLI; locks are an explicit server boundary.
|
||||
6. **Server split.** Hosts seen: `track.` (telemetry), `cloud.` + `grpc--cloud.`
|
||||
(sync API), S3 (updates). Status/commit/branch/stash/revert/merge UI work
|
||||
offline; locks, notifications, membership, trailers need the server.
|
||||
|
||||
## Explicit limitations / unknowns
|
||||
|
||||
- No Ghidra/Hopper decompilation was needed for v0.1 and none is claimed;
|
||||
Ghidra 12.2 is installed for future targeted work.
|
||||
- `apsync.*` native method bodies: class names only (pybind11), signatures
|
||||
unrecovered — reimplemented from stub + samples, marked where guessed.
|
||||
- Wire schemas (`connect-req`, `action-dispatch` JSON) need daemon traffic
|
||||
capture — see `docs/GAPS.md`.
|
||||
- QML/UI text in the binary is copyrighted expression: described, never copied.
|
||||
@@ -0,0 +1,20 @@
|
||||
[project]
|
||||
name = "asset-manager"
|
||||
version = "0.1.0"
|
||||
description = "OSS asset manager: action-plugin host, git-first version control, Python automation API"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
dependencies = [ "pyyaml>=6" ]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [ "pytest>=8" ]
|
||||
|
||||
[project.scripts]
|
||||
assetmgr = "assetmgr.cli:main"
|
||||
|
||||
[build-system]
|
||||
requires = [ "setuptools>=68" ]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = [ "assetmgr*", "anchorpoint*", "apsync*" ]
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Host behaviour tests: manifests, dispatch, store, local git status."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
import anchorpoint
|
||||
import apsync
|
||||
from assetmgr.actions import ActionManifest, ManifestError, discover, substitute_variables
|
||||
from assetmgr.host import DispatchError, run_action
|
||||
from assetmgr.store import find_project_root, init_project, is_project
|
||||
|
||||
ACTIONS_ROOT = os.path.join(os.path.dirname(__file__), "..", "actions")
|
||||
|
||||
|
||||
def test_discover_finds_example_action():
|
||||
found, errors = discover(ACTIONS_ROOT)
|
||||
assert not errors
|
||||
ids = [m.manifest_id for m in found]
|
||||
assert "example::hello-world" in ids
|
||||
|
||||
|
||||
def test_manifest_fields():
|
||||
found, _ = discover(ACTIONS_ROOT)
|
||||
action = next(m for m in found if m.manifest_id == "example::hello-world")
|
||||
assert action.name == "Hello World"
|
||||
assert action.action_type == "python"
|
||||
assert action.enabled_for_trigger("file") is True
|
||||
assert action.enabled_for_trigger("sidebar") is False
|
||||
assert action.matches_file("file", "shot.png") is True
|
||||
|
||||
|
||||
def test_manifest_rejects_missing_id(tmp_path):
|
||||
bad = tmp_path / "bad.yaml"
|
||||
bad.write_text('version: "1.0"\naction:\n name: No Id\n type: python\n')
|
||||
with pytest.raises(ManifestError):
|
||||
ActionManifest.from_file(str(bad))
|
||||
|
||||
|
||||
def test_manifest_rejects_unknown_trigger(tmp_path):
|
||||
bad = tmp_path / "bad.yaml"
|
||||
bad.write_text(
|
||||
'version: "1.0"\naction:\n name: X\n id: x::y\n type: python\n'
|
||||
' script: x.py\n register:\n everywhere:\n enable: true\n'
|
||||
)
|
||||
with pytest.raises(ManifestError):
|
||||
ActionManifest.from_file(str(bad))
|
||||
|
||||
|
||||
def test_filter_matching(tmp_path):
|
||||
manifest = ActionManifest.from_dict(
|
||||
"t.yaml",
|
||||
{"version": "1.0", "action": {
|
||||
"name": "T", "id": "t::t", "type": "python", "script": "t.py",
|
||||
"register": {"file": {"enable": True,
|
||||
"filter": "*.blend;*.png"}}}},
|
||||
)
|
||||
assert manifest.matches_file("file", "scene.BLEND") is True
|
||||
assert manifest.matches_file("file", "notes.txt") is False
|
||||
assert manifest.matches_file("folder", "anything") is False
|
||||
|
||||
|
||||
def test_substitute_variables():
|
||||
out = substitute_variables("${yaml_dir}/ffmpeg.exe", {"yaml_dir": "/a"})
|
||||
assert out == "/a/ffmpeg.exe"
|
||||
assert substitute_variables("${missing}", {}) == "${missing}"
|
||||
|
||||
|
||||
def test_run_python_action(tmp_path, capsys):
|
||||
found, _ = discover(ACTIONS_ROOT)
|
||||
action = next(m for m in found if m.manifest_id == "example::hello-world")
|
||||
target = tmp_path / "shot.png"
|
||||
target.write_text("x")
|
||||
assert run_action(action, path=str(target)) == 0
|
||||
assert "Hello World" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_run_missing_script_raises(tmp_path):
|
||||
manifest = ActionManifest.from_dict(
|
||||
"t.yaml",
|
||||
{"version": "1.0", "action": {
|
||||
"name": "T", "id": "t::t", "type": "python", "script": "nope.py",
|
||||
"register": {"file": {"enable": True}}}},
|
||||
)
|
||||
with pytest.raises(DispatchError):
|
||||
run_action(manifest, path=str(tmp_path))
|
||||
|
||||
|
||||
def test_context_only_inside_dispatch():
|
||||
with pytest.raises(RuntimeError):
|
||||
anchorpoint.get_context()
|
||||
|
||||
|
||||
def test_project_roundtrip(tmp_path):
|
||||
marker = init_project(str(tmp_path / "film"), "film")
|
||||
assert marker.endswith(".approj")
|
||||
assert is_project(str(tmp_path / "film")) is True
|
||||
assert is_project(str(tmp_path / "film"), recursive=True) is True
|
||||
nested = tmp_path / "film" / "shots" / "s01"
|
||||
nested.mkdir(parents=True)
|
||||
assert find_project_root(str(nested)) == str(tmp_path / "film")
|
||||
assert is_project(str(tmp_path), recursive=True) is False
|
||||
|
||||
|
||||
def test_next_version_naming(tmp_path):
|
||||
first = tmp_path / "plate_v001.exr"
|
||||
first.write_text("x")
|
||||
nxt = apsync.create_next_version(str(first))
|
||||
assert nxt.endswith("plate_v002.exr")
|
||||
assert os.path.exists(nxt)
|
||||
assert apsync.get_next_version_path("a/b/c.mov").endswith("c_v001.mov")
|
||||
|
||||
|
||||
def test_sidecar_attributes(tmp_path):
|
||||
target = tmp_path / "asset.png"
|
||||
target.write_text("x")
|
||||
apsync.set_attribute(str(target), "rating", 4)
|
||||
assert apsync.get_attribute(str(target), "rating") == 4
|
||||
assert apsync.get_attribute(str(target), "missing", "dflt") == "dflt"
|
||||
|
||||
|
||||
def test_server_boundary_raises():
|
||||
with pytest.raises(anchorpoint.ServerRequiredError):
|
||||
anchorpoint.lock("ws", "proj", ["a.png"])
|
||||
|
||||
|
||||
def test_git_status_clean(tmp_path):
|
||||
if subprocess.run(["git", "--version"], capture_output=True).returncode != 0:
|
||||
pytest.skip("git binary absent")
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
subprocess.run(["git", "-C", str(repo), "init", "-q"], check=True)
|
||||
subprocess.run(["git", "-C", str(repo), "config", "user.email", "t@t"], check=True)
|
||||
subprocess.run(["git", "-C", str(repo), "config", "user.name", "t"], check=True)
|
||||
(repo / "f.txt").write_text("x")
|
||||
subprocess.run(["git", "-C", str(repo), "add", "."], check=True)
|
||||
subprocess.run(["git", "-C", str(repo), "commit", "-qm", "init"], check=True)
|
||||
out = subprocess.run(
|
||||
["git", "-C", str(repo), "status", "--porcelain=v1", "--branch"],
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
assert "##" in out.stdout # branch header present, worktree clean
|
||||
Reference in new issue
Block a user