Complete name-first 1C adapter saved-state support

This commit is contained in:
2026-07-26 16:39:53 +03:00
parent b8c62fa8fa
commit aed134d817
44 changed files with 15436 additions and 697 deletions
+8
View File
@@ -20,6 +20,10 @@ infobase.
- `xml_metadata.py`: small XML metadata extractor used as validation oracle.
- `structured_metadata.py`: evidence-based projection from Config payloads to
normalized metadata records.
- `common_command.py`: adapter-independent reverse index from
`CommonCommand.Group` to command-group membership.
- `scheduled_job.py`: adapter-independent scheduled-job schedule decoder,
named-field validator, and verified tree rebuilder.
## Current Guarantees
@@ -42,6 +46,10 @@ The parser can currently:
evidence paths.
- attach child metadata items to concrete section record paths when a declared
child-record container is present.
- resolve command-group membership from CommonCommand payloads without
requiring callers to know GUIDs or storage paths.
- decode scheduled-job schedule payloads and build guarded named-field edits,
including weekday/month collection resize without exposing tree paths.
## Non-Goals At This Layer
+37 -1
View File
@@ -9,7 +9,13 @@ from .payload import (
payload_to_text,
try_decompress,
)
from .dbnames import DBNamesRecord, parse_dbnames_bytes, parse_dbnames_file
from .dbnames import (
DBNamesRecord,
parse_dbnames_bytes,
parse_dbnames_file,
parse_dbnames_version_bytes,
parse_dbnames_version_file,
)
from .extensions import (
ExtensionZippedInfo,
ManifestEntry,
@@ -20,6 +26,23 @@ from .config_object import MetadataObjectIdentity, find_identity, parse_config_o
from .storage import StorageRoute, group_records_by_guid, storage_route, storage_routes
from .config_sections import SectionSummary, summarize_section, summarize_sections
from .xml_metadata import XmlMetadataItem, extract_xml_metadata_items, group_xml_items
from .support_rules import (
SupplierSupport,
SupportRule,
parse_parent_configurations_bytes,
parse_parent_configurations_file,
)
from .common_command import (
common_command_group_guid,
index_common_command_groups,
parse_common_command_tree,
)
from .scheduled_job import (
decode_schedule,
rebuild_schedule_tree,
schedule_layout,
schedule_write_edits,
)
__all__ = [
"BraceNode",
@@ -32,6 +55,8 @@ __all__ = [
"DBNamesRecord",
"parse_dbnames_bytes",
"parse_dbnames_file",
"parse_dbnames_version_bytes",
"parse_dbnames_version_file",
"ExtensionZippedInfo",
"ManifestEntry",
"parse_extension_manifest_bytes",
@@ -49,4 +74,15 @@ __all__ = [
"XmlMetadataItem",
"extract_xml_metadata_items",
"group_xml_items",
"SupplierSupport",
"SupportRule",
"parse_parent_configurations_bytes",
"parse_parent_configurations_file",
"common_command_group_guid",
"index_common_command_groups",
"parse_common_command_tree",
"decode_schedule",
"rebuild_schedule_tree",
"schedule_layout",
"schedule_write_edits",
]
+89
View File
@@ -0,0 +1,89 @@
"""Pure reverse index for 1C common-command group membership."""
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from typing import Any
from .payload import decode_payload_lossless, parse_brace_text
_GUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
def _tree_item_at_path(tree: Any, path: tuple[int, ...]) -> Any | None:
node = tree
for index in path:
if not isinstance(node, dict) or not isinstance(node.get("items"), list):
return None
items = node["items"]
if index < 0 or index >= len(items):
return None
node = items[index]
return node
def _tree_scalar_at_path(tree: Any, path: tuple[int, ...]) -> str:
node = _tree_item_at_path(tree, path)
if isinstance(node, dict) and node.get("type") in {"atom", "string"}:
return str(node.get("value") or "")
return ""
def common_command_group_guid(tree: Any) -> str | None:
"""Return the group GUID stored in an observed CommonCommand Config tree."""
body = _tree_item_at_path(tree, (1, 1, 2))
group_guid = _tree_scalar_at_path(body, (7, 1)).strip().lower()
return group_guid if _GUID_RE.fullmatch(group_guid) else None
def parse_common_command_tree(data: bytes) -> Any | None:
"""Decode a CommonCommand payload without raising on unsupported data."""
try:
decoded = decode_payload_lossless(data)
text = decoded.get("text")
if not text or "{" not in text:
return None
return parse_brace_text(text)
except Exception:
return None
def index_common_command_groups(
commands: Iterable[Mapping[str, Any]],
payloads: Mapping[str, bytes],
) -> dict[str, Any]:
"""Build ``CommandGroup GUID -> CommonCommand rows`` from Config payloads."""
command_rows = [dict(item) for item in commands]
normalized_payloads = {str(key).strip().lower(): value for key, value in payloads.items()}
groups: dict[str, list[dict[str, Any]]] = {}
source_missing = 0
undecodable = 0
unassigned = 0
for item in command_rows:
guid = str(item.get("guid") or "").strip().lower()
data = normalized_payloads.get(guid)
if not data:
source_missing += 1
continue
tree = parse_common_command_tree(data)
if tree is None:
undecodable += 1
continue
group_guid = common_command_group_guid(tree)
if group_guid is None:
unassigned += 1
continue
groups.setdefault(group_guid, []).append(item)
return {
"groups": groups,
"scanned": len(command_rows),
"source_missing": source_missing,
"undecodable": undecodable,
"unassigned": unassigned,
}
+42
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
import re
from typing import Any
from .payload import parse_brace_text, payload_to_text, scalar
@@ -77,3 +78,44 @@ def parse_dbnames_bytes(data: bytes, *, source: str = "DBNames") -> dict[str, An
def parse_dbnames_file(path: Path) -> dict[str, Any]:
return parse_dbnames_bytes(path.read_bytes(), source=path.name)
_GUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
def parse_dbnames_version_bytes(data: bytes, *, source: str = "DBNamesVersion") -> dict[str, Any]:
"""Parse the version marker stored separately from DBNames records."""
decoded = payload_to_text(data)
text = decoded.get("text")
if text is None:
raise ValueError(f"{source}: cannot decode DBNamesVersion text")
parsed = _unwrap_bom_sequence(parse_brace_text(text))
if not (isinstance(parsed, dict) and parsed.get("type") == "list"):
raise ValueError(f"{source}: expected root list")
items = parsed.get("items") or []
if len(items) != 2:
raise ValueError(f"{source}: expected 2 root items, got {len(items)}")
if not all(isinstance(item, dict) and item.get("type") == "atom" for item in items):
actual_types = [item.get("type") if isinstance(item, dict) else type(item).__name__ for item in items]
raise ValueError(f"{source}: expected scalar marker and version, got {actual_types}")
marker_text = scalar(items[0]).strip()
try:
marker = int(marker_text)
except (TypeError, ValueError) as exc:
raise ValueError(f"{source}: marker must be an integer, got {marker_text!r}") from exc
version = scalar(items[1]).strip().lower()
if not _GUID_RE.fullmatch(version):
raise ValueError(f"{source}: version must be a GUID string, got {version!r}")
return {
"source": source,
"compression": decoded["compression"],
"encoding": decoded["encoding"],
"marker": marker,
"version": version,
}
def parse_dbnames_version_file(path: Path) -> dict[str, Any]:
return parse_dbnames_version_bytes(path.read_bytes(), source=path.name)
+451
View File
@@ -0,0 +1,451 @@
"""Pure decoder and safe tree editor for 1C scheduled-job schedules."""
from __future__ import annotations
import copy
import re
from datetime import datetime
from typing import Any
WRITABLE_SCALAR_FIELDS = {
"begin_date",
"end_date",
"begin_time",
"end_time",
"completion_time",
"completion_interval",
"repeat_period_in_day",
"repeat_pause",
"week_day_in_month",
"day_in_month",
"weeks_period",
"days_repeat_period",
}
WRITABLE_LIST_FIELDS = {"week_days", "months"}
INTEGER_RANGES = {
"completion_interval": (0, 2_147_483_647),
"repeat_period_in_day": (0, 2_147_483_647),
"repeat_pause": (0, 2_147_483_647),
"week_day_in_month": (0, 5),
"day_in_month": (0, 31),
"weeks_period": (0, 2_147_483_647),
"days_repeat_period": (0, 2_147_483_647),
}
def _scalar(node: Any) -> str:
if not isinstance(node, dict):
return str(node or "")
if node.get("type") in {"atom", "string"}:
return str(node.get("value") or "")
return ""
def schedule_datetime(raw: str) -> tuple[str | None, str | None]:
if not re.fullmatch(r"\d{14}", raw):
return None, None
return (
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]}",
f"{raw[8:10]}:{raw[10:12]}:{raw[12:14]}",
)
def schedule_layout(tree: Any) -> dict[str, Any]:
items = tree.get("items") if isinstance(tree, dict) and isinstance(tree.get("items"), list) else []
raw = [_scalar(item) for item in items]
if len(raw) < 13:
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "The SQL schedule payload is shorter than the supported format."},
}
week_day_count = int(raw[8]) if re.fullmatch(r"-?\d+", raw[8]) else None
cursor = 9
if week_day_count is None or week_day_count < 0 or cursor + week_day_count > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "Invalid weekday collection in the SQL schedule payload."},
}
week_days_start = cursor
cursor += week_day_count
if cursor + 3 > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "The SQL schedule payload has no month collection header."},
}
week_day_in_month_index = cursor
day_in_month_index = cursor + 1
month_count_index = cursor + 2
month_count = int(raw[month_count_index]) if re.fullmatch(r"-?\d+", raw[month_count_index]) else None
cursor += 3
if month_count is None or month_count < 0 or cursor + month_count > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "Invalid month collection in the SQL schedule payload."},
}
months_start = cursor
cursor += month_count
if cursor + 2 > len(raw):
return {
"status": "invalid_schedule_payload",
"diagnostics": {"message": "The SQL schedule payload has no repeat-period tail."},
}
return {
"status": "ok",
"raw": raw,
"indexes": {
"begin_date": 0,
"end_date": 1,
"begin_time": 2,
"end_time": 3,
"completion_time": 4,
"completion_interval": 5,
"repeat_period_in_day": 6,
"repeat_pause": 7,
"week_day_count": 8,
"week_days": list(range(week_days_start, week_days_start + week_day_count)),
"week_day_in_month": week_day_in_month_index,
"day_in_month": day_in_month_index,
"month_count": month_count_index,
"months": list(range(months_start, months_start + month_count)),
"weeks_period": cursor,
"days_repeat_period": cursor + 1,
},
"cursor": cursor + 2,
}
def decode_schedule(tree: Any, *, include_storage: bool = False) -> dict[str, Any]:
layout = schedule_layout(tree)
if layout.get("status") != "ok":
return layout
raw = layout["raw"]
indexes = layout["indexes"]
def integer(field: str) -> int | None:
value = raw[indexes[field]]
return int(value) if re.fullmatch(r"-?\d+", value) else None
begin_date, _ = schedule_datetime(raw[indexes["begin_date"]])
end_date, _ = schedule_datetime(raw[indexes["end_date"]])
_, begin_time = schedule_datetime(raw[indexes["begin_time"]])
_, end_time = schedule_datetime(raw[indexes["end_time"]])
_, completion_time = schedule_datetime(raw[indexes["completion_time"]])
result = {
"status": "ok",
"begin_date": begin_date,
"end_date": end_date,
"begin_time": begin_time,
"end_time": end_time,
"completion_time": completion_time,
"completion_interval": integer("completion_interval"),
"repeat_period_in_day": integer("repeat_period_in_day"),
"repeat_pause": integer("repeat_pause"),
"week_days": [int(raw[index]) for index in indexes["week_days"] if raw[index].isdigit()],
"week_day_in_month": integer("week_day_in_month"),
"day_in_month": integer("day_in_month"),
"months": [int(raw[index]) for index in indexes["months"] if raw[index].isdigit()],
"weeks_period": integer("weeks_period"),
"days_repeat_period": integer("days_repeat_period"),
"evidence": "live_sql_config_schedule_decoder",
}
if include_storage:
result["storage"] = {
"format": "scheduled_job_config_suffix_0",
"raw_values": raw,
"trailing_values": raw[int(layout["cursor"]) :],
}
return result
def rebuild_schedule_tree(tree: Any, requested: dict[str, Any]) -> dict[str, Any]:
layout = schedule_layout(tree)
if layout.get("status") != "ok":
return layout
raw = list(layout["raw"])
indexes = layout["indexes"]
current = decode_schedule(tree)
for field in WRITABLE_SCALAR_FIELDS:
if field not in requested:
continue
value = requested[field]
index = int(indexes[field])
if field in {"begin_date", "end_date"}:
raw[index] = str(value).replace("-", "") + (
raw[index][8:] if re.fullmatch(r"\d{14}", raw[index]) else "000000"
)
elif field in {"begin_time", "end_time", "completion_time"}:
raw[index] = (
raw[index][:8] if re.fullmatch(r"\d{14}", raw[index]) else "00010101"
) + str(value).replace(":", "")
else:
raw[index] = str(value)
week_days = list(requested.get("week_days", current.get("week_days") or []))
months = list(requested.get("months", current.get("months") or []))
rebuilt_raw = [
*raw[:8],
str(len(week_days)),
*(str(value) for value in week_days),
raw[int(indexes["week_day_in_month"])],
raw[int(indexes["day_in_month"])],
str(len(months)),
*(str(value) for value in months),
raw[int(indexes["weeks_period"])],
raw[int(indexes["days_repeat_period"])],
*raw[int(layout["cursor"]) :],
]
rebuilt_tree = copy.deepcopy(tree)
rebuilt_tree["items"] = [{"type": "atom", "value": value} for value in rebuilt_raw]
verification = decode_schedule(rebuilt_tree)
if verification.get("status") != "ok":
return {
"status": "error",
"error": "schedule_rebuild_verification_failed",
"diagnostics": {"message": "The rebuilt scheduled-job tree did not pass the schedule decoder."},
}
for field, expected in requested.items():
if verification.get(field) != expected:
return {
"status": "error",
"error": "schedule_rebuild_verification_failed",
"field": field,
"diagnostics": {
"message": "A named schedule field changed during tree rebuild verification.",
"expected": expected,
"actual": verification.get(field),
},
}
return {
"status": "ok",
"tree": rebuilt_tree,
"schedule": verification,
"old_counts": {
"week_days": len(indexes["week_days"]),
"months": len(indexes["months"]),
},
"new_counts": {
"week_days": len(week_days),
"months": len(months),
},
}
def schedule_write_edits(tree: Any, requested: Any) -> dict[str, Any]:
if not isinstance(requested, dict) or not requested:
return {
"status": "invalid_argument",
"error": "schedule_required",
"diagnostics": {"message": "schedule must be a non-empty JSON object."},
}
unknown = sorted(set(requested) - WRITABLE_SCALAR_FIELDS - WRITABLE_LIST_FIELDS)
if unknown:
return {
"status": "invalid_argument",
"error": "unsupported_schedule_fields",
"diagnostics": {
"message": "The request contains unsupported scheduled-job fields.",
"fields": unknown,
"allowed_fields": sorted(WRITABLE_SCALAR_FIELDS | WRITABLE_LIST_FIELDS),
},
}
layout = schedule_layout(tree)
if layout.get("status") != "ok":
return layout
current = decode_schedule(tree)
raw = layout["raw"]
indexes = layout["indexes"]
normalized: dict[str, Any] = {}
edits: list[dict[str, Any]] = []
resized_collections: list[str] = []
for field, value in requested.items():
if field in {"begin_date", "end_date"}:
if value is None:
compact = "00010101"
elif isinstance(value, str) and re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
try:
datetime.fromisoformat(value)
except ValueError:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be a real ISO date YYYY-MM-DD."},
}
compact = value.replace("-", "")
else:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be an ISO date YYYY-MM-DD or null."},
}
index = int(indexes[field])
encoded = compact + (raw[index][8:] if re.fullmatch(r"\d{14}", raw[index]) else "000000")
normalized[field] = "0001-01-01" if value is None else value
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
continue
if field in {"begin_time", "end_time", "completion_time"}:
if value is None:
compact = "000000"
elif isinstance(value, str) and re.fullmatch(r"\d{2}:\d{2}:\d{2}", value):
try:
datetime.strptime(value, "%H:%M:%S")
except ValueError:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be a real time HH:MM:SS."},
}
compact = value.replace(":", "")
else:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {"message": f"{field} must be a time HH:MM:SS or null."},
}
index = int(indexes[field])
encoded = (
raw[index][:8] if re.fullmatch(r"\d{14}", raw[index]) else "00010101"
) + compact
normalized[field] = "00:00:00" if value is None else value
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
continue
if field in WRITABLE_LIST_FIELDS:
maximum = 7 if field == "week_days" else 12
if (
not isinstance(value, list)
or any(
isinstance(item, bool)
or not isinstance(item, int)
or item < 1
or item > maximum
for item in value
)
or len(set(value)) != len(value)
):
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {
"message": f"{field} must be a JSON array of unique integers from 1 to {maximum}."
},
}
field_indexes = list(indexes[field])
if len(value) != len(field_indexes):
resized_collections.append(field)
normalized[field] = list(value)
continue
normalized[field] = list(value)
for index, item in zip(field_indexes, value):
encoded = str(item)
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
continue
minimum, maximum = INTEGER_RANGES[field]
if isinstance(value, bool) or not isinstance(value, int) or value < minimum or value > maximum:
return {
"status": "invalid_argument",
"error": "invalid_schedule_value",
"field": field,
"diagnostics": {
"message": f"{field} must be a JSON integer from {minimum} to {maximum}."
},
}
index = int(indexes[field])
encoded = str(value)
normalized[field] = value
if encoded != raw[index]:
edits.append(
{
"path": str(index),
"value": encoded,
"node_type": "atom",
"expected_old": raw[index],
"field": field,
}
)
if resized_collections:
rebuilt = rebuild_schedule_tree(tree, normalized)
if rebuilt.get("status") != "ok":
return rebuilt
edits = [
{
"replace_root": rebuilt["tree"],
"fields": sorted(normalized),
"resized_collections": sorted(resized_collections),
"old_counts": rebuilt["old_counts"],
"new_counts": rebuilt["new_counts"],
}
]
return {
"status": "ok",
"current": current,
"requested": normalized,
"edits": edits,
"counts": {
"requested_fields": len(normalized),
"edits": len(edits),
"resized_collections": len(resized_collections),
},
}
# Compatibility aliases retained by adapter_1c_server and existing clients.
config_schedule_datetime = schedule_datetime
scheduled_job_schedule_layout = schedule_layout
scheduled_job_sql_schedule = decode_schedule
scheduled_job_schedule_rebuild_tree = rebuild_schedule_tree
scheduled_job_schedule_write_edits = schedule_write_edits
SCHEDULED_JOB_WRITABLE_SCALAR_FIELDS = WRITABLE_SCALAR_FIELDS
SCHEDULED_JOB_WRITABLE_LIST_FIELDS = WRITABLE_LIST_FIELDS
SCHEDULED_JOB_INTEGER_RANGES = INTEGER_RANGES
__all__ = [
"INTEGER_RANGES",
"WRITABLE_LIST_FIELDS",
"WRITABLE_SCALAR_FIELDS",
"config_schedule_datetime",
"decode_schedule",
"rebuild_schedule_tree",
"schedule_datetime",
"schedule_layout",
"schedule_write_edits",
"scheduled_job_schedule_layout",
"scheduled_job_schedule_rebuild_tree",
"scheduled_job_schedule_write_edits",
"scheduled_job_sql_schedule",
]
+140
View File
@@ -0,0 +1,140 @@
"""Decoder for exported 1C ``ParentConfigurations.bin`` support rules.
The SQL representation of these data is platform-private and may differ from
the exported representation. Callers must therefore pass bytes from a
positively identified source; this module deliberately does not discover a
source or infer that missing data means "not on support".
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from pathlib import Path
import re
from typing import Any
from .payload import parse_brace_text, payload_to_text, scalar
GUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
OBJECT_RULES = {
0: "not_editable",
1: "editable_support_preserved",
2: "not_supported",
}
@dataclass(frozen=True)
class SupportRule:
object_guid: str
rule_code: int
rule: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@dataclass(frozen=True)
class SupplierSupport:
configuration_guid: str
general_mode_code: int
general_mode: str
version: str
producer: str
name: str
declared_object_count: int
rules: tuple[SupportRule, ...]
def to_dict(self) -> dict[str, Any]:
result = asdict(self)
result["rules"] = [rule.to_dict() for rule in self.rules]
return result
def _root_items(data: bytes, source: str) -> tuple[list[dict[str, Any]], dict[str, Any]]:
decoded = payload_to_text(data)
text = decoded.get("text")
if text is None:
raise ValueError(f"{source}: cannot decode support rules text")
root = parse_brace_text(text)
if not (isinstance(root, dict) and root.get("type") == "list"):
raise ValueError(f"{source}: expected root list")
return list(root.get("items") or []), decoded
def _integer(items: list[dict[str, Any]], index: int, source: str, field: str) -> int:
try:
return int(scalar(items[index]))
except (IndexError, TypeError, ValueError) as exc:
raise ValueError(f"{source}: invalid {field} at item {index}") from exc
def parse_parent_configurations_bytes(
data: bytes,
*,
source: str = "ParentConfigurations.bin",
) -> dict[str, Any]:
"""Decode a positively identified exported support-rules payload."""
items, decoded = _root_items(data, source)
if len(items) < 3:
raise ValueError(f"{source}: support rules header is incomplete")
format_marker = _integer(items, 0, source, "format marker")
if format_marker != 6:
raise ValueError(f"{source}: expected format marker 6, got {format_marker}")
supplier_count = _integer(items, 2, source, "supplier count")
if supplier_count < 0:
raise ValueError(f"{source}: supplier count must not be negative")
suppliers: list[SupplierSupport] = []
position = 3
for supplier_index in range(supplier_count):
if position + 6 >= len(items):
raise ValueError(f"{source}: supplier {supplier_index} header is incomplete")
configuration_guid = scalar(items[position]).lower()
if not GUID_RE.fullmatch(configuration_guid):
raise ValueError(f"{source}: supplier {supplier_index} configuration GUID is invalid")
general_code = _integer(items, position + 1, source, "general support mode")
object_count = _integer(items, position + 6, source, "object count")
if object_count < 0:
raise ValueError(f"{source}: supplier {supplier_index} object count must not be negative")
object_position = position + 7
rules: list[SupportRule] = []
for object_index in range(object_count):
current = object_position + object_index * 4
if current + 3 >= len(items):
raise ValueError(f"{source}: supplier {supplier_index} object {object_index} is incomplete")
rule_code = _integer(items, current, source, "object support rule")
object_guid = scalar(items[current + 2]).lower()
if rule_code not in OBJECT_RULES:
raise ValueError(f"{source}: unsupported object rule code {rule_code}")
if not GUID_RE.fullmatch(object_guid):
raise ValueError(f"{source}: supplier {supplier_index} object {object_index} GUID is invalid")
effective_code = 0 if general_code != 0 else rule_code
rules.append(SupportRule(object_guid, effective_code, OBJECT_RULES[effective_code]))
suppliers.append(
SupplierSupport(
configuration_guid=configuration_guid,
general_mode_code=general_code,
general_mode="editable" if general_code == 0 else "locked",
version=scalar(items[position + 3]),
producer=scalar(items[position + 4]),
name=scalar(items[position + 5]),
declared_object_count=object_count,
rules=tuple(rules),
)
)
position = object_position + object_count * 4 + 2
return {
"source": source,
"compression": decoded["compression"],
"encoding": decoded["encoding"],
"format_marker": format_marker,
"supplier_count": supplier_count,
"suppliers": suppliers,
}
def parse_parent_configurations_file(path: Path) -> dict[str, Any]:
return parse_parent_configurations_bytes(path.read_bytes(), source=path.name)