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:
BillyOutlast committed 2026-10-25 14:36:45 -09:00
commit 00f00682fe
20 files changed
+1486

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
.venv/
__pycache__/
*.egg-info/
+21
View File
@@ -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.
+58
View File
@@ -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
+318
View File
@@ -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
+134
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""Asset-Manager core package."""
__version__ = "0.1.0"
+223
View File
@@ -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
View File
@@ -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())
+14
View File
@@ -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."""
+76
View File
@@ -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
+93
View File
@@ -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
+57
View File
@@ -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
View File
@@ -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`).
+43
View File
@@ -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.
+19
View File
@@ -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.
+66
View File
@@ -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.
+20
View File
@@ -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*" ]
+143
View File
@@ -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