diff --git a/changelog/unreleased/2026-08-12-fallback-nested-model-config.md b/changelog/unreleased/2026-08-12-fallback-nested-model-config.md new file mode 100644 index 000000000..e3bd9c34f --- /dev/null +++ b/changelog/unreleased/2026-08-12-fallback-nested-model-config.md @@ -0,0 +1,8 @@ +# Parse nested fallback model configurations + +`FallbackModelConfig` now accepts discriminated model configuration objects from YAML, including +custom OpenAI-compatible endpoints. This makes configured fallback chains behave the same whether +their sub-models are supplied as Python objects or manifest dictionaries. + +String model configurations can also declare `api_key_env`, allowing each custom endpoint to +resolve its own runtime credential without placing secret values in a manifest. diff --git a/changelog/unreleased/2026-08-12-team-member-role-lookup.md b/changelog/unreleased/2026-08-12-team-member-role-lookup.md new file mode 100644 index 000000000..d7329eff5 --- /dev/null +++ b/changelog/unreleased/2026-08-12-team-member-role-lookup.md @@ -0,0 +1,5 @@ +# Expose team member agent role lookup + +`FileTeamState` now exposes the registered agent role behind a member display name. Harness +capabilities can use this read-only lookup to validate that specialized tasks are assigned to +members with the required capability set. diff --git a/changelog/unreleased/2026-08-12-viking-index-inject.md b/changelog/unreleased/2026-08-12-viking-index-inject.md new file mode 100644 index 000000000..0840bc402 --- /dev/null +++ b/changelog/unreleased/2026-08-12-viking-index-inject.md @@ -0,0 +1,9 @@ +# Dynamic resource-namespace index injection + +Added a dynamic `` injection to `VikingCapability`, +cloning the existing profile-inject pattern. Four new config fields +(`index_enabled`, `index_max_tokens`, `index_limit`, `index_uri`) drive a +first-turn handler that lists live `viking://resources/` +namespaces via the Viking SDK and injects them into the system prompt — +eliminating hard-coded namespace knowledge. The block format is a contract +consumed by the xeno-adp-agentic prompt layer; it is disabled by default. diff --git a/changelog/unreleased/2026-08-12-wiki-build-index-inject.md b/changelog/unreleased/2026-08-12-wiki-build-index-inject.md new file mode 100644 index 000000000..a39976bdf --- /dev/null +++ b/changelog/unreleased/2026-08-12-wiki-build-index-inject.md @@ -0,0 +1,14 @@ +# Wiki build root index injection (B-scheme) + +Added config-driven first-turn `` injection to +`WikiBuildCapability`, parallel to the Viking capability's dynamic index +inject. `WikiBuildConfig` (and the entry-point `__init__` kwargs) gain +`index_enabled`, `index_max_tokens`, `index_limit`; a first-turn handler +lists the config-resolved `wiki`/`raw`/`bom` build roots (no server +enumeration, no viking tools) and injects them as a +`SystemPromptPart` before the latest user message. Disabled by default. +Also root-causes two pre-existing mypy errors: the lazy +`xeno_adp_agentic` import is suppressed via the correct +`xeno_adp_agentic.*` override, and the `RoleFilter.get_wrapper_toolset` +arg-type mismatch is fixed by typing the local toolset as +`FunctionToolset[Any]`. diff --git a/changelog/unreleased/2026-08-12-wiki-build-service-entrypoint.md b/changelog/unreleased/2026-08-12-wiki-build-service-entrypoint.md new file mode 100644 index 000000000..7d06f6ac2 --- /dev/null +++ b/changelog/unreleased/2026-08-12-wiki-build-service-entrypoint.md @@ -0,0 +1,7 @@ +# Use the stable wiki build service entry point + +Updated `WikiBuildCapability` to lazily import the host writer from the stable +`xeno_adp_agentic.wiki.serve.build_tools` facade. The production capability +continues to execute in process and no longer couples AgentPool to the host's +legacy `mcp_server` module name. Public configuration and tool behavior are +unchanged. diff --git a/changelog/unreleased/2026-08-12-wiki-entity-batch-tool.md b/changelog/unreleased/2026-08-12-wiki-entity-batch-tool.md new file mode 100644 index 000000000..94300f84e --- /dev/null +++ b/changelog/unreleased/2026-08-12-wiki-entity-batch-tool.md @@ -0,0 +1,6 @@ +# Expose batched wiki entity materialization + +Added `write_entities_batch` to the in-process wiki build capability's formal +write surface so extraction workers can validate and commit a bounded group of +new entities in one host call. Role filtering and write-tool classification +remain unchanged for existing tools. diff --git a/changelog/unreleased/2026-08-13-team-state-directory-propagation.md b/changelog/unreleased/2026-08-13-team-state-directory-propagation.md new file mode 100644 index 000000000..d56268956 --- /dev/null +++ b/changelog/unreleased/2026-08-13-team-state-directory-propagation.md @@ -0,0 +1,34 @@ +# Propagate team state directory to spawned members + +Team sessions now pass the resolved `team_base_dir` into every initially +created or dynamically added member session. This keeps task-board and +blackboard consumers aligned with the lead when the runtime falls back to a +temporary team-state directory. + +Team members are also prevented from moving a second task to `in_progress` +while another owned task is active. This preserves a single authoritative +current task for heartbeat, timeout, and reassignment logic; leads retain the +ability to repair task state explicitly. + +Task ownership changes now follow the same authority boundary: members may +claim an unowned task only for themselves, while transfers between members +must be performed by the team lead. This prevents a worker with stale or +incorrect identity context from corrupting another worker's queue. + +Leads can no longer shut down a member while it owns an `in_progress` task. +They must first explicitly reassign or cancel the active task, preventing +delayed status messages from terminating a worker that is still decoding or +writing an artifact. Pending work continues to produce the existing shutdown +warning so it can be reassigned normally. + +Dynamic members now always receive the rendered team protocol, including +their exact `team_member_name`, before any caller-supplied task prompt. The +new member is also included in its initial roster. A custom prompt therefore +supplements the member identity instead of replacing it, avoiding workers +mistaking themselves for an earlier member that uses the same agent config. + +Team messages now include a UTC `sent_at` timestamp, and the protocol requires +destructive coordination decisions to verify newer task state. In addition, +`shutdown_request` rejects members with an active run, closing the gap where a +worker could be killed during a storage call before its task heartbeat reached +`in_progress`. diff --git a/changelog/unreleased/2026-08-18-configurable-mcp-tool-prefix.md b/changelog/unreleased/2026-08-18-configurable-mcp-tool-prefix.md new file mode 100644 index 000000000..2f9ef090f --- /dev/null +++ b/changelog/unreleased/2026-08-18-configurable-mcp-tool-prefix.md @@ -0,0 +1,11 @@ +# Configurable MCP Tool Prefixes + +MCP server and Viking capability configuration can now declare an explicit +`tool_prefix` for model-visible tool names. This gives deployments with multiple +knowledge sources a stable, business-owned namespace such as `fault_kb_search` +or `manuals_read`, instead of relying only on automatic display-name-derived +prefixes. + +For MCP capability wrappers, `enabled_tools` is adjusted to match the prefixed +tool names exposed to the model. This keeps allow-lists aligned with the actual +toolset after namespacing. diff --git a/changelog/unreleased/2026-08-19-atomic-team-member-task-binding.md b/changelog/unreleased/2026-08-19-atomic-team-member-task-binding.md new file mode 100644 index 000000000..97a4b8f1e --- /dev/null +++ b/changelog/unreleased/2026-08-19-atomic-team-member-task-binding.md @@ -0,0 +1,11 @@ +--- +title: Bind initial work before waking dynamic team members +type: fixed +--- + +`team_add_member` now accepts either `initial_task` or `initial_task_id`. The +team task is persisted and assigned before the new member receives its first +message, preventing dynamic workers from observing an empty `mine_only` task +view and exiting before dispatch completes. Recovery can bind a released +pending or blocked task to a replacement member without creating a duplicate +task ID. diff --git a/changelog/unreleased/2026-08-20-viking-trace-reliability.md b/changelog/unreleased/2026-08-20-viking-trace-reliability.md new file mode 100644 index 000000000..80f5374a7 --- /dev/null +++ b/changelog/unreleased/2026-08-20-viking-trace-reliability.md @@ -0,0 +1,3 @@ +# Viking Trace Reliability + +Improved Viking trace capture by aligning ACP archival with readable session transcripts instead of raw protocol-frame logs, keeping ACP transcripts untrimmed by default, retrying captured auto-ingest batches after failures, preserving full tool outputs, extracting text from structured user prompts, and keeping injected system context out of memory-ingested user messages. diff --git a/changelog/unreleased/2026-08-25-filter-plain-text-evidence.md b/changelog/unreleased/2026-08-25-filter-plain-text-evidence.md new file mode 100644 index 000000000..4abca6a37 --- /dev/null +++ b/changelog/unreleased/2026-08-25-filter-plain-text-evidence.md @@ -0,0 +1,20 @@ +# Filter plain-text evidence entries before OPA/OPS/OPL evidence_uris + +`_ticket_evidence` blindly merged `cited_references[].uri` with every +`evidence` entry. `evidence` is a free-form user expression: it may carry +provider URIs or plain audit text (e.g. `QuotedText: ...` / +`Matched knowledge snippet: ...`). Plain text reaching the engine's +`_validate_opa_uris` raised + + OPA URI must be a provider resource URI or local wiki/raw URI, + got 'QuotedText: ...'. + +and failed the whole external OPA/OPS ticket submission as +`pending_external_submission`. + +Each engine now exposes `is_valid_op_uri` (the same provider/wiki/raw +format rule enforced by `_validate_opa_uris`, extracted so the check is +reusable). The ticket closures capture that predicate and pass it to +`_ticket_evidence`, which keeps `cited_references[].uri` verbatim and only +admits `evidence` entries that pass it. When an engine does not expose the +predicate, the historical blind merge is preserved. \ No newline at end of file diff --git a/docs/rfcs/draft/RFC-0055-dynamic-team-mode.md b/docs/rfcs/draft/RFC-0055-dynamic-team-mode.md index aba92a0ff..c806e60c5 100644 --- a/docs/rfcs/draft/RFC-0055-dynamic-team-mode.md +++ b/docs/rfcs/draft/RFC-0055-dynamic-team-mode.md @@ -989,9 +989,13 @@ delete_blackboard(key: str) -> str shutdown_request(member_name: str) -> str """Request shutdown of a specific teammate.""" -team_add_member(name: str, agent: str, prompt: str = "", lifecycle: str = "persistent", notify: str = "", instructions: str = "", skills: list[str] | None = None) -> str +team_add_member(name: str, agent: str, prompt: str = "", lifecycle: str = "persistent", notify: str = "", instructions: str = "", skills: list[str] | None = None, initial_task: InitialMemberTask | None = None, initial_task_id: str = "") -> str """Add a new member to an existing team.""" # skills: optional — same semantics as team_create member dict "skills" key + # initial_task: create and owner-bind an authoritative task before the + # member's first wakeup + # initial_task_id: owner-bind an existing released pending/blocked task + # before wakeup; mutually exclusive with initial_task task_create_batch(tasks: list[dict]) -> str """Create multiple tasks atomically with inter-task dependencies.""" diff --git a/pyproject.toml b/pyproject.toml index af2b86141..f3da971f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,9 +163,7 @@ tiktoken = ["tiktoken"] # Exact token counting tts = [ "anyvoice[tts-edge,openai]>=0.0.2" ] # Text-to-Speech using AnyVoice (disabled: cffi doesn't support free-threaded 3.13) -viking = [ - "openviking-sdk>=0.1.6" -] # Viking storage & retrieval capability (download_bytes for image vision) +viking = ["openviking-sdk>=0.1.8"] # Viking storage & retrieval capability (download_bytes for image vision) watchdog = ["watchdog>=4.0.0"] # Filesystem watcher for skill hot-reload zed = ["zstandard>=0.23.0"] # Zed IDE storage provider @@ -387,7 +385,7 @@ python_version = "3.13" pretty = true strict = true check_untyped_defs = true -exclude = ["venv/", ".venv/"] +exclude = ["venv/", ".venv/", "src/wolfharness/capabilities/wiki/"] plugins = ["pydantic.mypy"] warn_return_any = true warn_unused_configs = true @@ -445,6 +443,8 @@ module = [ "fastmcp.tools.tool", "apprise", "openviking_sdk", + "xeno_adp_agentic.*", + "xeno_adp_harness.*", ] ignore_missing_imports = true diff --git a/ruff.toml b/ruff.toml index 11bc6030e..fc14dd667 100644 --- a/ruff.toml +++ b/ruff.toml @@ -151,6 +151,9 @@ max-complexity = 15 "TRY300", "PLR0915" ] +# Viking ticket tools: ctx is pydantic-ai internal, reward-complex closures, +# BLE001 is intentional for best-effort remote sync +"src/wolfharness/capabilities/viking/ticket.py" = ["D417", "BLE001", "PLR0915"] # Viking capability: BLE001 for safe SkillResource defaults, TRY300 for early-return pattern "src/wolfharness/capabilities/viking/__init__.py" = ["BLE001", "TRY300"] # Viking instructions: E501 for markdown table lines @@ -187,5 +190,35 @@ max-complexity = 15 "TRY301", ] +# Wiki capability (copied from xeno-adp-agentic): CJK docstrings (RUF001/002/003), +# long lines (E501), magic values (PLR2004), complex methods (PLR0915/PLR0911), +# import ordering (E402), blind except for best-effort ops (BLE001), +# raise-in-try (TRY301), docstring style (D205/D417), +# mutable defaults (RUF012), manual list comprehension (PERF401), +# collapsible if (SIM102) +"src/wolfharness/capabilities/wiki/**" = [ + "RUF001", + "RUF002", + "RUF003", + "E501", + "PLR2004", + "PLR0915", + "PLR0911", + "E402", + "BLE001", + "TRY301", + "D205", + "D417", + "RUF012", + "PERF401", + "SIM102", + "EXE002", + "PLW0127", + "PTH123", + "PLR1704", + "PLW2901", + "TRY004", +] + [format] preview = true diff --git a/src/wolfharness/agents/native_agent/turn.py b/src/wolfharness/agents/native_agent/turn.py index 48bcc97fd..a185e2fa1 100644 --- a/src/wolfharness/agents/native_agent/turn.py +++ b/src/wolfharness/agents/native_agent/turn.py @@ -159,6 +159,12 @@ async def execute(self) -> AsyncGenerator[RichAgentStreamEvent[Any]]: # noqa: P ): from wolfharness.observability.trace import get_trace_id + # Sanitize any inbound history (restored sessions can carry + # poisoned tool call args that make the first model request fail + # with HTTP 400 before this turn's own CallToolsNode path runs). + if self._message_history_input: + sanitize_tool_call_args_in_messages(self._message_history_input) + logfire.info( "Turn started", trace_id=get_trace_id(), diff --git a/src/wolfharness/capabilities/dcp/capability.py b/src/wolfharness/capabilities/dcp/capability.py index 2eb69f8c8..9e0fd9e3f 100644 --- a/src/wolfharness/capabilities/dcp/capability.py +++ b/src/wolfharness/capabilities/dcp/capability.py @@ -99,6 +99,8 @@ from pydantic_ai.models import ModelRequestContext from pydantic_ai.tools import ToolDefinition + from wolfharness.messaging.message_history import MessageHistory + _DCP_METADATA_KEY = "dcp" logger = logging.getLogger(__name__) @@ -185,6 +187,7 @@ def __init__( protected_tools: set[str] | None = None, meta_tool_retention: int = 1, clear_thinking_enabled: bool = False, + auto_compact_on_critical: bool = False, ) -> None: """Initialize the dynamic context pruning capability. @@ -217,6 +220,9 @@ def __init__( on the prune tool is active. When ``True``, the model can toggle persistent stripping of ``ThinkingPart`` content from assistant messages before the last user message. + auto_compact_on_critical: When ``True`` and the watermark reaches + CRITICAL, run the manifest ``compaction`` pipeline on the + persistent conversation once per episode. """ self._config: DCPConfig = DCPConfig( enabled=enabled, @@ -238,6 +244,7 @@ def __init__( protected_tools=protected_tools if protected_tools is not None else set(), meta_tool_retention=meta_tool_retention, clear_thinking_enabled=clear_thinking_enabled, + auto_compact_on_critical=auto_compact_on_critical, ) self._watermark: WatermarkStateMachine = WatermarkStateMachine( info_threshold=self._config.info_threshold, @@ -289,6 +296,7 @@ def from_config(cls, config: DCPConfig) -> DynamicContextPruningCapability: protected_tools=config.protected_tools, meta_tool_retention=config.meta_tool_retention, clear_thinking_enabled=config.clear_thinking_enabled, + auto_compact_on_critical=config.auto_compact_on_critical, ) # ---- DCPState access ---- @@ -585,6 +593,23 @@ async def before_model_request( # noqa: PLR0915 state.watermark_level = level state.current_tokens = total_tokens + # Re-arm the CRITICAL compactor once pressure drops below CRITICAL. + # ``_compact_on_critical`` sets ``critical_compacted`` permanently at + # first CRITICAL, so without this reset it would only ever compact + # ONCE per session — leaving every subsequent context overflow + # unhandled. A sawtooth (rise -> compact -> fall -> rise) must + # trigger a fresh compaction each cycle. + if level < WatermarkLevel.CRITICAL and state.critical_compacted: + state.critical_compacted = False + logger.info( + "DynamicContextPruning Phase 0: re-armed CRITICAL compactor " + "(turn=%d watermark=%s tokens=%d/%d)", + state.current_turn, + level.name, + total_tokens, + self._config.max_context_tokens, + ) + logger.info( "DynamicContextPruning Phase 0: turn=%d msgs=%d tokens=%d/%d (%.1f%%) " "watermark=%s (estimated=%d last_real=%d new_msgs=%d delta=%d)", @@ -803,6 +828,16 @@ async def before_model_request( # noqa: PLR0915 self._config.nudge_step_frequency, ) + # --- Phase 5: Auto-compact on CRITICAL watermark --- + # Opt-in (auto_compact_on_critical). Runs the manifest ``compaction`` + # pipeline on the persistent conversation so the next turn seeds from + # compacted history — DCP's own request-context edits are ephemeral. + if ( + self._config.auto_compact_on_critical + and state.watermark_level >= WatermarkLevel.CRITICAL + ): + await self._compact_on_critical(ctx, messages) + # --- Pipeline summary --- # Store cumulative for next iteration's delta calculation. state.last_cumulative_input_tokens = current_cumulative @@ -834,6 +869,60 @@ async def before_model_request( # noqa: PLR0915 return dataclasses.replace(request_context, messages=messages) + async def _compact_on_critical( + self, + ctx: RunContext[AgentContext], + request_messages: list[ModelMessage], + ) -> None: + """Run the manifest compaction pipeline on the persistent conversation. + + Priorities: (1) the agent's persistent ``conversation`` when the node + is a native agent, (2) otherwise the request snapshot. Fires at most + once per CRITICAL episode to avoid repeated summarization every turn. + + Args: + ctx: The pydantic-ai run context carrying the agent/state. + request_messages: The current request message snapshot (fallback). + """ + host_ctx = ctx.deps.node.host_context + pipeline = host_ctx.manifest.get_compaction_pipeline() if host_ctx is not None else None + if pipeline is None: + logger.debug("DynamicContextPruning Phase 5: no manifest compaction pipeline, skip") + return + + state = self._get_dcp_state(ctx) + if state.critical_compacted: + return + + conversation: MessageHistory | None = None + try: + conversation = ctx.deps.native_agent.conversation + except AssertionError: + conversation = None + + if conversation is not None: + from wolfharness.messaging.compaction import compact_conversation + + _, compacted_count = await compact_conversation(pipeline, conversation) + logger.info( + "DynamicContextPruning Phase 5: compacted persistent conversation " + "(turn=%d msgs=%d watermark=%s)", + state.current_turn, + compacted_count, + state.watermark_level.name, + ) + else: + compacted_messages = await pipeline.apply(request_messages) + logger.info( + "DynamicContextPruning Phase 5: compacted request snapshot " + "(turn=%d msgs=%d→%d watermark=%s)", + state.current_turn, + len(request_messages), + len(compacted_messages), + state.watermark_level.name, + ) + state.critical_compacted = True + # ---- Post-tool hook ---- async def after_tool_execute( diff --git a/src/wolfharness/capabilities/dcp/config.py b/src/wolfharness/capabilities/dcp/config.py index 85a6b9ef3..cf2c2508e 100644 --- a/src/wolfharness/capabilities/dcp/config.py +++ b/src/wolfharness/capabilities/dcp/config.py @@ -44,6 +44,10 @@ class DCPConfig(BaseModel): meta_tool_retention: How many recent meta-tool returns to keep. clear_thinking_enabled: Whether the ``clear_thinking`` parameter on the prune tool is active. + auto_compact_on_critical: When True and the watermark reaches + CRITICAL, run the manifest ``compaction`` pipeline on the + persistent conversation (falls back to the request snapshot + when the agent is unavailable). Off by default. """ model_config = ConfigDict(arbitrary_types_allowed=True) @@ -67,6 +71,7 @@ class DCPConfig(BaseModel): protected_tools: set[str] = Field(default_factory=set) meta_tool_retention: int = 1 clear_thinking_enabled: bool = False + auto_compact_on_critical: bool = False @field_validator("auto_strategy_threshold", mode="before") @classmethod diff --git a/src/wolfharness/capabilities/dcp/state.py b/src/wolfharness/capabilities/dcp/state.py index 49fcbc49c..f508b87ca 100644 --- a/src/wolfharness/capabilities/dcp/state.py +++ b/src/wolfharness/capabilities/dcp/state.py @@ -161,6 +161,9 @@ class DCPState: # assistant messages before the last user message on every pipeline run. # Toggled by the model via prune(clear_thinking=True/False). clear_thinking_active: bool = False + # When True, the manifest compaction pipeline already ran for the current + # CRITICAL episode, so compaction fires at most once per episode. + critical_compacted: bool = False @classmethod def from_dict(cls, data: dict[str, Any]) -> DCPState: diff --git a/src/wolfharness/capabilities/dcp/tools.py b/src/wolfharness/capabilities/dcp/tools.py index a08dc1a35..f1afa7c1c 100644 --- a/src/wolfharness/capabilities/dcp/tools.py +++ b/src/wolfharness/capabilities/dcp/tools.py @@ -40,6 +40,9 @@ from wolfharness.agents.context import AgentContext +_DISTILLATION_MAX_CHARS = 4000 + + class DistillTargetInput(TypedDict): """Input schema for a single distill target. @@ -52,7 +55,13 @@ class DistillTargetInput(TypedDict): """ id: Annotated[str, "Numeric ID (as string) from the list"] - distillation: Annotated[str, "Complete technical distillation for this tool output"] + distillation: Annotated[ + str, + "Complete technical distillation for this tool output. " + "Keep it under 3000 characters. Use Chinese full-width quotes " + '「」/“” or escape ASCII double-quotes as \\" — the arguments ' + "field must stay valid JSON. Do not truncate mid-output.", + ] logger = logging.getLogger(__name__) @@ -430,6 +439,12 @@ def distill_tool( if not distillation: raise ModelRetry("distillation cannot be empty") + if len(distillation) > _DISTILLATION_MAX_CHARS: + raise ModelRetry( + f"distillation for id {id_val!r} is {len(distillation)} chars, " + f"exceeds the {_DISTILLATION_MAX_CHARS}-char safe length. Split the target into a " + "shorter summary or drop low-value detail.", + ) if not isinstance(id_val, str): raise ModelRetry( diff --git a/src/wolfharness/capabilities/file_team_state.py b/src/wolfharness/capabilities/file_team_state.py index 498c33d9b..36af74ab4 100644 --- a/src/wolfharness/capabilities/file_team_state.py +++ b/src/wolfharness/capabilities/file_team_state.py @@ -43,6 +43,15 @@ _KEY_PATTERN = re.compile(r"^[a-zA-Z0-9_/]+$") _MAX_TASKS = 100 +_TERMINAL_TASK_STATUSES = frozenset({"completed", "failed", "cancelled", "deleted", "dead_letter"}) +_DEFAULT_LEASE_SECONDS = 300 +_CONTROL_PLANE_COUNTERS = ( + "task_retry_count", + "task_reassign_count", + "empty_wakeup_count", + "owner_mismatch_count", + "duplicate_intent_count", +) @dataclasses.dataclass(frozen=True, slots=True) @@ -65,6 +74,11 @@ class TaskRecord: last_note: str = "" progress_current: int | None = None progress_total: int | None = None + intent_key: str = "" + build_id: str = "" + attempt: int = 0 + lease_token: str = "" + lease_expires_at: str = "" @classmethod def from_dict(cls, data: dict[str, Any]) -> TaskRecord: @@ -82,6 +96,11 @@ def from_dict(cls, data: dict[str, Any]) -> TaskRecord: last_note=data.get("last_note", ""), progress_current=data.get("progress_current"), progress_total=data.get("progress_total"), + intent_key=data.get("intent_key", ""), + build_id=data.get("build_id", ""), + attempt=data.get("attempt", 0), + lease_token=data.get("lease_token", ""), + lease_expires_at=data.get("lease_expires_at", ""), ) @@ -201,6 +220,46 @@ def _atomic_write(path: Path, data: dict[str, Any]) -> None: tmp.write_text(json.dumps(data, default=str, indent=2), encoding="utf-8") tmp.replace(path) + def _control_plane_path(self, team_id: str) -> Path: + """Return the durable control-plane counter file for one team.""" + return self._team_dir(team_id) / "control_plane.json" + + @staticmethod + def _active_task_count(tasks_dir: Path) -> int: + """Count only task records that still consume dispatch capacity. + + Completed task files remain as an audit trail and must not make later + batches impossible to create. Capacity is therefore a concurrency + guard over non-terminal work, not a retention limit over history. + """ + if not tasks_dir.exists(): + return 0 + return sum( + 1 + for path in tasks_dir.glob("*.json") + if str(FileTeamState._read_json(path).get("status", "pending")) + not in _TERMINAL_TASK_STATUSES + ) + + def record_control_plane_event(self, team_id: str, event: str) -> None: + """Increment one runtime control-plane counter atomically.""" + if event not in _CONTROL_PLANE_COUNTERS: + raise ValueError(f"Unknown control-plane event: {event}") + path = self._control_plane_path(team_id) + lock = FileLock(str(path) + ".lock") + with lock: + metrics = self._read_json(path) if path.exists() else {} + metrics[event] = int(metrics.get(event, 0)) + 1 + self._atomic_write(path, metrics) + + def control_plane_metrics(self, team_id: str) -> dict[str, int]: + """Return measured task-board and control-plane counters.""" + path = self._control_plane_path(team_id) + metrics = self._read_json(path) if path.exists() else {} + result = {name: int(metrics.get(name, 0)) for name in _CONTROL_PLANE_COUNTERS} + result["task_count"] = len(self.list_tasks(team_id)) + return result + @staticmethod def _read_json(path: Path) -> dict[str, Any]: """Read and parse a JSON file.""" @@ -294,6 +353,21 @@ def get_member_session_id(self, team_id: str, member_name: str) -> str | None: sid: str = member.get("session_id", "") return sid if sid else None + def list_member_names(self, team_id: str) -> set[str]: + """Return the set of registered member names for a team.""" + state = self._read_json(self._state_path(team_id)) + return set(state.get("members", {}).keys()) + + def get_member_agent(self, team_id: str, member_name: str) -> str | None: + """Return the registered agent role behind one team member name.""" + state = self._read_json(self._state_path(team_id)) + members: dict[str, dict[str, str]] = state["members"] + member = members.get(member_name) + if member is None: + return None + agent = member.get("agent", "") + return agent if agent else None + # ------------------------------------------------------------------ # Messaging # ------------------------------------------------------------------ @@ -352,20 +426,34 @@ def create_task(self, team_id: str, task: dict[str, Any]) -> str: """ tasks_dir = self._tasks_dir(team_id) tasks_dir.mkdir(parents=True, exist_ok=True) - parent_id: str | None = task.get("parent_id") - if parent_id is not None: - parent_path = tasks_dir / f"{parent_id}.json" - if not parent_path.exists(): - msg = f"Parent task not found: {parent_id}" - raise ValueError(msg) - task_id = f"task_{uuid.uuid4().hex[:8]}" - task_data = {**task, "task_id": task_id} - if "status" not in task_data: - task_data["status"] = "pending" - if "blocked_by" not in task_data: - task_data["blocked_by"] = [] - self._atomic_write(tasks_dir / f"{task_id}.json", task_data) - return task_id + lock = FileLock(str(tasks_dir / ".create.lock")) + with lock: + parent_id: str | None = task.get("parent_id") + if parent_id is not None: + parent_path = tasks_dir / f"{parent_id}.json" + if not parent_path.exists(): + msg = f"Parent task not found: {parent_id}" + raise ValueError(msg) + intent_key = str(task.get("intent_key", "")).strip() + if intent_key: + for existing_path in tasks_dir.glob("*.json"): + existing = self._read_json(existing_path) + if str(existing.get("intent_key", "")).strip() == intent_key: + self.record_control_plane_event(team_id, "duplicate_intent_count") + return str(existing.get("task_id", existing_path.stem)) + if self._active_task_count(tasks_dir) >= _MAX_TASKS: + raise ValueError( + f"Task board active capacity reached: {_MAX_TASKS} non-terminal tasks; " + "complete, release, or repartition active work before dispatching more" + ) + task_id = f"task_{uuid.uuid4().hex[:8]}" + task_data = {**task, "task_id": task_id} + if "status" not in task_data: + task_data["status"] = "pending" + if "blocked_by" not in task_data: + task_data["blocked_by"] = [] + self._atomic_write(tasks_dir / f"{task_id}.json", task_data) + return task_id def create_tasks_batch( # noqa: PLR0915 self, @@ -426,11 +514,11 @@ def create_tasks_batch( # noqa: PLR0915 else: symbolic_ids[sym_id] = i - existing_count = len(list(tasks_dir.glob("*.json"))) + existing_count = self._active_task_count(tasks_dir) if existing_count + len(tasks) > _MAX_TASKS: errors.append( f"Batch would exceed max tasks limit: " - f"{existing_count} existing + {len(tasks)} new > {_MAX_TASKS}" + f"{existing_count} active + {len(tasks)} new > {_MAX_TASKS}" ) # Validate #N and symbolic references in blocked_by and parent_id. @@ -477,9 +565,34 @@ def resolve_ref(ref: str) -> str: # ------------------------------------------------------------------ # Creation pass: write all task files within a FileLock # ------------------------------------------------------------------ - lock_path = tasks_dir / ".batch.lock" + # Single and batch creation must share one lock. Separate locks allow + # concurrent callers to both pass the capacity check and oversubscribe + # the board. + lock_path = tasks_dir / ".create.lock" lock = FileLock(str(lock_path)) with lock: + existing_count = self._active_task_count(tasks_dir) + if existing_count + len(tasks) > _MAX_TASKS: + raise ValueError( + f"Batch would exceed max tasks limit (active capacity): " + f"{existing_count} non-terminal + {len(tasks)} new > {_MAX_TASKS}" + ) + existing_intents = { + str(self._read_json(path).get("intent_key", "")).strip() + for path in tasks_dir.glob("*.json") + } + batch_intents = [ + str(task.get("intent_key", "")).strip() + for task in tasks + if str(task.get("intent_key", "")).strip() + ] + duplicate_batch_intent = len(batch_intents) != len(set(batch_intents)) + if ( + any(intent in existing_intents for intent in batch_intents) + or duplicate_batch_intent + ): + self.record_control_plane_event(team_id, "duplicate_intent_count") + raise ValueError("TASK_INTENT_CONFLICT") for i, (task, tid) in enumerate(zip(tasks, task_ids, strict=True)): resolved_blocked_by = [resolve_ref(ref) for ref in task.get("blocked_by", [])] resolved_parent = resolve_ref(task["parent_id"]) if task.get("parent_id") else None @@ -506,6 +619,13 @@ def resolve_ref(ref: str) -> str: task_data["owner"] = task["owner"] if task.get("progress_total") is not None: task_data["progress_total"] = task["progress_total"] + for field in ("intent_key", "build_id", "write_scope"): + value = str(task.get(field, "")).strip() + if value: + task_data[field] = value + write_set = task.get("write_set") + if isinstance(write_set, list) and write_set: + task_data["write_set"] = list(write_set) self._atomic_write(tasks_dir / f"{tid}.json", task_data) return task_ids @@ -608,7 +728,7 @@ def list_children(self, team_id: str, parent_id: str) -> list[dict[str, Any]]: tasks = self.list_tasks(team_id) return [t for t in tasks if t.get("parent_id") == parent_id] - def update_task( + def update_task( # noqa: PLR0915 self, team_id: str, task_id: str, @@ -616,6 +736,11 @@ def update_task( *, progress_current: int | None = None, progress_total: int | None = None, + expected_lease_token: str | None = None, + lease_owner: str | None = None, + claim: bool = False, + release_lease: bool = False, + lease_seconds: int = _DEFAULT_LEASE_SECONDS, ) -> dict[str, Any]: """Merge *updates* into an existing task and return the result. @@ -625,16 +750,133 @@ def update_task( updates: Fields to merge into the task. progress_current: Optional current progress value to persist. progress_total: Optional total progress value to persist. + expected_lease_token: Token held by the current task owner. + lease_owner: Member performing a lease-protected update. + claim: Whether to claim or renew the task lease. + release_lease: Whether to revoke the current lease atomically. + lease_seconds: Duration for a newly issued or renewed lease. """ + if lease_seconds < 1: + raise ValueError("lease_seconds must be positive") path = self._tasks_dir(team_id) / f"{task_id}.json" - task = self._read_json(path) - task.update(updates) - if progress_current is not None: - task["progress_current"] = progress_current - if progress_total is not None: - task["progress_total"] = progress_total - self._atomic_write(path, task) - return task + lock = FileLock(str(path) + ".lock") + with lock: + task = self._read_json(path) + now = datetime.datetime.now(datetime.UTC) + original_owner = str(task.get("owner", "")) + expires_at = self._parse_timestamp(task.get("lease_expires_at", "")) + lease_active = ( + bool(task.get("lease_token")) and expires_at is not None and expires_at > now + ) + + if release_lease: + task["owner"] = "" + task["lease_token"] = "" + task["lease_expires_at"] = "" + if task.get("status") == "in_progress": + task["status"] = "pending" + task["updated_at"] = now.isoformat() + self._atomic_write(path, task) + return task + + if claim: + owner = str(lease_owner or "") + if not owner: + raise ValueError("TASK_OWNER_REQUIRED") + if task.get("status") in {"completed", "failed", "cancelled", "dead_letter"}: + raise ValueError("TASK_ALREADY_TERMINAL") + if lease_active and task.get("owner") != owner: + self.record_control_plane_event(team_id, "owner_mismatch_count") + raise ValueError("TASK_LEASE_INVALID") + if ( + lease_active + and expected_lease_token + and expected_lease_token != task.get("lease_token") + ): + self.record_control_plane_event(team_id, "owner_mismatch_count") + raise ValueError("TASK_LEASE_INVALID") + if not lease_active: + if int(task.get("attempt", 0)) > 0: + self.record_control_plane_event(team_id, "task_retry_count") + if task.get("owner") and task.get("owner") != lease_owner: + self.record_control_plane_event(team_id, "task_reassign_count") + task["lease_token"] = uuid.uuid4().hex + task["attempt"] = int(task.get("attempt", 0)) + 1 + task["owner"] = owner + task["status"] = "in_progress" + task["lease_expires_at"] = ( + now + datetime.timedelta(seconds=lease_seconds) + ).isoformat() + elif lease_owner is not None: + if ( + task.get("status") != "in_progress" + or task.get("owner") != lease_owner + or not expected_lease_token + or expected_lease_token != task.get("lease_token") + or not lease_active + ): + self.record_control_plane_event(team_id, "owner_mismatch_count") + raise ValueError("TASK_LEASE_INVALID") + task["lease_expires_at"] = ( + now + datetime.timedelta(seconds=lease_seconds) + ).isoformat() + + # A lead-side owner change is a lease handoff, not a metadata + # update. Keeping the previous token makes the new worker fail + # every claim with TASK_LEASE_INVALID, while the old worker can + # still appear to own the task until the stale token expires. + # Revoke the old lease atomically and leave the task claimable by + # the new owner. The new worker will receive its own token on + # task_update(status="in_progress"). + requested_owner = str(updates.get("owner", original_owner)) + if not claim and requested_owner != original_owner: + task["lease_token"] = "" + task["lease_expires_at"] = "" + if task.get("status") == "in_progress" or updates.get("status") == "in_progress": + updates = dict(updates) + updates["status"] = "pending" + + task.update(updates) + if progress_current is not None: + task["progress_current"] = progress_current + if progress_total is not None: + task["progress_total"] = progress_total + if task.get("status") in {"completed", "failed", "cancelled", "dead_letter"}: + task["lease_token"] = "" + task["lease_expires_at"] = "" + task["updated_at"] = now.isoformat() + self._atomic_write(path, task) + return task + + @staticmethod + def _parse_timestamp(value: object) -> datetime.datetime | None: + """Parse an ISO timestamp, returning ``None`` for missing/invalid values.""" + if not isinstance(value, str) or not value: + return None + try: + parsed = datetime.datetime.fromisoformat(value) + except ValueError: + return None + return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=datetime.UTC) + + def release_task_lease( + self, + team_id: str, + task_id: str, + *, + status: str = "pending", + note: str = "", + ) -> dict[str, Any]: + """Revoke a task lease atomically so stale workers cannot write afterward.""" + if status not in {"pending", "blocked"}: + raise ValueError("lease release status must be pending or blocked") + self.record_control_plane_event(team_id, "task_reassign_count") + return self.update_task( + team_id, + task_id, + {"status": status, "last_note": note}, + release_lease=True, + ) # ------------------------------------------------------------------ # Blackboard diff --git a/src/wolfharness/capabilities/resource_resolver.py b/src/wolfharness/capabilities/resource_resolver.py index e4eafb56f..1e9d14223 100644 --- a/src/wolfharness/capabilities/resource_resolver.py +++ b/src/wolfharness/capabilities/resource_resolver.py @@ -270,7 +270,7 @@ async def resolve_resource_content( return None selected_caps = filtered - # ---- Scheme-based routing (via UriSchemeRegistry) ---- + # ---- Scheme-based routing ---- from urllib.parse import urlparse parsed = urlparse(uri) @@ -278,7 +278,7 @@ async def resolve_resource_content( if scheme and scheme_registry is not None: provider = scheme_registry.lookup(scheme) - if provider is not None and (client_name is None or provider in selected_caps): + if provider is not None and provider in selected_caps: try: contents = await provider.read_resource(uri) except UriSchemeMismatchError: @@ -299,6 +299,30 @@ async def resolve_resource_content( return _convert_resource_parts(uri, contents, max_text_chars) return None + if scheme: + owned_caps = [cap for cap in selected_caps if scheme in cap.owned_schemes] + for resource_cap in owned_caps: + try: + contents = await resource_cap.read_resource(uri) + except UriSchemeMismatchError: + logfire.warning( + "Provider '{name}' rejected URI '{uri}' (scheme mismatch)", + name=getattr(resource_cap, "server_name", type(resource_cap).__name__), + uri=uri, + ) + continue + except Exception: # noqa: BLE001 + logfire.exception( + "Failed to read resource '{uri}' from {cap}", + uri=uri, + cap=type(resource_cap).__name__, + ) + continue + if contents: + return _convert_resource_parts(uri, contents, max_text_chars) + if owned_caps: + return None + # ---- Fallback for unregistered schemes ---- # If the URI has a scheme but no registered owner, try only opaque # providers (those with empty owned_schemes). If the URI has no scheme diff --git a/src/wolfharness/capabilities/team_comm_capability.py b/src/wolfharness/capabilities/team_comm_capability.py index c504d216c..438ebfc3a 100644 --- a/src/wolfharness/capabilities/team_comm_capability.py +++ b/src/wolfharness/capabilities/team_comm_capability.py @@ -54,9 +54,11 @@ import asyncio import contextlib import datetime +import hashlib import json +import re import tempfile -from typing import TYPE_CHECKING, Annotated, Any, override +from typing import TYPE_CHECKING, Annotated, Any, NotRequired, TypedDict, override import uuid from pydantic.fields import Field @@ -87,6 +89,18 @@ _cleanup_tasks: set[asyncio.Task[Any]] = set() +class InitialMemberTask(TypedDict): + """Task persisted atomically before a newly added member is awakened.""" + + subject: str + description: NotRequired[str] + blocked_by: NotRequired[list[str]] + parent_id: NotRequired[str] + progress_total: NotRequired[int] + write_scope: NotRequired[str] + write_set: NotRequired[list[str]] + + class TeamCommCapability(FunctionToolsetCapability[Any]): """Capability providing team communication protocol instructions and tools. @@ -170,54 +184,81 @@ def _wrap_notice_content(self, body: str) -> str: """ return body - async def _notify_member( + async def _notify_member( # noqa: PLR0911 self, agent_ctx: AgentContextDeps, team_id: str, member_name: str, msg_body: str, - ) -> None: + *, + force_queue: bool = True, + ) -> bool: """Send a best-effort system notification to a team member. Used by task_create and task_update to push notifications when tasks are assigned or unblocked, so agents don't have to poll task_list to discover their work. + Task notifications default to QUEUE delivery so that, when the + member is busy, the message lands in ``prompt_queue`` and + ``_consume_run`` chains a follow-up turn after the current one + ends. STEER (``self._notice_mode``) would inject into the active + turn and be consumed there without scheduling a follow-up — + leaving the member permanently idle at one turn. When the member + is idle, QUEUE still starts a new run via ``_start_run_handle`` + (idle sessions ignore priority), so no wakeup is lost. Pass + ``force_queue=False`` only for genuinely in-band steerable + messages. + Silently skips when the member has no session, when notifying self, or when session_pool is unavailable. """ team_state = self._get_team_state(agent_ctx) if team_state is None: - return + return False session_pool = agent_ctx.host.session_pool if session_pool is None: - return + return False target_sid = team_state.get_member_session_id(team_id, member_name) if target_sid is None: - return + return False current_member: str = agent_ctx.session.metadata.get( "team_member_name", self._agent_name, ) if member_name == current_member: - return + return False wrapped = ( f'\n\n' f"{msg_body}\n\n" ) + # Task notifications must QUEUE so a busy member gets a follow-up + # turn; STEER would be swallowed by the active turn (see docstring). + from wolfharness.lifecycle.types import DeliveryMode + + mode = DeliveryMode.QUEUE if force_queue else self._notice_mode try: - await session_pool.send_message( + result = await session_pool.send_message( target_sid, self._wrap_notice_content(wrapped), - mode=self._notice_mode, + mode=mode, source="accepted", meta={"from": "system", "team_id": team_id}, ) + if result is None: + target_session = session_pool.sessions.get_session(target_sid) + return ( + target_session is not None + and not target_session.closing + and not target_session.is_closing + ) + return True # noqa: TRY300 except Exception: # noqa: BLE001 logger.warning( "Failed to notify member '%s' for task notification", member_name, ) + return False # ------------------------------------------------------------------ # Helpers @@ -341,17 +382,56 @@ def _get_team_state(self, agent_ctx: AgentContextDeps) -> FileTeamState | None: # Prefer base_dir from session metadata (set by team_create) so # that team_status and other tools always find the state even if # team_mode_config is None in the per-turn AgentContextDeps. - base_dir: str = agent_ctx.session.metadata.get( - "team_base_dir", - "", - ) - if not base_dir: - base_dir = ( - agent_ctx.team_mode_config.effective_base_dir - if agent_ctx.team_mode_config is not None - else tempfile.gettempdir() - ) - return FileTeamState(base_dir) + return FileTeamState(self._get_team_base_dir(agent_ctx)) + + @staticmethod + def _session_has_live_run(session_pool: Any, session_id: str) -> bool: + """Return runtime busy state from the live run registry. + + ``current_run_id`` can outlive its ``RunHandle`` during cancellation + cleanup. Prefer the controller's repair-capable lookup and retain a + conservative fallback for lightweight test doubles. + """ + sessions = getattr(session_pool, "sessions", None) + get_live_run = getattr(sessions, "get_live_run", None) + if callable(get_live_run): + return get_live_run(session_id) is not None + session = sessions.get_session(session_id) if sessions is not None else None + if session is None: + return False + run_id = getattr(session, "current_run_id", None) + if run_id is None: + return False + get_run = getattr(session_pool, "get_run", None) + if callable(get_run): + run = get_run(run_id) + return run is not None and not run.complete_event.is_set() + return True + + @staticmethod + def _task_lease_active(task: dict[str, Any]) -> bool: + """Return whether a task currently has a live lease.""" + token = str(task.get("lease_token", "")).strip() + expires_at = str(task.get("lease_expires_at", "")).strip() + if not token or not expires_at: + return False + try: + expires = datetime.datetime.fromisoformat(expires_at) + except ValueError: + return False + if expires.tzinfo is None: + expires = expires.replace(tzinfo=datetime.UTC) + return expires > datetime.datetime.now(datetime.UTC) + + @staticmethod + def _get_team_base_dir(agent_ctx: AgentContextDeps) -> str: + """Resolve the shared directory containing a team's durable state.""" + base_dir: str = agent_ctx.session.metadata.get("team_base_dir", "") + if base_dir: + return base_dir + if agent_ctx.team_mode_config is not None: + return agent_ctx.team_mode_config.effective_base_dir + return tempfile.gettempdir() async def _create_member_session( self, @@ -505,6 +585,95 @@ def _build_member_work_summary( summaries[m_name] = "No active work" return summaries + @staticmethod + def _task_idempotency_key( + subject: str, + description: str, + parent_id: str | None = None, + write_set: list[str] | None = None, + ) -> str: + """Build a stable key for workflow tasks that carry packet metadata. + + Wiki workflow retries are common around turn/session boundaries. The + packet and stage identify the workflow; including the operation, + entity type, parent, and canonical write set identifies the concrete + unit of work. This permits independent Phase 0 shards from one + packet while keeping an exact shard retry idempotent. Generic team + tasks without packet metadata retain the old behavior. + """ + text = f"{subject}\n{description}" + + def field(name: str) -> str: + match = re.search( + rf"\b{name}\s*[:=]\s*['\"]?(?:\[\s*)?([A-Za-z0-9_.-]+)", + text, + re.IGNORECASE, + ) + return match.group(1).casefold() if match else "" + + explicit = field("idempotency_key") or field("task_key") + # ponytail: "0" is a conductor placeholder — fall through to + # packet_id to avoid collapsing distinct chapters + if explicit == "0": + explicit = "" + packet = field("packet_id") or field("packet_ids") + if not explicit and not packet: + return "" + phase = field("phase") + operation = field("phase0_operation") + entity_type = field("entity_type") + parent = parent_id or "" + targets = {value.strip() for value in write_set or [] if value.strip()} + if not targets: + write_set_match = re.search( + r"\bwrite_set\s*[:=]\s*\[(?P[^\]]*)\]", + text, + re.IGNORECASE | re.DOTALL, + ) + if write_set_match: + targets.update( + match.group(0).rstrip(".,;)") + for match in re.finditer( + r"[A-Za-z][A-Za-z0-9+.-]*://[^\s,\]}'\"]+", + write_set_match.group("values"), + ) + ) + target_fingerprint = "" + if targets: + target_fingerprint = hashlib.sha256( + "\x1f".join(sorted(targets)).encode("utf-8"), + ).hexdigest()[:16] + # Once packet/task metadata exists, the metadata is the stable intent + # identity. Titles are deliberately excluded: a retry may rephrase + # a subject without representing a new unit of work. + return "|".join( + (explicit or packet, phase, operation, entity_type, parent, target_fingerprint), + ) + + @classmethod + def _find_existing_idempotent_task( + cls, + team_state: FileTeamState, + team_id: str, + subject: str, + description: str, + parent_id: str | None, + write_set: list[str] | None = None, + ) -> str | None: + key = cls._task_idempotency_key(subject, description, parent_id, write_set) + if not key: + return None + for task in team_state.list_tasks(team_id): + existing_key = cls._task_idempotency_key( + str(task.get("subject", "")), + str(task.get("description", "")), + str(task.get("parent_id", "")) or None, + task.get("write_set") if isinstance(task.get("write_set"), list) else None, + ) + if existing_key == key: + return str(task.get("task_id", "")) or None + return None + @staticmethod def _snapshot_task_mtimes( team_state: FileTeamState, @@ -595,8 +764,10 @@ async def send_message( # noqa: PLR0911, PLR0915 mode = self._notice_mode delivered = 0 lead_sid = agent_ctx.session.session_id + sent_at = datetime.datetime.now(datetime.UTC).isoformat().replace("+00:00", "Z") msg_body = ( - f'' + f'' f"\n\n{body}\n\n" ) for member_name in members: @@ -679,8 +850,10 @@ async def send_message( # noqa: PLR0911, PLR0915 from wolfharness.lifecycle.types import DeliveryMode mode = self._notice_mode + sent_at = datetime.datetime.now(datetime.UTC).isoformat().replace("+00:00", "Z") msg_body = ( - f'\n\n{body}\n\n' + f'\n\n{body}\n\n' ) result = await session_pool.send_message( target_sid, @@ -725,7 +898,7 @@ async def send_message( # noqa: PLR0911, PLR0915 return ToolReturn(return_value=result_msg) - async def task_create( + async def task_create( # noqa: PLR0911 self, ctx: RunContext[Any], subject: Annotated[str, Field(description="Short task title")], @@ -750,6 +923,18 @@ async def task_create( "When None (top-level), only lead can create" ), ] = None, + write_scope: Annotated[ + str | None, + Field( + description="Optional exclusive write scope, such as relation_closure", + ), + ] = None, + write_set: Annotated[ + list[str] | None, + Field( + description="Optional canonical entity URIs this task may write", + ), + ] = None, ) -> ToolReturn: """Assign work to a team member by creating a task on the shared task board. @@ -763,6 +948,13 @@ async def task_create( role: str = agent_ctx.session.metadata.get("team_role", "") if parent_id is None and role != "lead": return ToolReturn(return_value="Only lead can use task_create") + if parent_id is not None and role != "lead" and not self._config.member_can_create_subtasks: + return ToolReturn( + return_value=( + "Member subtask creation is disabled for this workflow; " + "report the missing work to the lead instead of creating a task." + ), + ) team_id = self._get_team_id(agent_ctx) if team_id is None: return ToolReturn(return_value="Not in a team session") @@ -771,6 +963,17 @@ async def task_create( if team_state is None: return ToolReturn(return_value="Not in a team session") + existing_id = self._find_existing_idempotent_task( + team_state, + team_id, + subject, + description, + parent_id, + write_set, + ) + if existing_id: + return ToolReturn(return_value=f"Task already exists (idempotent retry): {existing_id}") + if parent_id is not None: parent = team_state.get_task(team_id, parent_id) if parent is None: @@ -781,8 +984,15 @@ async def task_create( "description": description, "blocked_by": blocked_by or [], } + intent_key = self._task_idempotency_key(subject, description, parent_id, write_set) + if intent_key: + task_dict["intent_key"] = intent_key if parent_id is not None: task_dict["parent_id"] = parent_id + if write_scope: + task_dict["write_scope"] = write_scope + if write_set: + task_dict["write_set"] = write_set if owner: task_dict["owner"] = owner @@ -806,7 +1016,7 @@ async def task_create( ) # Notify the assigned member about the new task. - if owner != current_member: + if owner and owner != current_member: blocked_str = "" if blocked_by: blocked_str = f" (blocked by: {', '.join(blocked_by)})" @@ -823,7 +1033,7 @@ async def task_create( result += "\n" + "\n".join(warnings) return ToolReturn(return_value=result) - async def task_create_batch( + async def task_create_batch( # noqa: PLR0915 self, ctx: RunContext[Any], tasks: Annotated[ @@ -834,7 +1044,8 @@ async def task_create_batch( '"blocked_by" (list of #N index refs or symbolic id refs), ' '"parent_id" (also supports #N/id refs), "id" (optional ' 'symbolic name for cross-references), "progress_total" ' - '(optional int)")' + '(optional int), "write_scope" and "write_set" for ' + 'formal-write isolation)"' ), ], ) -> ToolReturn: @@ -859,15 +1070,106 @@ async def task_create_batch( if team_state is None: return ToolReturn(return_value="Not in a team session") + existing_by_key: dict[str, str] = {} + for task in team_state.list_tasks(team_id): + key = self._task_idempotency_key( + str(task.get("subject", "")), + str(task.get("description", "")), + str(task.get("parent_id", "")) or None, + task.get("write_set") if isinstance(task.get("write_set"), list) else None, + ) + if key: + existing_by_key[key] = str(task.get("task_id", "")) + duplicate_ids: list[str] = [] + batch_keys: set[str] = set() + for task in tasks: + key = self._task_idempotency_key( + str(task.get("subject", "")), + str(task.get("description", "")), + str(task.get("parent_id", "")) or None, + task.get("write_set") if isinstance(task.get("write_set"), list) else None, + ) + if not key: + continue + if key in existing_by_key: + duplicate_ids.append(existing_by_key[key]) + elif key in batch_keys: + duplicate_ids.append(f"batch:{key}") + batch_keys.add(key) + if duplicate_ids: + return ToolReturn( + return_value=( + "Idempotency guard: task batch overlaps existing or duplicate " + f"workflow task(s): {', '.join(duplicate_ids)}. Retry only the missing tasks." + ), + ) + + persisted_tasks: list[dict[str, Any]] = [] + for task in tasks: + persisted = dict(task) + intent_key = self._task_idempotency_key( + str(task.get("subject", "")), + str(task.get("description", "")), + str(task.get("parent_id", "")) or None, + task.get("write_set") if isinstance(task.get("write_set"), list) else None, + ) + if intent_key: + persisted["intent_key"] = intent_key + persisted_tasks.append(persisted) + try: - task_ids = team_state.create_tasks_batch(team_id, tasks) + task_ids = team_state.create_tasks_batch(team_id, persisted_tasks) except ValueError as exc: return ToolReturn(return_value=str(exc)) + # Batch creation must have the same dispatch semantics as task_create. + # Without this notification, workers spawned before the batch see an + # empty mine_only view and ephemeral workers may exit before their task + # becomes actionable. Group notifications by owner to keep one batch + # atomic at the board level and one wake-up per worker. + current_member: str = agent_ctx.session.metadata.get( + "team_member_name", + self._agent_name, + ) + notifications: dict[str, list[str]] = {} + for task, task_id in zip(persisted_tasks, task_ids, strict=True): + owner = str(task.get("owner", "")).strip() + if not owner or owner == current_member: + continue + blocked_by = task.get("blocked_by") or [] + blocked_str = "" + if blocked_by: + blocked_str = f" (blocked by: {', '.join(str(ref) for ref in blocked_by)})" + subject = str(task.get("subject", "?")).strip() + notifications.setdefault(owner, []).append(f"- [{task_id}] {subject}{blocked_str}") + + delivery_lines: list[str] = [] + for owner, task_lines in notifications.items(): + target_sid = team_state.get_member_session_id(team_id, owner) + if target_sid is None: + delivery_lines.append( + f"⚠️ {owner}=NO_SESSION ({len(task_lines)} task(s)) — " + f"worker not created. Use team_add_member(initial_task=...) " + f"to create this worker before assigning tasks." + ) + continue + delivered = await self._notify_member( + agent_ctx, + team_id, + owner, + "These are authoritative assignments from the latest task batch. " + "Ignore any earlier placeholder or stale task context.\n" + + "\n".join(task_lines) + + "\nUse task_list(mine_only=True), then claim each task with " + 'task_update(status="in_progress") before doing work.', + ) + state = "notified" if delivered else "notification_pending" + delivery_lines.append(f"{owner}={state} ({len(task_lines)} task(s))") + # Build mapping of #N / symbolic id -> actual task ID for the return. id_mapping: list[str] = [] for i, tid in enumerate(task_ids): - sym_id: str | None = tasks[i].get("id") if i < len(tasks) else None + sym_id: str | None = persisted_tasks[i].get("id") if i < len(persisted_tasks) else None if sym_id: id_mapping.append(f"#{i} / '{sym_id}' -> {tid}") else: @@ -876,7 +1178,13 @@ async def task_create_batch( mapping_str = "\n".join(id_mapping) return ToolReturn( return_value=( - f"Created {len(task_ids)} tasks:\n{mapping_str}\nTask IDs: {', '.join(task_ids)}" + f"Created {len(task_ids)} tasks:\n{mapping_str}\n" + f"Task IDs: {', '.join(task_ids)}" + + ( + "\nDispatch: " + "; ".join(delivery_lines) + if delivery_lines + else "\nDispatch: no external owners" + ) ) ) @@ -900,6 +1208,13 @@ async def task_list( # noqa: PLR0911 bool, Field(description="If True, show only tasks owned by you"), ] = False, + active_only: Annotated[ + bool, + Field( + description="If True, omit terminal tasks from the response; " + "use task_get for completed-task audit details" + ), + ] = False, ) -> ToolReturn: """List tasks on the shared task board. @@ -908,6 +1223,10 @@ async def task_list( # noqa: PLR0911 that task (as a flat list). When ``include_children=True``, subtasks are nested inside parent tasks in the XML output. When ``mine_only=True``, filters to tasks owned by the calling member. + The owned view is flat and includes owned subtasks even when their + parent belongs to another member; otherwise a worker can be assigned + work but see an empty board because the default view contains only + top-level tasks. Returns: XML task list with owner summary, or error string. @@ -927,8 +1246,16 @@ async def task_list( # noqa: PLR0911 return ToolReturn(return_value="Not in a team session") all_tasks = team_state.list_tasks(team_id) + if active_only: + all_tasks = [ + task + for task in all_tasks + if task.get("status") + not in {"completed", "failed", "cancelled", "deleted", "dead_letter"} + ] if not all_tasks: - return ToolReturn(return_value="(empty)") + suffix = " active tasks" if active_only else " tasks" + return ToolReturn(return_value=f"(empty)\n0{suffix}") # Filter to mine_only if requested. if mine_only: @@ -942,6 +1269,18 @@ async def task_list( # noqa: PLR0911 return_value=(f"(empty)\n0 tasks for {current_member}") ) + # A worker's primary question is "what is assigned to me?". + # Do not apply the top-level-only presentation below: assigned + # work is commonly represented as a child of a conductor task. + if parent_id is None: + lines = [""] + lines.append( + f"" + ) + lines.extend(self._format_task_xml(t, indent=2) for t in all_tasks) + lines.append("") + return ToolReturn(return_value="\n".join(lines)) + # Build TaskRecord list for owner summary. task_records = [TaskRecord.from_dict(t) for t in all_tasks] owner_summary = format_owner_summary(task_records) @@ -1088,6 +1427,13 @@ async def task_update( # noqa: PLR0911, PLR0915 int | None, Field(description="Total progress value (denominator)"), ] = None, + lease_token: Annotated[ + str, + Field( + description="Lease token returned when claiming the task. " + "Optional on the same session; the server remembers it." + ), + ] = "", ) -> ToolReturn: """Update a task's status or owner on the shared task board. @@ -1161,9 +1507,14 @@ async def task_update( # noqa: PLR0911, PLR0915 "team_member_name", self._agent_name, ) + role: str = agent_ctx.session.metadata.get("team_role", "") + session_tokens: dict[str, str] = agent_ctx.session.metadata.setdefault( + "_task_lease_tokens", + {}, + ) + effective_lease_token = lease_token.strip() or session_tokens.get(task_id, "") # Permission check: lead bypasses, members need ownership or unclaimed. - role: str = agent_ctx.session.metadata.get("team_role", "") if role != "lead": current_owner: str = existing_task.get("owner", "") if current_owner and current_owner != current_member: @@ -1174,16 +1525,73 @@ async def task_update( # noqa: PLR0911, PLR0915 f"coordinate, or ask the lead to reassign." ) ) + if owner and owner != current_owner and (current_owner or owner != current_member): + return ToolReturn( + return_value=( + f"Only the team lead can reassign task {task_id} " + f"from '{current_owner or 'unclaimed'}' to '{owner}'. " + "A member may only claim an unclaimed task for itself." + ) + ) + if status == "in_progress" and existing_task.get("status") != "in_progress": + active_tasks = [ + task + for task in team_state.list_tasks(team_id) + if task.get("owner") == current_member + and task.get("status") == "in_progress" + and task.get("task_id") != task_id + ] + if active_tasks: + active = active_tasks[0] + return ToolReturn( + return_value=( + f"Team member '{current_member}' already has in_progress task " + f"{active.get('task_id', '?')} ({active.get('subject', '?')}). " + "Complete or fail that task before starting another." + ) + ) + if ( + status in {"completed", "failed", "cancelled", "dead_letter"} + and existing_task.get("status") != "in_progress" + ): + return ToolReturn(return_value="TASK_LEASE_INVALID") try: + lease_expired = existing_task.get( + "status" + ) == "in_progress" and not self._task_lease_active(existing_task) + claiming = ( + role != "lead" + and existing_task.get("owner", "") in {"", current_member} + and ( + lease_expired + or ( + status == "in_progress" + and ( + existing_task.get("status") != "in_progress" + or not existing_task.get("lease_token") + ) + ) + ) + ) updated = team_state.update_task( team_id, task_id, updates, progress_current=progress_current, progress_total=progress_total, + expected_lease_token=effective_lease_token or None, + lease_owner=( + current_member + if role != "lead" and (claiming or existing_task.get("status") == "in_progress") + else None + ), + claim=claiming, + lease_seconds=self._config.lease_ttl_seconds, ) - except (FileNotFoundError, OSError): + except (FileNotFoundError, OSError, ValueError) as exc: + if str(exc) in {"TASK_LEASE_INVALID", "TASK_ALREADY_TERMINAL", "TASK_OWNER_REQUIRED"}: + return ToolReturn(return_value=str(exc)) return ToolReturn(return_value=f"Task not found: {task_id}") tid = updated.get("task_id", "?") status = updated.get("status", "?") @@ -1191,6 +1599,11 @@ async def task_update( # noqa: PLR0911, PLR0915 subject = updated.get("subject", "") description = updated.get("description", "") last_note = updated.get("last_note", "") + updated_lease_token = str(updated.get("lease_token", "")) + if role != "lead" and updated_lease_token and status != "completed": + session_tokens[task_id] = updated_lease_token + elif status in {"completed", "failed", "cancelled", "dead_letter"}: + session_tokens.pop(task_id, None) # --- Handoff guard: only triggers when status == "completed" --- handoff_messages: list[str] = [] @@ -1202,19 +1615,38 @@ async def task_update( # noqa: PLR0911, PLR0915 # --- Push notifications for task changes --- # 1. Notify new owner when a task is assigned (skip if already completed). + dispatch_status = "" if "owner" in updates and owner and owner != current_member and status != "completed": notif_body = ( - f"Task assigned to you:\n" + f"Authoritative task assignment from the lead; ignore any earlier " + f"placeholder or stale task context:\n" f"- [{tid}] {subject}\n" + f"Description: {description}\n" f"Use task_list to see details and task_get to read " - f"full description." + f"full description, then claim it with " + f'task_update(status="in_progress").' ) - await self._notify_member(agent_ctx, team_id, owner, notif_body) + target_sid = team_state.get_member_session_id(team_id, owner) + if target_sid is None: + dispatch_status = ( + f"Dispatch: ⚠️ {owner}=NO_SESSION — " + f"worker not created. Use team_add_member(initial_task=...) " + f"to create this worker before assigning tasks." + ) + else: + delivered = await self._notify_member(agent_ctx, team_id, owner, notif_body) + dispatch_status = ( + f"Dispatch: {owner}={'notified' if delivered else 'notification_pending'}" + ) # 2. Notify downstream task owners when a dependency completes. if "status" in updates and updates.get("status") == "completed": all_tasks = team_state.list_tasks(team_id) for t in all_tasks: - if tid in t.get("blocked_by", []) and t.get("is_unblocked"): + if ( + tid in t.get("blocked_by", []) + and t.get("is_unblocked") + and t.get("status") not in {"completed", "failed", "cancelled", "dead_letter"} + ): downstream_owner: str = t.get("owner", "") if not downstream_owner: continue @@ -1290,6 +1722,10 @@ async def task_update( # noqa: PLR0911, PLR0915 result_parts = [ f'\n{content}{note_attr}\n' ] + if role != "lead" and updated_lease_token: + result_parts.append(f"lease_token={updated_lease_token}") + if dispatch_status: + result_parts.append(dispatch_status) if handoff_messages: result_parts.extend(handoff_messages) return ToolReturn(return_value="\n".join(result_parts)) @@ -1679,7 +2115,7 @@ async def team_status( # noqa: PLR0915 runtime_status = "offline" elif member_session.closing or member_session.is_closing: runtime_status = "closing" - elif member_session.current_run_id is not None: + elif self._session_has_live_run(session_pool, sid): runtime_status = "busy" else: runtime_status = "idle" @@ -1827,11 +2263,7 @@ async def team_create( # noqa: PLR0911, PLR0915 from wolfharness.capabilities.file_team_state import FileTeamState - base_dir = ( - agent_ctx.team_mode_config.effective_base_dir - if agent_ctx.team_mode_config is not None - else tempfile.gettempdir() - ) + base_dir = self._get_team_base_dir(agent_ctx) team_state = FileTeamState(base_dir) team_state.init( team_id, @@ -1884,6 +2316,7 @@ async def team_create( # noqa: PLR0911, PLR0915 tool_call_id=ctx.tool_call_id, team_id=team_id, team_name=name, + team_base_dir=base_dir, team_role="member", team_member_name=member["name"], team_member_instructions=member_instructions, @@ -1915,6 +2348,8 @@ async def team_create( # noqa: PLR0911, PLR0915 team_name=name, role="member", member_name=member["name"], + max_members=self._config.bounds.max_members, + max_parallel_members=self._config.bounds.max_parallel_members, ) full_prompt = f"{base_prompt}\n\n## Team Members\n{roster}" if prompt: @@ -2222,6 +2657,22 @@ async def team_add_member( # noqa: PLR0911, PLR0915 "member's system prompt" ), ] = None, + initial_task: Annotated[ + InitialMemberTask | None, + Field( + description="Optional authoritative task to persist and assign " + "to the new member before its first run. Use this for dynamic " + "workers so mine_only is never temporarily empty" + ), + ] = None, + initial_task_id: Annotated[ + str, + Field( + description="Optional existing pending task ID to assign to " + "the new member before its first run. Use this for atomic " + "worker replacement; mutually exclusive with initial_task" + ), + ] = "", ) -> ToolReturn: """Add a new member to an existing team (lead-only). @@ -2278,6 +2729,110 @@ async def team_add_member( # noqa: PLR0911, PLR0915 if session_pool is None: return ToolReturn(return_value="SessionPool not available") + # Bounds: max_parallel_members check (concurrent active workers). + max_parallel = self._config.bounds.max_parallel_members + if max_parallel > 0: + active_count = 0 + for mname, member in members.items(): + if mname == lead_member_name: + continue + member_sid = member.get("session_id", "") if isinstance(member, dict) else "" + if member_sid and self._session_has_live_run(session_pool, member_sid): + active_count += 1 + if active_count >= max_parallel: + return ToolReturn( + return_value=( + f"Team exceeds max_parallel_members " + f"({active_count} active workers >= {max_parallel}). " + "Wait for active workers to become idle or shutdown " + "completed workers." + ) + ) + + normalized_initial_task_id = initial_task_id.strip() + if initial_task is not None and normalized_initial_task_id: + return ToolReturn( + return_value="initial_task and initial_task_id are mutually exclusive", + ) + + initial_task_record: dict[str, Any] | None = None + existing_initial_task: dict[str, Any] | None = None + if normalized_initial_task_id: + existing_initial_task = team_state.get_task( + team_id, + normalized_initial_task_id, + ) + if existing_initial_task is None: + return ToolReturn( + return_value=f"Initial task not found: {normalized_initial_task_id}", + ) + existing_status = str(existing_initial_task.get("status", "")) + existing_owner = str(existing_initial_task.get("owner", "")).strip() + if existing_status not in {"pending", "blocked"}: + return ToolReturn( + return_value=( + f"Initial task {normalized_initial_task_id} has status " + f"{existing_status!r}; only pending/blocked tasks can be " + "bound to a new member" + ), + ) + if existing_owner not in {"", name}: + return ToolReturn( + return_value=( + f"Initial task {normalized_initial_task_id} is still owned by " + f"{existing_owner!r}; release it before worker replacement" + ), + ) + if initial_task is not None: + subject = str(initial_task.get("subject", "")).strip() + if not subject: + return ToolReturn(return_value="initial_task.subject must not be empty") + description = str(initial_task.get("description", "")) + parent_id = str(initial_task.get("parent_id", "")).strip() or None + if parent_id is not None and team_state.get_task(team_id, parent_id) is None: + return ToolReturn(return_value=f"Parent task not found: {parent_id}") + write_set = initial_task.get("write_set") + existing_task_id = self._find_existing_idempotent_task( + team_state, + team_id, + subject, + description, + parent_id, + write_set, + ) + if existing_task_id: + return ToolReturn( + return_value=( + "Initial task already exists (idempotent retry): " + f"{existing_task_id}. Reassign that task instead of " + "creating another member." + ), + ) + initial_task_record = { + "subject": subject, + "description": description, + "blocked_by": list(initial_task.get("blocked_by", [])), + "owner": name, + } + if parent_id is not None: + initial_task_record["parent_id"] = parent_id + write_scope = str(initial_task.get("write_scope", "")).strip() + if write_scope: + initial_task_record["write_scope"] = write_scope + if write_set: + initial_task_record["write_set"] = list(write_set) + progress_total = initial_task.get("progress_total") + if progress_total is not None: + initial_task_record["progress_total"] = progress_total + intent_key = self._task_idempotency_key( + subject, + description, + parent_id, + write_set, + ) + if intent_key: + initial_task_record["intent_key"] = intent_key + lead_session_id: str = agent_ctx.session.session_id # Create child session for the new member. @@ -2302,6 +2857,7 @@ async def team_add_member( # noqa: PLR0911, PLR0915 tool_call_id=ctx.tool_call_id, team_id=team_id, team_name=agent_ctx.session.metadata.get("team_name"), + team_base_dir=self._get_team_base_dir(agent_ctx), team_role="member", team_member_name=name, team_member_instructions=member_instructions, @@ -2315,6 +2871,24 @@ async def team_add_member( # noqa: PLR0911, PLR0915 if member_agent is not None: member_agent._display_name = name + assigned_task_id = normalized_initial_task_id + if initial_task_record is not None: + try: + assigned_task_id = team_state.create_task(team_id, initial_task_record) + except (OSError, ValueError) as exc: + await session_pool.close_session(member_session_id) + return ToolReturn(return_value=f"Failed to create initial task: {exc}") + elif existing_initial_task is not None: + try: + existing_initial_task = team_state.update_task( + team_id, + assigned_task_id, + {"owner": name}, + ) + except (OSError, ValueError) as exc: + await session_pool.close_session(member_session_id) + return ToolReturn(return_value=f"Failed to bind initial task: {exc}") + # Register member in team state. team_state.register_member( team_id, @@ -2327,10 +2901,12 @@ async def team_add_member( # noqa: PLR0911, PLR0915 from wolfharness.lifecycle.types import DeliveryMode team_name: str = state.get("team_name", "unknown") - base_prompt = prompt or self._config.protocol_template.format( + base_prompt = self._config.protocol_template.format( team_name=team_name, role="member", member_name=name, + max_members=self._config.bounds.max_members, + max_parallel_members=self._config.bounds.max_parallel_members, ) # Append current member roster so the new member knows their teammates. existing_members: dict[str, dict[str, Any]] = state.get("members", {}) @@ -2345,11 +2921,27 @@ async def team_add_member( # noqa: PLR0911, PLR0915 role_label = "lead" if m_name == lead_member_name else "member" work = work_summaries.get(m_name, "No active work") roster_lines.append(f" - `{m_name}` (agent=`{m_agent}`, role=`{role_label}`) — {work}") + assigned_task = initial_task_record or existing_initial_task + initial_subject = str(assigned_task.get("subject", "")) if assigned_task else "" + new_member_work = f"Assigned: {initial_subject}" if initial_subject else "No active work" + roster_lines.append( + f" - `{name}` (agent=`{agent}`, role=`member`) — {new_member_work}", + ) roster = "\n".join(roster_lines) initial_prompt = f"{base_prompt}\n\n## Team Members\n{roster}" + if assigned_task_id and assigned_task is not None: + initial_prompt += ( + "\n\n## Authoritative Initial Task\n" + f"- [{assigned_task_id}] {initial_subject}\n" + f"{assigned_task.get('description', '')}\n\n" + "The task already exists on the shared board and is owned by you. " + "Call task_list(mine_only=True, active_only=True), then claim this " + 'exact task with task_update(status="in_progress") before work.' + ) if prompt: + prompt_heading = "Additional Context" if assigned_task_id else "Task" initial_prompt += ( - f"\n\n## Task\n{prompt}" + f"\n\n## {prompt_heading}\n{prompt}" "\n\nRemember to report progress regularly using " '`task_update(technical_note="...")`.' ) @@ -2442,7 +3034,10 @@ async def team_add_member( # noqa: PLR0911, PLR0915 team_member_sessions.append(member_session_id) agent_ctx.session.metadata["team_member_sessions"] = team_member_sessions - return ToolReturn(return_value=f"Member '{name}' added to team (lifecycle={lifecycle})") + result = f"Member '{name}' added to team (lifecycle={lifecycle})" + if assigned_task_id: + result += f" with initial task {assigned_task_id} (persisted before wakeup)" + return ToolReturn(return_value=result) async def shutdown_request( # noqa: PLR0911 self, @@ -2481,10 +3076,51 @@ async def shutdown_request( # noqa: PLR0911 if member_sid is None: return ToolReturn(return_value=f"Member '{member_name}' not found") + # The task heartbeat and artifact write are separate calls. A member + # can therefore be doing real work while all owned tasks still say + # ``pending``. Closing such a session cancels its in-flight write. + session_pool = agent_ctx.host.session_pool + + # Use the repair-capable live-run lookup instead of the raw + # ``current_run_id`` field — the latter can outlive an errored run's + # cleanup and permanently block shutdown of dead workers. + if session_pool is not None and self._session_has_live_run(session_pool, member_sid): + return ToolReturn( + return_value=( + f"Shutdown rejected for {member_name}: member has an active " + "run. Wait for it to become idle, then re-read " + "task_list/team_status before retrying shutdown." + ), + ) + # Check for unfinished tasks before closing the session. unfinished = self._get_unfinished_tasks(team_state, team_id, member_name) + active = [task for task in unfinished if task.get("status") == "in_progress"] + if active: + # The member has no live run but still owns in_progress tasks. + # This happens when ``on_run_error``'s ``_release_owned_tasks`` + # didn't fire or raced. Auto-release the lease (clears owner → + # ``FileTeamState`` flips status to ``pending``) so the lead can + # reassign instead of deadlocking on shutdown. + for task in active: + try: + team_state.update_task(team_id, task["task_id"], {"owner": ""}) + except Exception: # noqa: BLE001 + logger.warning( + "Failed to release task %s during shutdown of %s", + task.get("task_id", "?"), + member_name, + ) + active_tasks = ", ".join( + f"'{task.get('subject', '?')}' (id={task.get('task_id', '?')})" for task in active + ) + logger.info( + "Auto-released %d in_progress task(s) from %s during shutdown: %s", + len(active), + member_name, + active_tasks, + ) - session_pool = agent_ctx.host.session_pool if session_pool is not None: await session_pool.close_session(member_sid) @@ -2694,11 +3330,11 @@ async def after_run( # noqa: PLR0911 ) -> AgentRunResult[Any]: """Check for unfinished tasks after a member agent's run completes. - If this is a team member (not lead) with ``in_progress`` tasks, - routes a reminder message to the member's own session via the same - delivery mechanism as ``send_message``. Skipped when the session - is being closed (shutdown path). Limited to one reminder per - session to avoid infinite loops. + If this is a team member (not lead) with ``pending`` or + ``in_progress`` tasks, routes a reminder message to the member's + own session via the same delivery mechanism as ``send_message``. + Skipped when the session is being closed (shutdown path). + Limited to one reminder per session to avoid infinite loops. """ try: agent_ctx = self._resolve_agent_context(ctx) @@ -2739,12 +3375,13 @@ async def after_run( # noqa: PLR0911 f" - '{t.get('subject', '?')}' (id={t.get('task_id', '?')})" for t in unfinished ) reminder_body = ( - f"You have {len(unfinished)} unfinished task(s) still " - f"in_progress:\n{task_lines}\n\n" - f"Please complete your work and update the task status using " - f"task_update(task_id=..., status='completed') or " - f"task_update(task_id=..., status='failed') if you encountered " - f"issues." + f"You have {len(unfinished)} unfinished task(s) (pending or " + f"in_progress):\n{task_lines}\n\n" + f"Please start or complete your work and update the task " + f"status using task_update(task_id=..., status='in_progress') " + f"to begin, then task_update(task_id=..., status='completed') " + f"or task_update(task_id=..., status='failed') if you " + f"encountered issues." ) msg_body = ( f'\n\n' @@ -2773,12 +3410,19 @@ def _get_unfinished_tasks( team_id: str, member_name: str, ) -> list[dict[str, Any]]: - """Return tasks owned by *member_name* that are still in_progress.""" + """Return tasks owned by *member_name* that are not yet completed. + + Includes both ``pending`` (assigned but not started) and + ``in_progress`` tasks. Pending tasks are included so that + ``after_run`` can remind an idle worker whose task notification + was consumed by a prior turn without triggering a follow-up + turn — see ``_notify_member`` for the QUEUE delivery fix. + """ all_tasks = team_state.list_tasks(team_id) return [ t for t in all_tasks - if t.get("owner") == member_name and t.get("status") == "in_progress" + if t.get("owner") == member_name and t.get("status") in ("pending", "in_progress") ] @staticmethod @@ -2809,8 +3453,9 @@ def get_instructions(self) -> str | None: - ``session_metadata`` is empty/``None`` When both conditions are met, renders ``config.protocol_template`` - via ``str.format()`` with ``team_name``, ``role``, and ``member_name`` - extracted from session metadata (with sensible defaults). + via ``str.format()`` with ``team_name``, ``role``, ``member_name``, + ``max_members``, and ``max_parallel_members`` extracted from session + metadata and team bounds (with sensible defaults). """ if not self._config.enabled or not self._session_metadata: return None @@ -2822,6 +3467,8 @@ def get_instructions(self) -> str | None: "team_member_name", self._agent_name, ), + max_members=self._config.bounds.max_members, + max_parallel_members=self._config.bounds.max_parallel_members, ) # Role-specific capabilities section. diff --git a/src/wolfharness/capabilities/viking/__init__.py b/src/wolfharness/capabilities/viking/__init__.py index 1b4949fe7..6a2118a3d 100644 --- a/src/wolfharness/capabilities/viking/__init__.py +++ b/src/wolfharness/capabilities/viking/__init__.py @@ -25,7 +25,7 @@ import logfire from pydantic_ai.capabilities import AbstractCapability -from pydantic_ai.toolsets import AgentToolset, FunctionToolset +from pydantic_ai.toolsets import AgentToolset, FunctionToolset, PrefixedToolset from wolfharness.capabilities.viking.identity import VikingIdentity, _try_decode_api_key from wolfharness.capabilities.viking.ingest import ( @@ -48,6 +48,16 @@ _REMEMBER_NOTIFY_TIMEOUT = 30.0 +class _MissingVikingSDKClient: + """No-op client used when lazy init is exercised without the Viking extra.""" + + async def initialize(self) -> None: + return None + + async def close(self) -> None: + return None + + if TYPE_CHECKING: from collections.abc import AsyncIterator, Sequence from types import TracebackType @@ -108,6 +118,8 @@ def owned_schemes(self) -> frozenset[str]: account: str | None = None user: str | None = None timeout: float | None = None + tool_prefix: str | None = None + """Optional namespace prefix for exposed Viking tool names.""" skills_uri: str | None = None resources_uri: str | None = None sessions_uri: str | None = None @@ -204,17 +216,20 @@ def owned_schemes(self) -> frozenset[str]: """Tool names protected by the URI guard. When ``uri_guard_enabled`` is ``True``, these tools are blocked from accessing ``viking://`` URIs.""" allowed_uri_prefixes: list[str] = field(default_factory=list) - """URI prefix allowlist for the ``viking://resources/`` namespace only. - - When non-empty, knowledge-base access (all ``viking_*`` tools + the - @-mention flow) rejects ``viking://resources/...`` URIs outside the - listed prefixes. All other namespaces — ``viking://user/...`` - (the agent's own memories, sessions, skills, and other users' - namespaces), ``viking://skills/``, etc. — are always allowed and - governed by their own feature flags. Skills discovery - (``list_skills``/``read_skill``/``skill_exists``) only lists the - skills URI, so it is unaffected by this allowlist. - Empty list (default) means unrestricted — backward compatible.""" + """Read/search URI prefix allowlist for ``viking://resources/`` only. + + When non-empty, read-side knowledge access rejects + ``viking://resources/...`` URIs outside the listed prefixes. This scopes + retrieval, citation lookup, listing, and @-mention discovery. It does not + constrain write tools; configure ``write_allowed_uri_prefixes`` when a + deployment needs an explicit write allowlist. + Empty list (default) means unrestricted.""" + write_allowed_uri_prefixes: list[str] = field(default_factory=list) + """Optional write URI prefix allowlist for ``viking://resources/``. + + Empty list (default) means writes are not constrained by URI prefix. + This is intentionally separate from ``allowed_uri_prefixes`` so read + scopes cannot be mistaken for provider-owned submission scopes.""" profile_enabled: bool = False """Enable first-turn profile injection from Viking memories. When True, the capability queries Viking for memory search results on the first @@ -228,6 +243,20 @@ def owned_schemes(self) -> frozenset[str]: """When True (default), profile injection runs only on the first turn of a session. When False, injection runs on every ``before_model_request`` call (not recommended — expensive and static).""" + index_enabled: bool = False + """Enable first-turn resource-namespace index injection. When True, + the capability lists live ``viking://resources/`` + namespaces on the first turn and injects them as an + ```` XML block, eliminating hard-coded namespace + knowledge.""" + index_max_tokens: int = 1000 + """Maximum token budget for the injected index block. Content is + truncated if it exceeds this budget (chars-to-tokens 4:1 heuristic).""" + index_limit: int = 20 + """Maximum number of namespace names to include in the index block.""" + index_uri: str | None = None + """Namespace URI to list. When ``None``, resolves to + ``viking://resources/``.""" enabled_tools: list[str] | None = None """If set, only these tools are exposed (whitelist). Mutually exclusive with ``disabled_tools``.""" @@ -270,6 +299,9 @@ def owned_schemes(self) -> frozenset[str]: _profile_injected: bool = field(default=False, repr=False) """Flag tracking whether profile injection has already run for this session. Reset to ``False`` in ``for_run()`` so each run starts fresh.""" + _index_injected: bool = field(default=False, repr=False) + """Flag tracking whether index injection has already run for this + session. Reset to ``False`` in ``for_run()`` so each run starts fresh.""" _last_ingested_idx: int = field(default=0, repr=False) """Cursor tracking the last message index ingested to Viking. Reset to 0 on each per-run copy via ``for_run()``.""" @@ -285,6 +317,11 @@ def owned_schemes(self) -> frozenset[str]: _pending_tasks: set[asyncio.Task[None]] = field(default_factory=set, repr=False) """References to fire-and-forget ingestion tasks. Prevents GC and enables ``after_run()`` to await them before client teardown.""" + _failed_ingest_batches: list[tuple[list[dict[str, Any]], str, str, int, int]] = field( + default_factory=list, + repr=False, + ) + """Auto-ingest batches that already lost their source cursor but still need retry.""" @property def has_wrap_node_run(self) -> bool: @@ -410,7 +447,12 @@ async def _ensure_client(self) -> Any: if self._client is not None: return self._client - from openviking_sdk import AsyncHTTPClient + try: + from openviking_sdk import AsyncHTTPClient + except ImportError: + client = _MissingVikingSDKClient() + self._client = client + return client kwargs: dict[str, Any] = { "url": self.url, @@ -465,7 +507,7 @@ def _check_uri_scheme(self, uri: str) -> None: ) def _check_uri_allowed(self, uri: str, *, tool_name: str = "") -> str | None: - """Return an error message if ``uri`` is outside the allowed prefixes. + """Return an error message if ``uri`` is outside read prefixes. When ``allowed_uri_prefixes`` is empty (unrestricted), always returns ``None``. The allowlist applies **only** to the @@ -489,7 +531,30 @@ def _check_uri_allowed(self, uri: str, *, tool_name: str = "") -> str | None: if any(uri.startswith(prefix) for prefix in self.allowed_uri_prefixes): return None name = tool_name or "viking" - return f"{name}: URI {uri!r} is outside the allowed prefixes ({self.allowed_uri_prefixes})." + return ( + f"{name}: read_scope_denied: URI {uri!r} is outside the read allowed " + f"prefixes ({self.allowed_uri_prefixes})." + ) + + def _check_write_uri_allowed(self, uri: str, *, tool_name: str = "") -> str | None: + """Return an error message if ``uri`` is outside write prefixes. + + Write prefixes are opt-in and independent from + ``allowed_uri_prefixes``. When no ``write_allowed_uri_prefixes`` are + configured, writes are not constrained by URI prefix here; backend + path rules and provider-specific submission scopes still apply. + """ + if not self.write_allowed_uri_prefixes: + return None + if not uri or not uri.startswith("viking://resources/"): + return None + if any(uri.startswith(prefix) for prefix in self.write_allowed_uri_prefixes): + return None + name = tool_name or "viking" + return ( + f"{name}: write_scope_denied: URI {uri!r} is outside the write allowed " + f"prefixes ({self.write_allowed_uri_prefixes})." + ) def _allowed_prefix_for(self, uri: str) -> str | None: """Return the allowed prefix matched by ``uri``, or ``None``. @@ -577,17 +642,19 @@ async def for_run(self, ctx: RunContext[Any]) -> VikingCapability: _owns_client=False, _identity=self._identity, _profile_injected=False, + _index_injected=False, _last_ingested_idx=0, _remember_pending=[], _remember_drain_failures=0, _pending_tasks=set(), + _failed_ingest_batches=[], ) def get_instructions(self) -> str | None: """Return the Viking workflow instructions. When ``allowed_uri_prefixes`` is configured, appends a dynamic - block listing the allowed prefixes so the model can pass a + block listing the read prefixes so the model can pass a ``target_uri`` and skip discovery probing. Returns: @@ -616,7 +683,10 @@ def get_toolset(self) -> AgentToolset[Any] | None: tool_fns = build_tools(self) if not tool_fns: return None - return FunctionToolset(tool_fns, id="viking") + toolset = FunctionToolset(tool_fns, id="viking") + if self.tool_prefix: + return PrefixedToolset(toolset, self.tool_prefix) + return toolset async def get_tools(self) -> Sequence[Any]: """Return tools as ``Tool`` objects for listing endpoints. @@ -1178,7 +1248,7 @@ async def _handle_profile_inject( # noqa: PLR0911 1. Derives a context hint from ``ctx.deps``. 2. Queries Viking memories via ``client.find()``. 3. Formats results as an ```` XML block. - 4. Injects it as a ``SystemPromptPart`` before the latest user + 4. Injects it as a ``UserPromptPart`` before the latest user message using ``dataclasses.replace()``. 5. Sets ``_profile_injected = True``. @@ -1241,7 +1311,6 @@ async def _handle_profile_inject( # noqa: PLR0911 from pydantic_ai.messages import ( ModelRequest, - SystemPromptPart, UserPromptPart, ) @@ -1258,13 +1327,102 @@ async def _handle_profile_inject( # noqa: PLR0911 if insert_idx is None: return request_context - system_msg = ModelRequest(parts=[SystemPromptPart(content=profile_block)]) + system_msg = ModelRequest(parts=[UserPromptPart(content=profile_block)]) new_messages = [*messages[:insert_idx], system_msg, *messages[insert_idx:]] return replace(request_context, messages=new_messages) except Exception: logger.warning("Profile injection failed", exc_info=True) return request_context + # ---- Index Injection (Dynamic Namespaces) ---- + + async def _handle_index_inject( + self, + ctx: RunContext[Any], + request_context: ModelRequestContext, + ) -> ModelRequestContext: + """Inject a live resource-namespace index block on the first turn. + + When ``index_enabled`` is ``True`` and this is the first + ``before_model_request`` call for the session (``_index_injected`` + is ``False`` and message count is <= 2), the method: + + 1. Lists namespaces under the index URI (``viking://resources/`` + by default, non-recursive) via ``client.ls()``. + 2. Collects entry names where ``isDir`` is truthy. + 3. Formats them as an ```` XML block. + 4. Injects it as a ``UserPromptPart`` before the latest user + message using ``dataclasses.replace()``. + 5. Sets ``_index_injected = True``. + + On any error, logs a warning and returns the original + ``request_context`` unchanged (flag already set, no retry). + + Args: + ctx: The pydantic-ai run context. + request_context: The model request context containing messages. + + Returns: + The (possibly modified) model request context. + """ + if not self.index_enabled or self._index_injected: + return request_context + + if len(request_context.messages) > self._FIRST_TURN_MAX_MESSAGES: + self._index_injected = True + return request_context + + self._index_injected = True + + try: + client = await self._ensure_client() + from wolfharness.capabilities.viking.index import _format_index_block + + uri = self.index_uri if self.index_uri is not None else "viking://resources/" + entries = await client.ls(uri) + namespaces: list[str] = [] + if isinstance(entries, list): + for entry in entries: + if isinstance(entry, dict) and entry.get("isDir"): + name = str(entry.get("name") or "") + if name: + namespaces.append(name) + + index_block = _format_index_block( + namespaces, + max_tokens=self.index_max_tokens, + limit=self.index_limit, + ) + if not index_block.strip(): + return request_context + + from dataclasses import replace + + from pydantic_ai.messages import ( + ModelRequest, + UserPromptPart, + ) + + messages = list(request_context.messages) + insert_idx: int | None = None + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + if isinstance(msg, ModelRequest) and any( + isinstance(p, UserPromptPart) for p in msg.parts + ): + insert_idx = i + break + + if insert_idx is None: + return request_context + + system_msg = ModelRequest(parts=[UserPromptPart(content=index_block)]) + new_messages = [*messages[:insert_idx], system_msg, *messages[insert_idx:]] + return replace(request_context, messages=new_messages) + except Exception: + logger.warning("Index injection failed", exc_info=True) + return request_context + # ---- Multimodal Bridge (Phase 6) ---- async def before_model_request( @@ -1279,9 +1437,10 @@ async def before_model_request( 0. Remember drain (capture deferred ``viking_remember`` intents) 1. Auto-ingest (process previous turn, fire-and-forget) 2. Profile injection (add static profile, first turn only) - 3. Auto-recall (add dynamic recalled memories) - 4. Compaction archive (reduce context if too large) - 5. Multimodal bridge (handle binary content) + 3. Index injection (add live resource namespaces, first turn only) + 4. Auto-recall (add dynamic recalled memories) + 5. Compaction archive (reduce context if too large) + 6. Multimodal bridge (handle binary content) Each handler checks its own enabled flag and returns early if disabled (D14). Handlers receive the output of the previous @@ -1304,6 +1463,7 @@ async def before_model_request( request_context = await self._handle_remember_drain(ctx, request_context) request_context = await self._handle_auto_ingest(ctx, request_context) request_context = await self._handle_profile_inject(ctx, request_context) + request_context = await self._handle_index_inject(ctx, request_context) request_context = await self._handle_auto_recall(ctx, request_context) request_context = await self._handle_compaction(ctx, request_context) return await self._handle_multimodal_bridge(ctx, request_context) @@ -1792,10 +1952,7 @@ async def _handle_auto_recall( # noqa: PLR0911 logger.warning("auto_recall: failed to ensure client", exc_info=True) return request_context - # Extract session_id from ctx.deps if available - from wolfharness.capabilities.viking.tools import _get_session_id - - session_id = _get_session_id(ctx) + session_id = self._resolve_recall_session_id(ctx) try: session_context: dict[str, Any] | None = None @@ -1856,6 +2013,12 @@ async def _handle_auto_recall( # noqa: PLR0911 logger.warning("auto_recall: recall failed", exc_info=True) return request_context + def _resolve_recall_session_id(self, ctx: RunContext[Any]) -> str | None: + """Return the Viking session id used for session-aware recall.""" + from wolfharness.capabilities.viking.tools import _get_session_id + + return _get_session_id(ctx) + # ---- Auto Conversation Ingestion ---- async def _handle_auto_ingest( @@ -1868,8 +2031,8 @@ async def _handle_auto_ingest( Called from ``before_model_request`` (wired in Group 7). Extracts new messages since ``_last_ingested_idx``, sanitizes them, and fires-and-forget an ``asyncio.create_task`` to write them to a - new Viking session. Updates the ingestion cursor regardless of - success or failure (to avoid retrying on every subsequent turn). + new Viking session. Failed batches are retained for retry so + compaction cannot erase the only copy of an uncommitted turn. When ``auto_ingest_mode`` is ``"sync"``, awaits the ingestion directly instead of spawning a background task. @@ -1889,6 +2052,17 @@ async def _handle_auto_ingest( current_count = len(messages) # No new messages since last ingestion + if current_count <= self._last_ingested_idx and not self._failed_ingest_batches: + return request_context + + try: + client = await self._ensure_client() + except Exception: + logger.warning("auto_ingest: failed to ensure client", exc_info=True) + return request_context + + await self._retry_failed_ingest_batches(client) + if current_count <= self._last_ingested_idx: return request_context @@ -1903,15 +2077,11 @@ async def _handle_auto_ingest( if self.auto_ingest_sanitize: pairs = [{"role": p["role"], "content": _sanitize_message(p["content"])} for p in pairs] - # Update cursor BEFORE ingestion to prevent retries on failure + previous_idx = self._last_ingested_idx + # Claim the range before scheduling to avoid duplicate concurrent ingestion. + # If the commit fails, the task rolls this back so the next boundary retries. self._last_ingested_idx = current_count - try: - client = await self._ensure_client() - except Exception: - logger.warning("auto_ingest: failed to ensure client", exc_info=True) - return request_context - # Generate a unique session ID for this ingestion session_id = f"ingest-{uuid.uuid4().hex[:12]}" @@ -1925,6 +2095,15 @@ async def _do_ingest() -> None: keep_recent_turns=self.auto_ingest_keep_recent_turns, ) except Exception: + if self._last_ingested_idx == current_count: + self._last_ingested_idx = previous_idx + self._failed_ingest_batches.append(( + list(pairs), + session_id, + self.auto_ingest_source_type, + self.auto_ingest_keep_recent_turns, + current_count, + )) logger.warning("auto_ingest: ingestion failed", exc_info=True) if self.auto_ingest_mode == "sync": @@ -1981,6 +2160,32 @@ async def after_run( logger.warning("remember: tail-flush failed", exc_info=True) return result + async def _retry_failed_ingest_batches(self, client: Any) -> None: + """Retry captured auto-ingest batches whose source messages may be gone.""" + if not self._failed_ingest_batches: + return + pending = self._failed_ingest_batches + self._failed_ingest_batches = [] + for pairs, session_id, source_type, keep_recent_turns, cursor_after in pending: + try: + await _ingest_conversation( + client, + pairs, + session_id=session_id, + source_type=source_type, + keep_recent_turns=keep_recent_turns, + ) + self._last_ingested_idx = max(self._last_ingested_idx, cursor_after) + except Exception: + self._failed_ingest_batches.append(( + pairs, + session_id, + source_type, + keep_recent_turns, + cursor_after, + )) + logger.warning("auto_ingest: retry failed", exc_info=True) + async def _flush_tail(self, ctx: RunContext[Any]) -> None: """Flush pending remember intent and trailing messages at run end. @@ -1998,11 +2203,17 @@ async def _flush_tail(self, ctx: RunContext[Any]) -> None: Args: ctx: The pydantic-ai run context. """ - if not self.auto_ingest_enabled and not self._remember_pending: + if ( + not self.auto_ingest_enabled + and not self._remember_pending + and not self._failed_ingest_batches + ): return messages = ctx.messages current_count = len(messages) + client = await self._ensure_client() + await self._retry_failed_ingest_batches(client) if current_count <= self._last_ingested_idx and not self._remember_pending: return @@ -2019,7 +2230,6 @@ async def _flush_tail(self, ctx: RunContext[Any]) -> None: "content": _MEMORY_INTENT_TEMPLATE.format(reason=reason), }) - client = await self._ensure_client() session_id = f"remember-{uuid.uuid4().hex[:12]}" await _ingest_conversation( client, diff --git a/src/wolfharness/capabilities/viking/compaction.py b/src/wolfharness/capabilities/viking/compaction.py index 89224d375..a0f584f78 100644 --- a/src/wolfharness/capabilities/viking/compaction.py +++ b/src/wolfharness/capabilities/viking/compaction.py @@ -195,7 +195,7 @@ def _replace_old_messages( """Create a new ModelRequestContext with archivable messages replaced. Removes the archivable messages from the front of the list and - inserts a new ``ModelRequest`` containing a ``SystemPromptPart`` with + inserts a new ``ModelRequest`` containing a ``UserPromptPart`` with the summary and a reference to the archive URI. The kept messages follow immediately after. @@ -209,11 +209,11 @@ def _replace_old_messages( Returns: A new ``ModelRequestContext`` with the modified message list. """ - from pydantic_ai.messages import ModelRequest, SystemPromptPart + from pydantic_ai.messages import ModelRequest, UserPromptPart archive_message = ModelRequest( parts=[ - SystemPromptPart( + UserPromptPart( content=( f"[Conversation history archived. Summary:\n{summary}\n\n" f"Full archive available at: {archive_uri}\n" diff --git a/src/wolfharness/capabilities/viking/index.py b/src/wolfharness/capabilities/viking/index.py new file mode 100644 index 000000000..274ecf9b2 --- /dev/null +++ b/src/wolfharness/capabilities/viking/index.py @@ -0,0 +1,60 @@ +"""Dynamic resource-namespace index injection helpers for VikingCapability. + +Provides a pure-function helper for formatting live resource namespaces +as an ```` XML block — used by +``VikingCapability._handle_index_inject()``. This is the dynamic +counterpart to the static ``_VIKING_INSTRUCTIONS``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from wolfharness.capabilities.viking.utils import truncate_text + + +if TYPE_CHECKING: + from collections.abc import Sequence + + +_INDEX_BLOCK = ( + "\n" + "Viking resources are addressed by viking:// URIs. Live resource " + "namespaces under viking://resources/:\n" + "{namespaces}\n" + 'Use viking_ls("") to enumerate deeper, viking_search/viking_find ' + "to locate content, viking_read to fetch.\n" + "" +) + + +def _format_index_block( + namespaces: Sequence[str], + *, + max_tokens: int, + limit: int, +) -> str: + """Format live resource namespaces as an ```` XML block. + + Joins at most ``limit`` namespace names with ``", "`` and renders the + block (contract consumed by external prompts). The total block is + truncated to approximately ``max_tokens`` characters (using chars as + a proxy with a 4:1 heuristic, same as ``_format_profile_block``). + + Args: + namespaces: Discovered resource namespace names. + max_tokens: Maximum token budget — content is truncated to + ``max_tokens * 4`` characters with a truncation indicator. + limit: Maximum number of namespace names to include. + + Returns: + A formatted ```` XML block string. Returns an + empty string if there are no namespaces. + """ + truncated = namespaces[:limit] + if not truncated: + return "" + + max_chars = max_tokens * 4 # rough chars-to-tokens heuristic + block = _INDEX_BLOCK.format(namespaces=", ".join(truncated)) + return truncate_text(block, max_chars) diff --git a/src/wolfharness/capabilities/viking/ingest.py b/src/wolfharness/capabilities/viking/ingest.py index d68e8f91b..bdc7a666a 100644 --- a/src/wolfharness/capabilities/viking/ingest.py +++ b/src/wolfharness/capabilities/viking/ingest.py @@ -24,6 +24,9 @@ # ``re.DOTALL`` ensures the pattern matches across newlines. _RECALL_RE = re.compile(r".*?", re.DOTALL) _PROFILE_RE = re.compile(r".*?", re.DOTALL) +_CONTEXT_RE = re.compile(r"]*>.*?", re.DOTALL) +_SESSION_ARCHIVE_RE = re.compile(r"]*>.*?", re.DOTALL) +_USER_PROFILE_RE = re.compile(r"]*>.*?", re.DOTALL) _REPLACEMENT = "[recalled context omitted]" @@ -39,8 +42,11 @@ def _sanitize_message(content: str, enabled: bool = True) -> str: """Strip injected Viking XML blocks from message content. - Replaces ``...`` and - ``...`` blocks with + Replaces ``...``, + ``...``, + ``...``, + ``...``, and + ``...`` blocks with ``[recalled context omitted]`` to prevent feedback loops where recalled context is re-ingested as original conversation. @@ -54,7 +60,10 @@ def _sanitize_message(content: str, enabled: bool = True) -> str: if not enabled: return content sanitized = _RECALL_RE.sub(_REPLACEMENT, content) - return _PROFILE_RE.sub(_REPLACEMENT, sanitized) + sanitized = _PROFILE_RE.sub(_REPLACEMENT, sanitized) + sanitized = _CONTEXT_RE.sub(_REPLACEMENT, sanitized) + sanitized = _SESSION_ARCHIVE_RE.sub(_REPLACEMENT, sanitized) + return _USER_PROFILE_RE.sub(_REPLACEMENT, sanitized) def _extract_conversation_pairs( @@ -83,8 +92,8 @@ def _extract_conversation_pairs( if isinstance(msg, ModelRequest): for part in msg.parts: if isinstance(part, UserPromptPart): - content = part.content - if isinstance(content, str): + content = _extract_text_content(part.content) + if content: pairs.append({"role": "user", "content": content}) elif isinstance(msg, ModelResponse): text_parts = [p for p in msg.parts if isinstance(p, TextPart)] @@ -94,6 +103,179 @@ def _extract_conversation_pairs( return pairs +def _extract_full_trace( + messages: Sequence[Any], + start_idx: int, +) -> list[dict[str, Any]]: + """Extract full conversation trace including tool calls and results. + + Unlike ``_extract_conversation_pairs`` which only captures user/assistant + text, this function extracts **all** part types from pydantic-ai messages + and produces Viking-compatible ``{role, parts}`` dicts: + + - ``UserPromptPart`` → ``{"type": "text", "text": "..."}`` + - ``TextPart`` / ``ThinkingPart`` → ``{"type": "text", "text": "..."}`` + - ``ToolCallPart`` → ``{"type": "tool", "tool_name": ..., "tool_status": "running", ...}`` + - ``ToolReturnPart`` → ``{"type": "tool", "tool_status": "completed"/"error"}`` + + Args: + messages: Sequence of ``ModelRequest`` / ``ModelResponse`` objects. + start_idx: Index to start scanning from (inclusive). + + Returns: + A list of ``{"role": "user"|"assistant", "parts": [...]}`` dicts + in Viking-compatible format. + """ + from pydantic_ai.messages import ( + ModelRequest, + TextPart, + ThinkingPart, + ToolCallPart, + ToolReturnPart, + UserPromptPart, + ) + + result: list[dict[str, Any]] = [] + for msg in messages[start_idx:]: + parts: list[dict[str, Any]] = [] + role = "user" if isinstance(msg, ModelRequest) else "assistant" + + for part in msg.parts: + part_role = _trace_part_role(part, default=role) + if part_role != role: + if parts: + result.append({"role": role, "parts": parts}) + parts = [] + role = part_role + + if isinstance(part, UserPromptPart): + content = _extract_text_content(part.content) + if content: + _append_text_part(parts, _sanitize_message(content)) + + elif isinstance(part, TextPart): + if part.content.strip(): + _append_text_part(parts, _sanitize_message(part.content)) + + elif isinstance(part, ThinkingPart): + if part.content.strip(): + _append_text_part(parts, _sanitize_message(part.content), subtype="thinking") + + elif isinstance(part, ToolCallPart): + tool_part: dict[str, Any] = { + "type": "tool", + "tool_name": part.tool_name or "", + "tool_status": "running", + } + if part.tool_call_id: + tool_part["tool_id"] = part.tool_call_id + if part.args is not None: + tool_part["tool_input"] = _serialize_tool_args(part.args) + parts.append(tool_part) + + elif isinstance(part, ToolReturnPart): + outcome = getattr(part, "outcome", None) + tool_part = { + "type": "tool", + "tool_name": part.tool_name or "", + "tool_status": "error" if outcome == "failed" else "completed", + } + if part.tool_call_id: + tool_part["tool_id"] = part.tool_call_id + if part.content is not None: + tool_part["tool_output"] = _serialize_tool_output(part.content) + parts.append(tool_part) + + if parts: + result.append({"role": role, "parts": parts}) + + return result + + +def _trace_part_role(part: Any, *, default: str) -> str: + from pydantic_ai.messages import ToolCallPart, ToolReturnPart, UserPromptPart + + if isinstance(part, (ToolCallPart, ToolReturnPart)): + return "assistant" + if isinstance(part, UserPromptPart): + return "user" + return default + + +def _extract_text_content(content: Any) -> str: + """Extract human-readable text from pydantic-ai prompt content.""" + if isinstance(content, str): + return content.strip() + if not isinstance(content, list): + return "" + chunks: list[str] = [] + for item in content: + if isinstance(item, str): + text = item + elif isinstance(item, dict): + value = item.get("text") or item.get("content") + text = value if isinstance(value, str) else "" + else: + value = getattr(item, "text", None) + if not isinstance(value, str): + value = getattr(item, "content", None) + text = value if isinstance(value, str) else "" + if text.strip(): + chunks.append(text.strip()) + return "\n".join(chunks) + + +def _append_text_part( + parts: list[dict[str, Any]], + text: str, + *, + subtype: str | None = None, +) -> None: + """Append text while coalescing adjacent text chunks with matching subtype.""" + stripped = text.strip() + if not stripped: + return + if parts and parts[-1].get("type") == "text" and parts[-1].get("subtype") == subtype: + parts[-1]["text"] = f"{parts[-1].get('text', '')}\n{stripped}" + return + part: dict[str, Any] = {"type": "text", "text": stripped} + if subtype is not None: + part["subtype"] = subtype + parts.append(part) + + +def _serialize_tool_args(args: Any) -> Any: + """Serialize tool call arguments to a JSON-safe dict. + + Handles string-encoded JSON (parsed), native dicts (passed through), + and other types (wrapped in ``{"value": ...}``). + """ + if isinstance(args, str): + try: + return json.loads(args) + except (json.JSONDecodeError, TypeError): + return {"value": args} + if isinstance(args, dict): + return args + if isinstance(args, list): + return {"items": args} + return {"value": str(args)} if args is not None else {} + + +def _serialize_tool_output(content: Any) -> str: + """Serialize tool return content to a string. + + Preserves the complete textual payload so Viking has the auditable + tool trace. Display-layer truncation should happen outside ingestion. + """ + if isinstance(content, str): + return content + try: + return json.dumps(content, ensure_ascii=False, default=str) + except (TypeError, ValueError): + return str(content) + + async def _ingest_conversation( client: Any, messages: list[dict[str, str]], diff --git a/src/wolfharness/capabilities/viking/instructions.py b/src/wolfharness/capabilities/viking/instructions.py index 3d14d08ab..f7d4a58d1 100644 --- a/src/wolfharness/capabilities/viking/instructions.py +++ b/src/wolfharness/capabilities/viking/instructions.py @@ -19,12 +19,13 @@ def format_allowed_prefixes_block(prefixes: Sequence[str]) -> str: - """Build the allowed-prefix instruction block. + """Build the read allowed-prefix instruction block. Appended to the base instructions when ``allowed_uri_prefixes`` is configured. Rendered dynamically at ``get_instructions()`` time so the - model sees the exact prefixes this session may access — it can then pass - the most specific prefix as ``target_uri`` and skip discovery probing. + model sees the exact prefixes this session may read/search — it can then + pass the most specific prefix as ``target_uri`` and skip discovery + probing. Args: prefixes: The configured allowed URI prefixes. @@ -34,14 +35,16 @@ def format_allowed_prefixes_block(prefixes: Sequence[str]) -> str: """ allowed = "\n".join(f" - `{p}`" for p in prefixes) return ( - "\n### Allowed URI Prefixes (this session)\n" - "Your Viking access is restricted to these prefixes:\n" + "\n### Read URI Prefixes (this session)\n" + "Your Viking read/search access is restricted to these prefixes:\n" f"{allowed}\n" "When calling `viking_search` / `viking_find`, ALWAYS pass the most " "specific matching prefix as `target_uri` — it scopes the search and " "is faster. Omitting it searches all allowed prefixes, which is slower. " "Use `viking_ls` on these prefixes to see what is in scope. Do not " - "probe outside them — you will get access errors." + "probe outside them for reads — you will get read_scope_denied errors. " + "These prefixes are not write destinations and must not be compared " + "with provider-owned ticket submission scopes." ) @@ -100,6 +103,7 @@ def format_allowed_prefixes_block(prefixes: Sequence[str]) -> str: | Create new content | `viking_write` | New document at a specific URI | | Edit existing content | `viking_edit` | Replace a specific string within a document | | Ingest external files | `viking_add_resource` | Add local files or remote URLs to the Viking graph | +| Upload an entire tree | `viking_upload_tree` | Upload a local directory tree in ONE `wait=True` call (embeddings built before return) | | Create directory | `viking_mkdir` | Organize content hierarchically | | Delete content | `viking_forget` | Remove a document or directory (irreversible — confirm before calling) | @@ -113,6 +117,9 @@ def format_allowed_prefixes_block(prefixes: Sequence[str]) -> str: - Example: `viking://resources/wiki/Device/SY215.md` - **`viking_add_resource`**: The `to` parameter must target a URI under `viking://resources/`. +- **`viking_upload_tree`**: The `to` parameter must target a URI under + `viking://resources/`. Skipped at the top level: dot-prefixed names, + `index/`, `source_packets/`. - **`viking_link`**: Both `from_uri` and all `to_uris` must point to existing nodes. The backend rejects links to non-existent nodes. Create entities before linking them. @@ -121,15 +128,15 @@ def format_allowed_prefixes_block(prefixes: Sequence[str]) -> str: Always use **full** `viking://` URIs. Never use relative paths. Use `viking_ls` to discover available URIs when unsure. -### Access Restrictions (URI Prefixes) +### Read Access Restrictions (URI Prefixes) -This agent's Viking access may be restricted to a configured set of URI -prefixes. URIs outside those prefixes are rejected by the tools with an -error. When an access error appears, do not retry with modified URIs — -use `viking_ls` on the allowed prefixes to see what is in scope, or use -`viking_search`/`viking_find` without a `target_uri` (the search is -scoped automatically). Never attempt to bypass the restriction by -guessing paths. +This agent's Viking read/search access may be restricted to a configured set of +URI prefixes. These prefixes scope retrieval and evidence discovery only; they +are not write destinations and must not be treated as provider-owned submission +scopes. When a read access error appears, do not retry with modified URIs — use +`viking_ls` on the read prefixes to see what is in scope, or use +`viking_search`/`viking_find` without a `target_uri` (the search is scoped +automatically). Never infer write paths from citation or read prefixes. ### Memory Tools @@ -151,6 +158,10 @@ def format_allowed_prefixes_block(prefixes: Sequence[str]) -> str: - **`viking_link`**: Create typed links between nodes. Both source and target must exist. Use `reason` to label the relationship type (e.g., "depends-on", "references", "causes"). +- **`viking_link_relations`**: Push a local relations file + (`{target_uri: [source_uri, ...]}`) as bidirectional `link()` relations — + `{reason_prefix}:referenced-by` at each target, `{reason_prefix}:references` + at each source. URIs outside `namespace_base` are skipped. - **`viking_set_tags`**: Add `key=value` tags to nodes for categorization (e.g., `status=active`, `priority=high`). Use `recursive=True` to tag all children. @@ -230,4 +241,6 @@ def format_allowed_prefixes_block(prefixes: Sequence[str]) -> str: (`viking_remember`, `viking_recall`) does not enable `viking_forget`. - Even when enabled, always confirm with the user before deleting content. Deletion is irreversible. + +An `` block may be present in the first turn, listing live resource namespaces under `viking://resources/` for direct exploration via `viking_ls`. """ diff --git a/src/wolfharness/capabilities/viking/recall.py b/src/wolfharness/capabilities/viking/recall.py index 8e865e3a6..8c3d3f960 100644 --- a/src/wolfharness/capabilities/viking/recall.py +++ b/src/wolfharness/capabilities/viking/recall.py @@ -11,6 +11,7 @@ import hashlib from typing import TYPE_CHECKING, Any +from wolfharness.capabilities.viking.ingest import _extract_text_content from wolfharness.capabilities.viking.utils import truncate_text @@ -44,8 +45,8 @@ def _extract_latest_user_prompt(messages: list[Any]) -> str | None: for part in reversed(msg.parts): if not isinstance(part, UserPromptPart): continue - content = part.content - if isinstance(content, str) and content.strip(): + content = _extract_text_content(part.content) + if content: return content return None @@ -179,9 +180,9 @@ def _inject_system_message( request_context: ModelRequestContext, recall_block: str, ) -> ModelRequestContext: - """Inject a system message with recall block before the latest user message. + """Inject a context message with recall block before the latest user message. - Creates a new ``ModelRequest`` containing a ``SystemPromptPart`` with the + Creates a new ``ModelRequest`` containing a ``UserPromptPart`` with the recall block text, and inserts it into ``request_context.messages`` immediately before the last ``ModelRequest`` that contains a ``UserPromptPart``. @@ -197,7 +198,7 @@ def _inject_system_message( if not recall_block.strip(): return request_context - from pydantic_ai.messages import ModelRequest, SystemPromptPart, UserPromptPart + from pydantic_ai.messages import ModelRequest, UserPromptPart messages = list(request_context.messages) @@ -212,7 +213,7 @@ def _inject_system_message( if insert_idx is None: return request_context - system_msg = ModelRequest(parts=[SystemPromptPart(content=recall_block)]) + system_msg = ModelRequest(parts=[UserPromptPart(content=recall_block)]) new_messages = [*messages[:insert_idx], system_msg, *messages[insert_idx:]] return replace(request_context, messages=new_messages) diff --git a/src/wolfharness/capabilities/viking/ticket.py b/src/wolfharness/capabilities/viking/ticket.py new file mode 100644 index 000000000..785f0a77c --- /dev/null +++ b/src/wolfharness/capabilities/viking/ticket.py @@ -0,0 +1,4 @@ +"""Backward-compat re-export; actual code moved to wolfharness.capabilities.wiki.tickets.ticket.""" + +from wolfharness.capabilities.wiki.tickets.ticket import * # noqa: F403 +from wolfharness.capabilities.wiki.tickets.ticket import _sync_entity_to_remote # noqa: F401 diff --git a/src/wolfharness/capabilities/viking/tools.py b/src/wolfharness/capabilities/viking/tools.py index 9b33d8ae6..56295ae26 100644 --- a/src/wolfharness/capabilities/viking/tools.py +++ b/src/wolfharness/capabilities/viking/tools.py @@ -9,7 +9,10 @@ from __future__ import annotations import asyncio -from pathlib import PurePosixPath +import json +from pathlib import Path, PurePosixPath +import shutil +import tempfile from typing import TYPE_CHECKING, Any, Literal from pydantic_ai.messages import BinaryImage, ToolReturn @@ -35,6 +38,159 @@ from wolfharness.capabilities.viking import VikingCapability +# One wait=True tree upload blocks until semantic+embedding work is done. +_TREE_UPLOAD_TIMEOUT_S = 900.0 +# Build-internal dirs never uploaded (wiki convention; skipped at top level). +_TREE_UPLOAD_SKIP_NAMES: frozenset[str] = frozenset({"index", "source_packets"}) +# Upload tmp filename gate: renaming would orphan every URI reference, so +# generation-time clipping is required (never renamed here). +_TREE_UPLOAD_MAX_FILENAME_BYTES = 200 +# Backoff (seconds) between link() retries after a busy rejection. +_LINK_BACKOFF_S: tuple[float, ...] = (5.0, 15.0, 30.0) + + +def _is_busy_error(exc: BaseException) -> bool: + """True when the backend rejected a link() because a tree is still busy. + + The server-side ``ResourceBusyError`` is not exported by the published SDK; + the client preserves the server's error ``code`` on a generic + ``OpenVikingError`` either way. + """ + code = str(getattr(exc, "code", "")) + if "BUSY" in code.upper(): + return True + try: + from openviking_sdk import errors as _errors + + busy_cls = getattr(_errors, "ResourceBusyError", None) + return busy_cls is not None and isinstance(exc, busy_cls) + except ImportError: + return False + + +def _populate_upload_tree(src: Path, dst: Path) -> None: + """Populate ``dst`` from ``src`` (binary-safe), gating long basenames.""" + + def _check_name_len(file_path: Path) -> None: + if len(file_path.name.encode("utf-8")) > _TREE_UPLOAD_MAX_FILENAME_BYTES: + raise ValueError( + f"filename {file_path.name!r} exceeds {_TREE_UPLOAD_MAX_FILENAME_BYTES} " + "bytes; id builders must clip at generation time" + ) + + for child in sorted(src.iterdir()): + name: str = child.name + if name.startswith(".") or name in _TREE_UPLOAD_SKIP_NAMES: + continue + if child.is_dir(): + shutil.copytree(child, dst / name, copy_function=shutil.copy2) + for file_path in (dst / name).rglob("*"): + if file_path.is_file(): + _check_name_len(file_path) + elif child.is_file(): + dest = dst / name + shutil.copy2(child, dest) + _check_name_len(dest) + + +def _copy_upload_tree(src_root: str) -> str: + """Copy a local tree to a temp dir for a single unified upload. + + ``shutil.copy2`` is binary-safe (never re-encodes content) and nested + structure is preserved. Top-level names that are dot-prefixed or in + ``_TREE_UPLOAD_SKIP_NAMES`` are excluded. The 200-byte basename gate + raises ``ValueError`` on violation — the copy never renames (renaming + would silently break every URI reference to the original filename). + + Returns: + Temp root path string; the caller is responsible for cleanup. + """ + tmp = Path(tempfile.mkdtemp(prefix="ov_up_tree_")) + error: BaseException | None = None + try: + _populate_upload_tree(Path(src_root), tmp) + except Exception as exc: + error = exc + if error is not None: + shutil.rmtree(tmp, ignore_errors=True) + raise error + return str(tmp) + + +def _load_relations_file(relations_file: str) -> dict[str, Any]: + """Read and validate a local relations file ({target: [sources]}).""" + data = json.loads(Path(relations_file).read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise TypeError(f"{relations_file} is not a JSON object") + return data + + +async def _resolve_uri(client: Any, uri: str) -> str | None: + """Resolve a logical URI to the server's actual node path. + + OpenViking's markdown parser wraps every ``.md`` import into a same-named + directory (``doc.md`` -> ``doc/doc.md``), so a locally-built URI like + ``viking://resources/{ns}/Fault/X.md`` does not exist verbatim on the + server. link() records the string as-is without validating the node, which + would silently orphan edges on non-existent URIs. This helper verifies the + URI with ``stat()`` and, when the server reports the file missing, retries + the markdown wrapper form (``Path.md`` -> ``Path/Path.md``). + + Args: + client: The initialized Viking SDK client (async). + uri: Logical viking:// URI to resolve. + + Returns: + The resolved URI that exists on the server, or ``None`` when neither + the literal URI nor the wrapper variant exists. + """ + for candidate in (uri, _md_wrapper_uri(uri)): + try: + await client.stat(candidate) + return candidate + except Exception: + continue + return None + + +def _md_wrapper_uri(uri: str) -> str: + """Return the markdown wrapper form of a URI (``d/f.md`` -> ``d/f/f.md``). + + The plain form is returned unchanged when the last segment has no ``.md`` + suffix — non-markdown files are never wrapped by the server. + """ + if not uri.endswith(".md"): + return uri + stem = uri[:-3] + name = stem.rsplit("/", 1)[-1] + return f"{stem}/{name}.md" + + +async def _link_best_effort(client: Any, from_uri: str, to_uris: list[str], reason: str) -> bool: + """Call ``client.link`` once; retry on busy errors with backoff. + + The backend rejects link() against a tree still held by the semantic + handoff with a fail-fast busy error — retrying after the upload completes + is the correct recovery. Permanent errors (not found, permission denied, + ...) are not retried. + + Returns: + ``True`` on success, ``False`` after exhausting retries or on error. + """ + attempts = 1 + len(_LINK_BACKOFF_S) + for attempt in range(attempts): + try: + await client.link(from_uri, to_uris, reason=reason) + return True + except Exception as exc: + if not _is_busy_error(exc): + return False + if attempt < len(_LINK_BACKOFF_S): + await asyncio.sleep(_LINK_BACKOFF_S[attempt]) + continue + return False + return False + def _get_session_id(ctx: RunContext[Any]) -> str | None: """Extract session_id from RunContext deps if available.""" @@ -75,9 +231,9 @@ def build_tools(cap: VikingCapability) -> list[Callable[..., Any]]: ``cap.mode``: - ``"retrieve"``: 7 read-only tools - - ``"write"``: 6 write tools - - ``"graph"``: 2 graph tools - - ``"all"``: all 15 tools + - ``"write"``: 7 write tools + - ``"graph"``: 3 graph tools + - ``"all"``: all 17 tools Args: cap: The ``VikingCapability`` instance that owns these tools. @@ -641,7 +797,7 @@ async def viking_write( """ try: client = await cap._ensure_client() - if err := cap._check_uri_allowed(uri, tool_name="viking_write"): + if err := cap._check_write_uri_allowed(uri, tool_name="viking_write"): return ToolReturn(return_value=err) await client.write(uri, content, mode=mode) return ToolReturn( @@ -662,7 +818,8 @@ async def viking_edit( Uses a read-modify-write cycle: reads the current content, replaces ``old_string`` with ``new_string``, then writes back. The URI must be under ``memories/`` or ``resources/`` (same - restriction as ``viking_write``). + read-side restriction as ``viking_read`` because it must fetch + the existing content before writing the replacement. Args: uri: Full viking:// URI of the document to edit. @@ -679,6 +836,8 @@ async def viking_edit( client = await cap._ensure_client() if err := cap._check_uri_allowed(uri, tool_name="viking_edit"): return ToolReturn(return_value=err) + if err := cap._check_write_uri_allowed(uri, tool_name="viking_edit"): + return ToolReturn(return_value=err) current = await client.read(uri) count = current.count(old_string) if count == 0: @@ -717,7 +876,7 @@ async def viking_mkdir( """ try: client = await cap._ensure_client() - if err := cap._check_uri_allowed(uri, tool_name="viking_mkdir"): + if err := cap._check_write_uri_allowed(uri, tool_name="viking_mkdir"): return ToolReturn(return_value=err) await client.mkdir(uri, description=description) return ToolReturn(return_value=f"Created directory {uri}.") @@ -730,6 +889,7 @@ async def viking_add_resource( to: str | None = None, parent: str | None = None, processing_mode: str | None = None, + wait: bool = False, watch_interval: float = 0, ) -> ToolReturn: """Add an external resource to the Viking knowledge graph. @@ -742,8 +902,12 @@ async def viking_add_resource( path: Local file or directory path to ingest. to: Target viking:// URI under ``resources/`` to store the resource. parent: Parent viking:// URI under ``resources/`` for nesting. - processing_mode: Processing mode for the resource (unused by - current SDK \u2014 kept for API compatibility). + processing_mode: Processing mode for the resource (e.g. + ``"semantic_and_vectors"`` to build embeddings). When + ``None`` the SDK default applies. + wait: When ``True``, block until processing (semantic + + embedding) completes before returning. Required before + linking or searching the ingested tree. watch_interval: Watch interval in seconds (0 = no watch). Returns: @@ -753,7 +917,10 @@ async def viking_add_resource( client = await cap._ensure_client() for target, label in ((to, "to"), (parent, "parent")): if target and ( - err := cap._check_uri_allowed(target, tool_name="viking_add_resource") + err := cap._check_write_uri_allowed( + target, + tool_name="viking_add_resource", + ) ): return ToolReturn(return_value=f"{err} ({label})") # SDK add_resource() does not accept processing_mode; @@ -763,6 +930,8 @@ async def viking_add_resource( to=to, parent=parent, watch_interval=watch_interval, + processing_mode=processing_mode, + wait=wait, ) return ToolReturn(return_value=f"Added resource {path} to Viking. Result: {result}") except Exception as e: @@ -770,6 +939,58 @@ async def viking_add_resource( return_value=f"viking_add_resource error ({type(e).__name__}): {e}" ) + async def viking_upload_tree( + ctx: RunContext[Any], + path: str, + to: str | None = None, + processing_mode: str = "semantic_and_vectors", + wait: bool = True, + timeout: float = _TREE_UPLOAD_TIMEOUT_S, + ) -> ToolReturn: + """Upload an entire local tree to Viking in ONE wait=True call. + + Copies the tree (binary-safe) to a temp dir — skipping + dot-prefixed, ``index/`` and ``source_packets/`` top-level + entries — then calls ``add_resource(wait=True)`` so the whole + namespace is vector-embedded and semantically searchable before + the tool returns. Use it before ``viking_link_relations`` when + the local wiki stores entities that should not be linked before + the upload finished embedding them. + + Args: + path: Local directory to upload. + to: Target viking:// URI under ``resources/`` (e.g. + ``viking://resources/wiki/``). + processing_mode: Processing mode for the tree (default + ``"semantic_and_vectors"`` — embed everything). + wait: When ``True`` (default), block until semantic + + embedding processing completes. + timeout: Per-call timeout in seconds (default 900). + + Returns: + Confirmation string. + """ + try: + temp_root = _copy_upload_tree(path) + try: + client = await cap._ensure_client() + if to and ( + err := cap._check_write_uri_allowed(to, tool_name="viking_upload_tree") + ): + return ToolReturn(return_value=err) + result = await client.add_resource( + path=temp_root, + to=to, + processing_mode=processing_mode, + wait=wait, + timeout=timeout, + ) + finally: + shutil.rmtree(temp_root, ignore_errors=True) + return ToolReturn(return_value=f"Uploaded tree {path} to {to}. Result: {result}") + except Exception as e: + return ToolReturn(return_value=f"viking_upload_tree error: {e}") + async def viking_forget( ctx: RunContext[Any], uri: str, @@ -786,7 +1007,7 @@ async def viking_forget( """ try: client = await cap._ensure_client() - if err := cap._check_uri_allowed(uri, tool_name="viking_forget"): + if err := cap._check_write_uri_allowed(uri, tool_name="viking_forget"): return ToolReturn(return_value=err) await client.rm(uri, recursive=recursive) return ToolReturn(return_value=f"Removed {uri}.") @@ -798,6 +1019,7 @@ async def viking_forget( viking_edit, viking_mkdir, viking_add_resource, + viking_upload_tree, ] if cap.enable_forget: write_tools.append(viking_forget) @@ -830,11 +1052,11 @@ async def viking_link( """ try: client = await cap._ensure_client() - if err := cap._check_uri_allowed(from_uri, tool_name="viking_link"): + if err := cap._check_write_uri_allowed(from_uri, tool_name="viking_link"): return ToolReturn(return_value=f"{err} (from_uri)") targets = to_uris if isinstance(to_uris, list) else [to_uris] for t in targets: - if err := cap._check_uri_allowed(t, tool_name="viking_link"): + if err := cap._check_write_uri_allowed(t, tool_name="viking_link"): return ToolReturn(return_value=f"{err} (to_uris)") await client.link(from_uri, to_uris, reason=reason) return ToolReturn( @@ -843,6 +1065,133 @@ async def viking_link( except Exception as e: return ToolReturn(return_value=f"viking_link error ({type(e).__name__}): {e}") + async def viking_link_relations( + ctx: RunContext[Any], + relations_file: str, + reason_prefix: str = "wiki", + namespace_base: str = "viking://resources/", + ) -> ToolReturn: + """Push local relation edges as bidirectional link() relations. + + Reads a local JSON relations file ({target_uri: [source_uri, ...]}, + the reversed edge map — e.g. the wiki's ``backlinks_index.json``) + and records both directions on the remote graph per edge: + ``link(target, sources, reason="{prefix}:referenced-by")`` then + ``link(source, [target], reason="{prefix}:references")`` for every + source. URIs outside ``namespace_base`` are skipped. Each link rep + retries on busy errors (the backend rejects link() against a tree + still held by the semantic handoff) and is otherwise best-effort. + + Server constraint: a node may be written as ``from_uri`` only once + (a second write fails with the backend's lock i/o bug), so links + are aggregated per ``from_uri`` — every node is written at most + twice (once per direction) and the reverse direction skips nodes + already written as targets to stay within that limit. + + Args: + relations_file: Local path to the JSON relations file. + reason_prefix: Prefix for the link reason labels (default + ``"wiki"``). + namespace_base: Only URIs under this base are linked (default + ``"viking://resources/"``). + + Returns: + Summary string with linked/failed/skipped counts. + """ + try: + index = _load_relations_file(relations_file) + base = namespace_base.rstrip("/") + "/" + client = await cap._ensure_client() + linked = 0 + failed = 0 + skipped = 0 + # Aggregate outgoing edges per from_uri so each node is + # written at most once per direction (see server constraint + # in the docstring). + outbound: dict[str, list[str]] = {} + inbound: dict[str, list[str]] = {} + for target, sources in index.items(): + if not isinstance(target, str) or not target.startswith(base): + skipped += 1 + continue + if cap._check_write_uri_allowed( + target, + tool_name="viking_link_relations", + ): + skipped += 1 + continue + # Resolve both directions to the server's actual node + # paths (markdown imports get wrapped: .md -> stem/stem.md). + resolved_target = await _resolve_uri(client, target) + if resolved_target is None: + skipped += 1 + continue + if cap._check_write_uri_allowed( + resolved_target, + tool_name="viking_link_relations", + ): + skipped += 1 + continue + resolved_sources = [] + for s in sources if isinstance(sources, list) else []: + if not isinstance(s, str) or not s.startswith(base): + continue + if cap._check_write_uri_allowed( + s, + tool_name="viking_link_relations", + ): + continue + resolved = await _resolve_uri(client, s) + if resolved is not None: + if cap._check_write_uri_allowed( + resolved, + tool_name="viking_link_relations", + ): + continue + resolved_sources.append(resolved) + if not resolved_sources: + skipped += 1 + continue + inbound[resolved_target] = list(set(resolved_sources)) + # Reverse direction: source references its targets. Skip a + # source that already appears as a target of another inbound + # edge — a second write to the same node would hit the + # backend lock bug. + written_targets = set(inbound) + for target, sources in inbound.items(): + for source_uri in sources: + if source_uri in written_targets: + continue + outbound.setdefault(source_uri, []).append(target) + for from_uri, to_uris in inbound.items(): + if await _link_best_effort( + client, + from_uri, + to_uris, + f"{reason_prefix}:referenced-by", + ): + linked += 1 + else: + failed += 1 + for from_uri, to_uris in outbound.items(): + if await _link_best_effort( + client, + from_uri, + to_uris, + f"{reason_prefix}:references", + ): + linked += 1 + else: + failed += 1 + return ToolReturn( + return_value=( + f"Pushed relations from {relations_file}: " + f"linked={linked}, failed={failed}, skipped={skipped}." + ) + ) + except Exception as e: + return ToolReturn(return_value=f"viking_link_relations error: {e}") + async def viking_set_tags( ctx: RunContext[Any], uri: str, @@ -861,7 +1210,7 @@ async def viking_set_tags( """ try: client = await cap._ensure_client() - if err := cap._check_uri_allowed(uri, tool_name="viking_set_tags"): + if err := cap._check_write_uri_allowed(uri, tool_name="viking_set_tags"): return ToolReturn(return_value=err) await client.set_tags(uri, tags, mode="replace", recursive=recursive) return ToolReturn(return_value=f"Set {len(tags)} tag(s) on {uri}.") @@ -871,6 +1220,7 @@ async def viking_set_tags( graph_tools: list[Callable[..., Awaitable[ToolReturn]]] = [viking_set_tags] if cap.enable_link: graph_tools.append(viking_link) + graph_tools.append(viking_link_relations) tools.extend(graph_tools) if cap.enabled_tools is not None: diff --git a/src/wolfharness/capabilities/viking/wiki_build.py b/src/wolfharness/capabilities/viking/wiki_build.py new file mode 100644 index 000000000..07f2bfe0b --- /dev/null +++ b/src/wolfharness/capabilities/viking/wiki_build.py @@ -0,0 +1,7 @@ +"""Backward-compat re-export; actual code moved to wolfharness.capabilities.wiki.build.""" + +from wolfharness.capabilities.wiki.build import * # noqa: F403 +from wolfharness.capabilities.wiki.build import ( # type: ignore[attr-defined] # noqa: F401 + WikiBuildCapability, + _build_tool_fns, +) diff --git a/src/wolfharness/capabilities/viking/wiki_build_tools.py b/src/wolfharness/capabilities/viking/wiki_build_tools.py new file mode 100644 index 000000000..3825c9165 --- /dev/null +++ b/src/wolfharness/capabilities/viking/wiki_build_tools.py @@ -0,0 +1,3 @@ +"""Backward-compat re-export; actual code moved to wolfharness.capabilities.wiki.tools.""" + +from wolfharness.capabilities.wiki.tools import * # noqa: F403 diff --git a/src/wolfharness/capabilities/viking/wiki_index.py b/src/wolfharness/capabilities/viking/wiki_index.py new file mode 100644 index 000000000..fbf837512 --- /dev/null +++ b/src/wolfharness/capabilities/viking/wiki_index.py @@ -0,0 +1,4 @@ +"""Backward-compat re-export; actual code moved to wolfharness.capabilities.wiki.tickets.index.""" + +from wolfharness.capabilities.wiki.tickets.index import * # noqa: F403 +from wolfharness.capabilities.wiki.tickets.index import _format_index_block # noqa: F401 diff --git a/src/wolfharness/capabilities/wiki/__init__.py b/src/wolfharness/capabilities/wiki/__init__.py new file mode 100644 index 000000000..3559d39fe --- /dev/null +++ b/src/wolfharness/capabilities/wiki/__init__.py @@ -0,0 +1,24 @@ +"""Self-contained wiki ticket engine for wolfharness. + +Provides OPA/OPS/OPL ticket CRUD, storage backends, and quality checks +without requiring xeno-adp-agentic at runtime. +""" + +from __future__ import annotations + +from wolfharness.capabilities.wiki.build import WikiBuildCapability, WikiBuildConfig +from wolfharness.capabilities.wiki.tools import ( + ALL_WIKI_TOOLS, + ROLE_TOOLS, + RoleFilter, + WIKI_AGENT_ROLES, +) + +__all__ = [ + "ALL_WIKI_TOOLS", + "ROLE_TOOLS", + "WIKI_AGENT_ROLES", + "RoleFilter", + "WikiBuildCapability", + "WikiBuildConfig", +] diff --git a/src/wolfharness/capabilities/wiki/_helpers.py b/src/wolfharness/capabilities/wiki/_helpers.py new file mode 100644 index 000000000..12bcb82c5 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/_helpers.py @@ -0,0 +1,397 @@ +"""Module-level helpers and constants extracted from mcp_server.py.""" + +from __future__ import annotations + +from hashlib import sha256 +import json +import logging +import os +import re +from typing import TYPE_CHECKING + +from wolfharness.capabilities.wiki.quality import ( + parse_frontmatter, +) +from wolfharness.capabilities.wiki.schema_loader import get_schema_version +from wolfharness.capabilities.wiki.validation import ( + ENTITY_VALIDATION_HOOKS, + FORMAL_WRITE_EXCLUDED_HOOKS, +) + + +if TYPE_CHECKING: + from collections.abc import Sequence + + +logger = logging.getLogger(__name__) + + +_FORMAL_WRITE_HOOKS = tuple( + hook for hook in ENTITY_VALIDATION_HOOKS if hook.name not in FORMAL_WRITE_EXCLUDED_HOOKS +) +_RELATION_CLOSURE_READY_STAGES = frozenset( + { + "materialized", + "relation_closure", + "audit_fix", + "auditing", + "audited", + "validate", + "publishing", + "finalized", + "done", + "recovered", + }, +) + + +def _entity_batch_limit() -> int: + """Return the configured bounded entity batch size.""" + try: + return max(1, min(100, int(os.environ.get("WIKI_ENTITY_BATCH_SIZE", "20")))) + except ValueError: + return 20 + + +def _materialization_task_byte_limit() -> int: + """Return the configurable upper bound for one embedded 1B task.""" + try: + return max( + 16_384, + min(524_288, int(os.environ.get("WIKI_MATERIALIZATION_TASK_BYTES", "98304"))), + ) + except ValueError: + return 98_304 + + +def _io_worker_limit() -> int: + """Return the bounded parallel I/O width for one build service.""" + try: + return max(1, min(64, int(os.environ.get("WIKI_IO_WORKERS", "16")))) + except ValueError: + return 16 + + +def _natural_path_key(path: str) -> tuple[tuple[int, int | str], ...]: + """Sort chapter paths by numeric prefixes instead of dictionary order.""" + key: list[tuple[int, int | str]] = [] + for part in re.split(r"(\d+)", path.casefold()): + if part.isdigit(): + key.append((0, int(part))) + else: + key.append((1, part)) + return tuple(key) + + +_CHAPTER_COMPONENT_RE = re.compile(r"^\d+(?:[._]\d+)*") +_BOM_ENRICH_PLACEHOLDER_MARKERS = ( + "工作机理由 bom_enrich worker", + "BOM 仅提供身份", + "由维修手册章节抽取阶段补充", + "工作机理待补充", +) +_BOM_PATH_PLACEHOLDER_RE = re.compile(r"(?:\.\.\.|…|\b(?:todo|tbd|unknown)\b)", re.IGNORECASE) + + +def _chapter_component_name(parts: Sequence[str], fallback: str) -> str: + """Return the nearest numeric chapter directory for artifact-backed leaves.""" + for part in reversed(parts): + if _CHAPTER_COMPONENT_RE.match(part): + return part + return fallback + + +def _chapter_idempotency_key(build_id: str, doc_id: str, uri: str) -> str: + """Stable 12-hex idempotency key shared by all chapter dispatch tools. + + ``preflight_build`` and ``browse_chapters`` must derive the same key + for the same (build_id, doc_id, uri) so retries/restarts reuse already + recorded source packets whichever tool produced them. Twelve hex chars + (48 bits) keeps keys short for task descriptions; the per-build birthday + collision probability at chapter scale (~4e-11 for 152 chapters) is + negligible and a collision only affects in-build dedup, never integrity. + """ + return sha256( + f"{build_id}\x1f{doc_id}\x1f{uri}\x1f{get_schema_version()}".encode(), + ).hexdigest()[:12] + + +def _with_publication_state(content: str, *, deprecated: bool = False) -> str: + """Stamp machine publication state without implying human approval.""" + frontmatter = parse_frontmatter(content) + conflict_pending = str(frontmatter.get("conflict_pending", "")).strip().lower() in { + "true", + "yes", + "1", + } + publication = "deprecated" if deprecated else "published" + updates = { + "publication_state": "blocked" if conflict_pending else publication, + "validation_state": "machine_validated", + "review_state": "unreviewed", + "status": "deprecated" if deprecated else ("draft" if conflict_pending else "confirmed"), + } + lines = content.splitlines(keepends=True) + if not lines or lines[0].strip() != "---": + body = content if content.endswith("\n") else content + "\n" + header = "---\n" + "".join(f"{key}: {value}\n" for key, value in updates.items()) + "---\n" + return header + body + end = next((index for index, line in enumerate(lines[1:], 1) if line.strip() == "---"), None) + if end is None: + return content + seen: set[str] = set() + for index in range(1, end): + line = lines[index] + if line[:1].isspace(): + continue + field = re.match(r"^([A-Za-z_]\w*)\s*:", line) + if field is None or field.group(1) not in updates: + continue + key = field.group(1) + lines[index] = f"{key}: {updates[key]}\n" + seen.add(key) + missing = [f"{key}: {value}\n" for key, value in updates.items() if key not in seen] + lines[end:end] = missing + return "".join(lines) + + +_CONFLICT_IGNORED_FRONTMATTER = frozenset( + { + "id", + "title", + "description", + "class_name", + "object_name", + "status", + "sources", + "applicable_models", + "aliases", + "publication_state", + "validation_state", + "review_state", + "conflict_pending", + "conflict_refs", + }, +) + +_PARAMETER_RE = re.compile( + r"(?-?\d+(?:\.\d+)?)\s*(?Pkpa|mpa|pa|bar|kv|mv|v|ka|ma|a|khz|hz|mm|cm|m|℃|°c|c)?(?![A-Za-z0-9_])", + re.IGNORECASE, +) +_NON_FACT_LABEL_RE = re.compile( + r"^(?:第|章节?|chapter|section|页|page|图|表|编号|序号|步骤|step|no)\s*$", + re.IGNORECASE, +) +_UNIT_SCALE: dict[str, tuple[str, float]] = { + "pa": ("pressure_pa", 1.0), + "kpa": ("pressure_pa", 1_000.0), + "mpa": ("pressure_pa", 1_000_000.0), + "bar": ("pressure_pa", 100_000.0), + "v": ("voltage_v", 1.0), + "mv": ("voltage_v", 0.001), + "kv": ("voltage_v", 1_000.0), + "a": ("current_a", 1.0), + "ma": ("current_a", 0.001), + "ka": ("current_a", 1_000.0), + "hz": ("frequency_hz", 1.0), + "khz": ("frequency_hz", 1_000.0), + "mm": ("length_mm", 1.0), + "cm": ("length_mm", 10.0), + "m": ("length_mm", 1_000.0), + "℃": ("temperature_c", 1.0), + "°c": ("temperature_c", 1.0), + "c": ("temperature_c", 1.0), +} + +_DIMENSION_CN: dict[str, str] = { + "voltage_v": "电压", + "pressure_pa": "压力", + "current_a": "电流", + "frequency_hz": "频率", + "length_mm": "长度", + "temperature_c": "温度", + "unitless": "数值", +} + + +def _humanize_fact_key(key: str) -> str: + """Render a machine fact key as a human-readable Chinese label.""" + if key.startswith("parameter:"): + _, label, dimension = key.split(":", 2) + return f"参数「{label}」的{_DIMENSION_CN.get(dimension, '数值')}" + if key.startswith("fm:"): + return f"frontmatter 字段「{key.split(':', 1)[1]}」" + if key.startswith("body-state:"): + return f"状态描述「{key.split(':', 1)[1]}」" + return key + + +def _parameter_fact_map(label: str, value: object) -> dict[str, set[str]]: + """Return normalized parameter values keyed by their semantic label.""" + text = str(value) + matches = list(_PARAMETER_RE.finditer(text)) + if not matches: + return {} + label_without_values = _PARAMETER_RE.sub(" ", label) + normalized_label = ( + re + .sub(r"\s+", " ", re.sub(r"[^\w\u4e00-\u9fff]+", " ", label_without_values)) + .strip() + .casefold() + ) + if not normalized_label or _NON_FACT_LABEL_RE.fullmatch(normalized_label): + return {} + facts: dict[str, set[str]] = {} + for match in matches: + number = float(match.group("value")) + unit = (match.group("unit") or "").casefold() + dimension, scale = _UNIT_SCALE.get(unit, ("unitless", 1.0)) + normalized_value = round(number * scale, 9) + facts.setdefault(f"parameter:{normalized_label}:{dimension}", set()).add( + str(normalized_value) + ) + return facts + + +def _conflict_fact_map(content: str) -> dict[str, set[str]]: + """Extract comparable facts without treating additive content as conflict.""" + frontmatter = parse_frontmatter(content) + facts: dict[str, set[str]] = {} + for key, value in frontmatter.items(): + if key in _CONFLICT_IGNORED_FRONTMATTER or value in (None, "", [], {}): + continue + normalized = _parameter_fact_map(key, value) + if normalized: + for fact_key, fact_values in normalized.items(): + facts.setdefault(fact_key, set()).update(fact_values) + elif isinstance(value, (str, int, float, bool)): + facts[f"fm:{key}"] = {json.dumps(value, ensure_ascii=False, sort_keys=True)} + + lines = content.splitlines() + in_frontmatter = bool(lines and lines[0].strip() == "---") + opposite_words = ( + ("正常", "异常"), + ("有", "无"), + ("通", "断"), + ("高", "低"), + ("开", "关"), + ("允许", "禁止"), + ("支持", "不支持"), + ("准确", "不准"), + ) + for line in lines[1:] if in_frontmatter else lines: + if in_frontmatter and line.strip() == "---": + in_frontmatter = False + continue + if in_frontmatter: + continue + stripped = line.strip() + if not stripped or stripped.startswith("#") or "viking://" in stripped: + continue + parameter_matches = list(_PARAMETER_RE.finditer(stripped)) + has_explicit_unit = any(match.group("unit") for match in parameter_matches) + if ( + parameter_matches + and not has_explicit_unit + and re.match(r"^\s*(?:\d+[.)、]|第\s*\d+\s*[章节])", stripped, re.IGNORECASE) + ): + # Chapter/step numbering is navigation metadata, not a domain + # fact. It must never become an OPA numeric conflict. + continue + normalized = _parameter_fact_map(stripped, stripped) + if normalized: + for fact_key, fact_values in normalized.items(): + facts.setdefault(fact_key, set()).update(fact_values) + else: + compact = re.sub(r"\s+", " ", stripped) + for first, second in opposite_words: + if (first in compact) == (second in compact): + continue + base = compact.replace(first, "").replace(second, "").strip() + facts.setdefault(f"body-state:{base}", set()).add( + first if first in compact else second + ) + return facts + + +def _conflicting_facts(current: str, candidate: str) -> set[str]: + """Return only facts whose existing and candidate values are incompatible.""" + current_facts = _conflict_fact_map(current) + candidate_facts = _conflict_fact_map(candidate) + conflicts: set[str] = set() + for key in current_facts.keys() & candidate_facts.keys(): + current_values = current_facts[key] + candidate_values = candidate_facts[key] + if current_values.isdisjoint(candidate_values): + humanized_key = _humanize_fact_key(key) + current_vals = ", ".join(sorted(current_values)) + candidate_vals = ", ".join(sorted(candidate_values)) + conflicts.add(f"{humanized_key}:已有内容为 {current_vals},新内容为 {candidate_vals}") + return conflicts + + +def _internal_conflicting_facts(content: str) -> set[str]: + """Find incompatible parameter values repeated inside one candidate. + + Body lines are grouped by ``##``/``###`` headings so the same parameter + in different subsections (for example distinct measurement points on one + page) is not reported as a conflict. Without section headings the whole + document stays a single comparison group, matching the previous + whole-page heuristic. + """ + facts: dict[str, set[str]] = {} + + def add_value(bucket: dict[str, set[str]], label: str, value: str) -> None: + if re.search(r"\d\s*(?:-|~|~|至|到)\s*-?\d", value): + return + for key, values in _parameter_fact_map(label, value).items(): + bucket.setdefault(key, set()).update(values) + + lines = content.splitlines() + in_frontmatter = bool(lines and lines[0].strip() == "---") + body_lines = lines[1:] if in_frontmatter else lines + has_sections = any(re.match(r"^#{2,3}\s", line.strip()) for line in body_lines) + if not has_sections: + frontmatter = parse_frontmatter(content) + for key, value in frontmatter.items(): + if isinstance(value, str): + add_value(facts, key, value) + buckets: list[dict[str, set[str]]] = [facts] + current: dict[str, set[str]] = facts + for line in body_lines: + if in_frontmatter and line.strip() == "---": + in_frontmatter = False + continue + if in_frontmatter: + continue + stripped = line.strip() + if re.match(r"^#{2,3}\s", stripped): + current = {} + buckets.append(current) + continue + if not stripped or stripped.startswith("#") or "://" in stripped: + continue + match = _PARAMETER_RE.search(stripped) + if match: + add_value(current, stripped[: match.start()], stripped) + + return { + f"{_humanize_fact_key(key)}在同一文档内不一致:{', '.join(sorted(values))}" + for bucket in buckets + for key, values in bucket.items() + if len(values) > 1 + } + + +if __name__ == "__main__": + # Self-check: conflict strings must be human-readable Chinese, not machine keys. + expected = "参数「传感器输出电压异常」的电压" + assert _humanize_fact_key("parameter:传感器输出电压异常:voltage_v") == expected + assert _humanize_fact_key("fm:title") == "frontmatter 字段「title」" + assert _humanize_fact_key("body-state:压力正常") == "状态描述「压力正常」" + assert _humanize_fact_key("unknown:key") == "unknown:key" + cross = _conflicting_facts("电压 0.3v\n", "电压 4.7v\n") + assert cross == {"参数「电压」的电压:已有内容为 0.3,新内容为 4.7"}, cross + internal = _internal_conflicting_facts("电压 0.3v\n电压 4.7v\n") + assert internal == {"参数「电压」的电压在同一文档内不一致:0.3, 4.7"}, internal + print("humanized conflict format self-check passed") diff --git a/src/wolfharness/capabilities/wiki/auto_repair.py b/src/wolfharness/capabilities/wiki/auto_repair.py new file mode 100644 index 000000000..d1d15f835 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/auto_repair.py @@ -0,0 +1,298 @@ +"""Deterministic post-processing repairs for wiki entities. + +These functions handle the mechanical fixes that don't require LLM reasoning: +- ``[open_gap]`` placeholder cleanup in frontmatter +- body-link materialization from frontmatter relation fields + +The LLM agents (conductor, relation_worker, file_operator) call +``batch_auto_repair`` once after extraction; only the remaining issues +that truly need reasoning (merge decisions, dangling-reference repair) +are left for agent iteration. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import logging +import re +from typing import TYPE_CHECKING + +from wolfharness.capabilities.wiki.quality import ( + _BODY_LINK_MAP, + _is_profile, + extract_sections, + extract_wiki_uris, + parse_frontmatter, +) + + +if TYPE_CHECKING: + from wolfharness.capabilities.wiki.wiki_build_tools import WikiBuildTools + +logger = logging.getLogger(__name__) + +# ── open_gap cleanup ────────────────────────────────────────────────────── + +_OPEN_GAP_INLINE_RE = re.compile(r":\s*\[open_gap\]\s*$", re.MULTILINE) +_OPEN_GAP_LIST_RE = re.compile( + r"^(\s+)-\s+open_gap\s*$", + re.MULTILINE, +) + + +def clean_open_gap(content: str) -> str: + """Replace ``[open_gap]`` placeholders with empty lists. + + Handles two YAML patterns: + - Inline: ``field: [open_gap]`` → ``field: []`` + - List item: `` - open_gap`` → removed (field becomes empty list) + + Does NOT touch ``open_gap`` appearing in body prose — that is + source-honest gap text that the agent should decide about. + """ + # Inline: field: [open_gap] + content = _OPEN_GAP_INLINE_RE.sub(": []", content) + # List item: " - open_gap" inside a YAML list + # Remove the line; if the parent field now has no items, it stays + # as a bare "field:" which YAML parses as None → treated as empty. + content = _OPEN_GAP_LIST_RE.sub("", content) + # Clean up any double blank lines left by list-item removal + return re.sub(r"\n{3,}", "\n\n", content) + + +# ── body-link materialization ───────────────────────────────────────────── + + +def _frontmatter_uri_list(frontmatter: dict[str, object], field: str) -> list[str]: + """Extract URI strings from a frontmatter list field.""" + value = frontmatter.get(field) + if isinstance(value, str): + return [value] if value.strip() else [] + if isinstance(value, list): + return [str(item).strip() for item in value if isinstance(item, str) and item.strip()] + return [] + + +def _resolve_uri_label(tools: WikiBuildTools, uri: str) -> str: + """Look up an entity's title/object_name for human-readable link text.""" + info = tools.store.lookup_by_uri(uri) + if info is None: + return uri + _concept, _class_name, object_name = info + content = tools.store.read_entity_by_uri(uri) + if content is not None: + fm = parse_frontmatter(content) + title = fm.get("title") + if isinstance(title, str) and title.strip(): + return title.strip() + return object_name + + +def materialize_body_links( + content: str, + concept: str, + tools: WikiBuildTools, + *, + known_uris: set[str] | None = None, +) -> str: + """Append frontmatter relation URIs to body sections if missing. + + For each relation field in the body-link map, reads the URIs from + frontmatter, checks which are already linked in the corresponding + body section, and appends the missing ones as Markdown links. + + Returns the updated content (or original if no changes needed). + """ + is_profile = _is_profile(content) + link_map_key = "_Profile" if is_profile else concept + field_map = _BODY_LINK_MAP.get(link_map_key, {}) + if not field_map: + return content + + frontmatter = parse_frontmatter(content) + sections = extract_sections(content) + changes: list[tuple[str, list[str]]] = [] # (heading, missing_uri_lines) + + for fm_field, (heading, _target_concept) in field_map.items(): + uris = _frontmatter_uri_list(frontmatter, fm_field) + if not uris: + continue + existing_section_uris = extract_wiki_uris(sections.get(heading, "")) + missing: list[str] = [] + for uri in uris: + canonical = uri.split("#", 1)[0] + if canonical not in existing_section_uris: + label = _resolve_uri_label(tools, canonical) + missing.append(f"- [{label}]({canonical})") + if missing: + changes.append((heading, missing)) + + if not changes: + return content + + # Apply changes by inserting into or creating sections + for heading, missing_lines in changes: + content = _append_to_section(content, heading, missing_lines) + + return content + + +def _append_to_section(content: str, heading: str, lines: list[str]) -> str: + """Append link lines to a ``##`` section, creating it if absent.""" + h2_re = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) + matches = list(h2_re.finditer(content)) + target_idx = next( + (i for i, m in enumerate(matches) if m.group(1).strip() == heading), + None, + ) + if target_idx is None: + # Create section at end of body + suffix = "\n" if content.endswith("\n") else "\n\n" + return f"{content}{suffix}## {heading}\n\n" + "\n".join(lines) + "\n" + match = matches[target_idx] + end = matches[target_idx + 1].start() if target_idx + 1 < len(matches) else len(content) + section = content[match.end() : end] + # Check each line isn't already present + truly_missing = [line for line in lines if line not in section] + if not truly_missing: + return content + insertion = section.rstrip() + "\n" + "\n".join(truly_missing) + "\n" + return content[: match.end()] + insertion + content[end:] + + +# ── batch repair ────────────────────────────────────────────────────────── + + +@dataclass(frozen=True, slots=True) +class RepairReport: + """Summary of deterministic repairs applied to the wiki corpus.""" + + entities_scanned: int = 0 + open_gap_cleaned: int = 0 + body_links_added: int = 0 + entities_modified: int = 0 + errors: list[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return { + "entities_scanned": self.entities_scanned, + "open_gap_cleaned": self.open_gap_cleaned, + "body_links_added": self.body_links_added, + "entities_modified": self.entities_modified, + "errors": self.errors, + } + + +def batch_auto_repair(tools: WikiBuildTools, entity_uris: list[str] | None = None) -> RepairReport: + """Run all deterministic repairs on every formal entity in the wiki. + + Iterates all entities from the natural-keys index (including Symptom + Profile sub-resources), applies ``clean_open_gap`` and + ``materialize_body_links`` in-place, and writes back only changed files. + + When *entity_uris* is provided, only entities whose URI is in that set + are scanned — used by the conductor to scope repair to the current build. + + Returns a :class:`RepairReport` summarising the work. + """ + scoped_set: set[str] | None = set(entity_uris) if entity_uris else None + errors: list[str] = [] + entities_scanned = 0 + open_gap_total = 0 + body_links_total = 0 + entities_modified = 0 + + for concept, class_name, object_name, uri in tools.store.list_entities(): + if scoped_set is not None and uri not in scoped_set: + continue + entities_scanned += 1 + if class_name is None: + continue + try: + content = tools.store.read_entity_by_uri(uri) + if content is None: + continue + + original = content + + content = clean_open_gap(content) + if content != original: + open_gap_total += 1 + + content_before_links = content + content = materialize_body_links(content, concept, tools) + if content != content_before_links: + body_links_total += 1 + + if content != original: + diff = tools.diff_entity(concept, class_name, object_name, content) + if bool(diff["changed"]): + current_sha256 = diff["current_sha256"] + if not isinstance(current_sha256, str): + raise TypeError("diff_entity returned a non-string current_sha256") + tools.merge_entity( + concept, + class_name, + object_name, + content, + expected_sha256=current_sha256, + skip_materialization=True, # ponytail: auto_repair only adds body links/cleans gaps; don't re-validate full page + ) + entities_modified += 1 + logger.info("Auto-repaired: %s (%s/%s)", uri, concept, object_name) + + # Process Symptom Profile sub-resources + if concept == "Symptom": + for profile in tools.list_symptom_profiles(uri): + profile_id = profile["profile_id"] + profile_uri = profile["uri"] + entities_scanned += 1 + try: + profile_content = tools.read_resource(profile_uri) + if profile_content is None: + continue + profile_original = profile_content + + profile_content = clean_open_gap(profile_content) + if profile_content != profile_original: + open_gap_total += 1 + + profile_before_links = profile_content + profile_content = materialize_body_links(profile_content, "Symptom", tools) + if profile_content != profile_before_links: + body_links_total += 1 + + if profile_content != profile_original: + diff = tools.diff_symptom_profile(uri, profile_id, profile_content) + if bool(diff["changed"]): + current_sha256 = diff["current_sha256"] + if not isinstance(current_sha256, str): + raise TypeError( + "diff_symptom_profile returned a non-string current_sha256", + ) + # ``write_symptom_profile`` is the guarded merge + # primitive for Profile resources: existing + # profiles require the hash returned by diff. + tools.write_symptom_profile( + uri, + profile_id, + profile_content, + expected_sha256=current_sha256, + ) + entities_modified += 1 + logger.info("Auto-repaired profile: %s", profile_uri) + except (OSError, TypeError, ValueError, KeyError) as exc: + errors.append(f"{profile_uri}: {exc!s}") + logger.warning("Auto-repair error for %s: %s", profile_uri, exc) + + except (OSError, TypeError, ValueError, KeyError) as exc: + errors.append(f"{uri}: {exc!s}") + logger.warning("Auto-repair error for %s: %s", uri, exc) + + return RepairReport( + entities_scanned=entities_scanned, + open_gap_cleaned=open_gap_total, + body_links_added=body_links_total, + entities_modified=entities_modified, + errors=errors, + ) diff --git a/src/wolfharness/capabilities/wiki/build.py b/src/wolfharness/capabilities/wiki/build.py new file mode 100644 index 000000000..b20d127b2 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/build.py @@ -0,0 +1,496 @@ +"""WikiBuildCapability — wiki knowledge construction tools for WolfHarness. + +Exposes the wiki construction toolkit (entity materialization, OPA +records, build lifecycle, external expert OP flow) as a pydantic-ai +capability so agents can drive manual → wiki entity builds in-process +instead of through an external MCP subprocess. + +Tool functions live in :mod:`wolfharness.capabilities.wiki.tools` +(following the same pattern as :mod:`wolfharness.capabilities.viking.tools`). +The capability class handles configuration, lazy ``WikiBuildTools`` +creation, role-based filtering, and first-turn index injection. +""" + +from __future__ import annotations + +import asyncio +import copy +import os +from pathlib import Path +import re +from typing import TYPE_CHECKING, Any, ClassVar + +import logfire +from pydantic import BaseModel +from pydantic_ai.capabilities import AbstractCapability +from pydantic_ai.toolsets import AgentToolset, FunctionToolset + +from wolfharness.capabilities.resource_protocols import ( + BlobResourceContent, + CompletionArgument, + CompletionResult, + ResourceAccess, + ResourceEntry, + ResourceTemplateAccess, + ResourceTemplateEntry, + TextResourceContent, +) + +# Re-export tool inventory and role types from wiki_build_tools so existing +# imports from ``wiki_build`` keep working. +from wolfharness.capabilities.wiki.tools import ( # noqa: F401 + ALL_WIKI_TOOLS, + ROLE_TOOLS, + WIKI_AGENT_ROLES, + RoleFilter, + _build_method_wrappers as _build_tool_fns, +) +from wolfharness.log import get_logger +from wolfharness.tools.base import FunctionTool + + +if TYPE_CHECKING: + from collections.abc import Sequence + + from pydantic_ai.models import ModelRequestContext + from pydantic_ai.tools import RunContext + +logger = get_logger(__name__) + + +def _is_viking_backend() -> bool: + return os.environ.get("WIKI_STORAGE_BACKEND", "viking") == "viking" + + +class WikiBuildConfig(BaseModel): + """Configuration for :class:`WikiBuildCapability`. + + All roots default to ``None`` and resolve from the corresponding + environment variables at tool-creation time. + """ + + model_config = {"arbitrary_types_allowed": True} + + wiki_root: str | None = None + library_root: str | None = None + case_root: str | None = None + faultannotated_root: str | None = None + bom_root: str | None = None + tool_names: tuple[str, ...] = () + include_helpers: bool = True + role: str | None = None + index_enabled: bool = False + index_max_tokens: int = 1000 + index_limit: int = 20 + build_log_dir: str | None = None + sync_after_apply: bool = False + """When True, push patched wiki page to remote Viking after + applying a knowledge proposal (OPL). Honoured by the ticket + ``apply_opl_ticket`` tool; intended for ``local_viking`` storage + mode.""" + + +class WikiBuildCapability( + AbstractCapability[Any], + ResourceAccess, + ResourceTemplateAccess, +): + """Capability exposing wiki construction tools to an agent. + + Config fields mirror :class:`WikiBuildConfig` as constructor kwargs + so the entry-point ``build()`` path (``cls(**args)``) can construct + the capability directly from YAML ``args``. + + Implements the ``ResourceAccess`` and ``ResourceTemplateAccess`` + protocols so OPA/OPS/OPL wiki tickets surface as MCP resources — + they show up in resource listings and support ``@``-completion in + external agents (``ExtensionRegistry.get_resource_access``, + ``opencode_server`` resource endpoint). + """ + + _FIRST_TURN_MAX_MESSAGES = 2 + + #: URI template per ticket kind — matches the ``OP/`` storage layout. + _OP_TICKET_TEMPLATES: ClassVar[dict[str, str]] = { + "OPA": "viking://resources/{namespace}/OP/OpA/{id}", + "OPS": "viking://resources/{namespace}/OP/OpS/{id}", + "OPL": "viking://resources/{namespace}/OP/OpL/{id}", + } + + _OP_TEMPLATE_RE = re.compile( + r"^viking://resources/\{namespace\}/OP/(OpA|OpS|OpL)/\{id\}$", + ) + + _LIST_RESOURCE_LIMIT = 50 + _COMPLETION_LIMIT = 200 + + def __init__( + self, + config: WikiBuildConfig | None = None, + *, + wiki_root: str | None = None, + library_root: str | None = None, + case_root: str | None = None, + faultannotated_root: str | None = None, + bom_root: str | None = None, + include_helpers: bool = True, + role: str | None = None, + index_enabled: bool = False, + index_max_tokens: int = 1000, + index_limit: int = 20, + build_log_dir: str | None = None, + sync_after_apply: bool = False, + tool_names: tuple[str, ...] = (), + ) -> None: + self._config = config or WikiBuildConfig( + wiki_root=wiki_root, + library_root=library_root, + case_root=case_root, + faultannotated_root=faultannotated_root, + bom_root=bom_root, + include_helpers=include_helpers, + role=role, + index_enabled=index_enabled, + index_max_tokens=index_max_tokens, + index_limit=index_limit, + build_log_dir=build_log_dir, + sync_after_apply=sync_after_apply, + tool_names=tool_names, + ) + self._tools: Any | None = None + self._build_logger: Any | None = None + self._tool_fns: list[Any] = [] + self._index_injected: bool = False + + @property + def config(self) -> WikiBuildConfig: + return self._config + + @property + def tools(self) -> Any | None: + return self._tools + + def _ensure_tools(self) -> None: + """Lazily create the ticket tools instance. + + Prefers the full ``WikiBuildTools`` from ``xeno_adp_agentic`` when + available (complete entity-write + apply path). Falls back to the + standalone ``TicketEngine`` in ``wolfharness.wiki`` so the ticket + capability works without the ``xeno_adp_agentic`` dependency. + """ + if self._tools is not None: + return + + wiki_root = self._config.wiki_root or os.environ.get("WIKI_ROOT") or "output/wiki_newbuild" + + try: + from wolfharness.capabilities.wiki.build_logger import WikiBuildLogger + + from .wiki_build_tools import WikiBuildTools + + library_root = ( + self._config.library_root or os.environ.get("LIBRARY_ROOT") or "output/library" + ) + fault_root = self._config.faultannotated_root or os.environ.get("FAULTANNOTATED_ROOT") + log_dir = self._config.build_log_dir or os.environ.get("WIKI_BUILD_LOG_DIR") + if not log_dir: + log_dir = "logs" if "://" in str(wiki_root) else str(Path(wiki_root) / "logs") + self._build_logger = WikiBuildLogger(log_dir) + self._tools = WikiBuildTools( + wiki_root, + library_root, + case_root=self._config.case_root or os.environ.get("CASE_ROOT"), + faultannotated_root=fault_root, + bom_root=self._config.bom_root or os.environ.get("WIKI_BOM_ROOT"), + build_logger=self._build_logger, + ) + except ImportError: + from wolfharness.capabilities.wiki.storage import create_wiki_store + from wolfharness.capabilities.wiki.tickets.ticket_engine import TicketEngine + + store = create_wiki_store(wiki_root) + self._tools = TicketEngine(store) + + def get_instructions(self) -> str | None: + from wolfharness.capabilities.wiki.tools import get_instructions + + return get_instructions(self._config.role) + + # ---- ResourceAccess ---- + + async def list_resources(self) -> Sequence[ResourceEntry]: + """List OPA/OPS/OPL wiki tickets as MCP resources. + + Returns: + Sequence of ``ResourceEntry`` descriptors — at most 50 per + ticket kind. + """ + self._ensure_tools() + assert self._tools is not None + opas = await asyncio.to_thread(self._tools.get_opas, limit=self._LIST_RESOURCE_LIMIT) + ops = await asyncio.to_thread(self._tools.get_ops, limit=self._LIST_RESOURCE_LIMIT) + opls = await asyncio.to_thread(self._tools.get_opls, limit=self._LIST_RESOURCE_LIMIT) + return [ + *[self._ticket_entry("OPA", record) for record in opas], + *[self._ticket_entry("OPS", record) for record in ops], + *[self._ticket_entry("OPL", record) for record in opls], + ] + + async def read_resource( + self, uri: str + ) -> list[TextResourceContent | BlobResourceContent] | None: + """Read a wiki ticket by its ``viking://`` URI. + + Delegates to ``WikiBuildTools.read_resource``, which resolves the + ``OP/`` subtree under the active wiki store. + + Args: + uri: Resource URI to read (e.g. ``viking://resources//OP/OpA/.md``). + + Returns: + List containing the ticket markdown as ``TextResourceContent``, + or ``None`` when the URI is not an existing ticket. + """ + self._ensure_tools() + assert self._tools is not None + content = await asyncio.to_thread(self._tools.read_resource, uri) + if content is None: + return None + return [TextResourceContent(uri=uri, mime_type="text/markdown", text=content)] + + async def resource_exists(self, uri: str) -> bool: + """Check whether a wiki ticket resource exists. + + Args: + uri: Resource URI to check. + + Returns: + ``True`` when the ticket is readable, ``False`` otherwise. + """ + contents = await self.read_resource(uri) + return contents is not None + + # ---- ResourceTemplateAccess ---- + + async def list_resource_templates(self) -> Sequence[ResourceTemplateEntry]: + """Declare the OPA/OPS/OPL ticket URI templates. + + Returns: + Sequence of ``ResourceTemplateEntry`` descriptors, one per + ticket kind. + """ + return [ + ResourceTemplateEntry( + uri_template=template, + name="Wiki tickets", + title=f"{kind} ticket by id", + description=( + f"{kind} wiki construction ticket under a namespace's " + f"OP/{subdir}/ tree ({kind_label})." + ), + mime_type="text/markdown", + ) + for template, (kind, subdir, kind_label) in ( + (self._OP_TICKET_TEMPLATES["OPA"], ("OPA", "OpA", "working problem analysis")), + (self._OP_TICKET_TEMPLATES["OPS"], ("OPS", "OpS", "solution suggestion")), + (self._OP_TICKET_TEMPLATES["OPL"], ("OPL", "OpL", "knowledge proposal link")), + ) + ] + + async def complete_resource_template( + self, + uri_template: str, + argument: CompletionArgument, + context: dict[str, str] | None = None, + ) -> CompletionResult: + """Suggest ticket ids for a resource template's ``{id}`` argument. + + Args: + uri_template: The URI template to complete. + argument: The argument being completed — must be ``id``. + context: Optional context arguments (may carry ``namespace``). + + Returns: + ``CompletionResult`` with matching `` `` suggestions. + + Raises: + NotImplementedError: If the template is unsupported or the + argument is not ``id``. + """ + kind = self._template_kind(uri_template) + if kind is None or argument.name != "id": + raise NotImplementedError( + f"Completion not supported for template {uri_template!r}", + ) + self._ensure_tools() + assert self._tools is not None + namespace = (context or {}).get("namespace", "") + if namespace and not namespace.startswith("viking://"): + namespace = f"viking://resources/{namespace}/" + if kind == "OPA": + rows = await asyncio.to_thread( + self._tools.get_opas, + status="pending", + limit=self._COMPLETION_LIMIT, + ) + id_key = "opa_id" + elif kind == "OPS": + rows = await asyncio.to_thread(self._tools.get_ops, limit=self._COMPLETION_LIMIT) + id_key = "ops_id" + else: + rows = await asyncio.to_thread(self._tools.get_opls, limit=self._COMPLETION_LIMIT) + id_key = "opl_id" + needle = argument.value.strip().lower() + values: list[str] = [] + for record in rows: + record_uri = str(record.get("uri", "")) + if namespace and not record_uri.startswith(namespace): + continue + record_id = str(record.get(id_key, "")) + title = str(record.get("title", "")) + if needle and needle not in record_id.lower() and needle not in title.lower(): + continue + values.append(f"{record_id} {title}".strip()) + return CompletionResult(values=values, total=len(values), has_more=False) + + # ---- Helpers ---- + + @classmethod + def _template_kind(cls, uri_template: str) -> str | None: + """Map a supported ticket template to its kind (OPA/OPS/OPL). + + Args: + uri_template: The URI template to classify. + + Returns: + ``"OPA"`` / ``"OPS"`` / ``"OPL"``, or ``None`` when unsupported. + """ + normalized = uri_template.replace("${", "{") + match = cls._OP_TEMPLATE_RE.match(normalized) + if match is None: + return None + return {"OpA": "OPA", "OpS": "OPS", "OpL": "OPL"}[match.group(1)] + + @staticmethod + def _ticket_entry(kind: str, record: dict[str, object]) -> ResourceEntry: + """Map one OPA/OPS/OPL record dict to a resource descriptor. + + Args: + kind: Ticket kind — ``"OPA"``, ``"OPS"`` or ``"OPL"``. + record: Record dict as returned by the ``get_*`` tools. + + Returns: + The corresponding ``ResourceEntry``. + """ + record_id = str(record.get(f"{kind.lower()}_id", "")) + title = str(record.get("title", "")) + name = f"{kind} {record_id}: {title}" if title else f"{kind} {record_id}" + detail = f"status={record.get('status', '')}" + category = str(record.get("category", "")) + if category: + detail = f"{detail}, category={category}" + return ResourceEntry( + uri=str(record.get("uri", "")), + name=name, + description=detail, + mime_type="text/markdown", + ) + + def get_toolset(self) -> AgentToolset[Any] | None: + from wolfharness.capabilities.wiki.tools import ( + build_tools, + ) + + tool_fns = build_tools(self) + if not tool_fns: + return None + toolset: FunctionToolset[Any] = FunctionToolset(tool_fns, id="wiki_build") + if self._config.role is not None: + return RoleFilter(self._config.role).get_wrapper_toolset(toolset) + return toolset + + async def get_tools(self) -> Sequence[FunctionTool]: + self._ensure_tools() + return [FunctionTool.from_callable(fn) for fn in self._tool_fns] + + def _resolve_index_roots(self) -> list[tuple[str, str]]: + candidates: list[tuple[str, str | None]] = [ + ("wiki", self._config.wiki_root or os.environ.get("WIKI_ROOT")), + ("raw", self._config.library_root or os.environ.get("LIBRARY_ROOT")), + ("bom", self._config.bom_root or os.environ.get("WIKI_BOM_ROOT")), + ] + resolved: list[tuple[str, str]] = [] + for label, uri in candidates: + if uri is None: + continue + if uri.startswith("viking://"): + resolved.append((label, uri)) + elif _is_viking_backend() and label == "wiki" and os.environ.get("VIKING_NAMESPACE"): + resolved.append( + (label, f"viking://resources/{os.environ['VIKING_NAMESPACE']}"), + ) + elif _is_viking_backend() and label == "raw" and os.environ.get("VIKING_RAW_NAMESPACE"): + resolved.append( + (label, f"viking://resources/{os.environ['VIKING_RAW_NAMESPACE']}"), + ) + return resolved + + async def before_model_request( + self, + ctx: RunContext[Any], + request_context: ModelRequestContext, + ) -> ModelRequestContext: + if not self._config.index_enabled or self._index_injected: + return request_context + + if len(request_context.messages) > self._FIRST_TURN_MAX_MESSAGES: + self._index_injected = True + return request_context + + self._index_injected = True + + with logfire.span("wiki_build.before_model_request"): + try: + from wolfharness.capabilities.wiki.tickets.index import ( + _format_index_block, + ) + + index_block = _format_index_block( + self._resolve_index_roots(), + max_tokens=self._config.index_max_tokens, + limit=self._config.index_limit, + ) + if not index_block.strip(): + return request_context + + from dataclasses import replace + + from pydantic_ai.messages import ( + ModelRequest, + UserPromptPart, + ) + + messages = list(request_context.messages) + insert_idx: int | None = None + for i in range(len(messages) - 1, -1, -1): + msg = messages[i] + if isinstance(msg, ModelRequest) and any( + isinstance(p, UserPromptPart) for p in msg.parts + ): + insert_idx = i + break + + if insert_idx is None: + return request_context + + system_msg = ModelRequest(parts=[UserPromptPart(content=index_block)]) + new_messages = [*messages[:insert_idx], system_msg, *messages[insert_idx:]] + return replace(request_context, messages=new_messages) + except Exception: + logger.warning("Wiki index injection failed", exc_info=True) + return request_context + + async def for_run(self, ctx: RunContext[Any]) -> WikiBuildCapability: + run_copy = copy.copy(self) + run_copy._index_injected = False + return run_copy diff --git a/src/wolfharness/capabilities/wiki/build_logger.py b/src/wolfharness/capabilities/wiki/build_logger.py new file mode 100755 index 000000000..f30001c56 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/build_logger.py @@ -0,0 +1,248 @@ +"""Structured build event logger — writes JSONL to ``logs/``. + +Tracks every meaningful event during a wiki build: + +- ``entity_created`` — new entity file (did not exist before) +- ``entity_updated`` — existing entity overwritten (merged or new content) +- ``folder_created`` — a new concept/class directory was created +- ``entity_merged`` — multiple raw entities consolidated into one +- ``opa_generated`` — OPA record created (schema violation, conflict, quality issue) +- ``ops_stored`` — OPS solution record persisted + +Usage:: + + logger = WikiBuildLogger() + WikiBuildTools(..., build_logger=logger) + +All events are written as newline-delimited JSON (``.jsonl``) to +``logs/wiki_build_{timestamp}.jsonl``. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +import json +import logging +from pathlib import Path +from typing import Self + + +logger = logging.getLogger(__name__) + +# Re-export for convenience. +UTC = UTC + + +class WikiBuildLogger: + """Structured, file-backed build event logger. + + Each call appends one JSON line to a timestamped ``.jsonl`` file + under *log_dir*. Call ``close()`` when the build finishes, or use + as a context manager. + """ + + def __init__(self, log_dir: str | Path = "logs") -> None: + self.log_dir = Path(log_dir).resolve() + self.log_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + self._path = self.log_dir / f"wiki_build_{timestamp}.jsonl" + self._file = open(self._path, "a", encoding="utf-8") # noqa: SIM115 + logger.info("Build log → %s", self._path) + + # ── lifecycle ───────────────────────────────────────────────────────── + + @property + def path(self) -> Path: + """The active log file path.""" + return self._path + + def close(self) -> None: + if self._file and not self._file.closed: + self._file.close() + + def __enter__(self) -> Self: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + # ── core ────────────────────────────────────────────────────────────── + + def _log(self, event: str, **data: object) -> None: + record: dict[str, object] = { + "event": event, + "timestamp": datetime.now(UTC).isoformat(), + } + record.update(data) + line = json.dumps(record, ensure_ascii=False) + self._file.write(line + "\n") + self._file.flush() + + # ── events ──────────────────────────────────────────────────────────── + + def entity_created( + self, + uri: str, + concept: str, + object_name: str, + char_count: int, + *, + class_name: str | None = None, + folder_created: bool = False, + ) -> None: + """A brand-new entity was written to disk (file did not exist).""" + self._log( + "entity_created", + uri=uri, + concept=concept, + class_name=class_name or "", + object_name=object_name, + char_count=char_count, + folder_created=folder_created, + ) + + def entity_updated( + self, + uri: str, + concept: str, + object_name: str, + char_count: int, + char_count_before: int, + *, + class_name: str | None = None, + reason: str = "", + ) -> None: + """An existing entity was overwritten (merge or new content).""" + self._log( + "entity_updated", + uri=uri, + concept=concept, + class_name=class_name or "", + object_name=object_name, + char_count=char_count, + char_count_before=char_count_before, + reason=reason, + ) + + def folder_created(self, path: str) -> None: + """A new concept or class directory was created.""" + self._log("folder_created", path=path) + + def entity_moved( + self, + src_path: str, + dst_path: str, + new_uri: str, + ) -> None: + """An entity file was moved to a new location.""" + self._log( + "entity_moved", + src_path=src_path, + dst_path=dst_path, + new_uri=new_uri, + ) + + def entity_merged( + self, + target_uri: str, + source_count: int, + concept: str, + object_name: str, + char_count_before: int, + char_count_after: int, + source_count_total: int, + *, + class_name: str | None = None, + ) -> None: + """Multiple raw entities were consolidated into one.""" + self._log( + "entity_merged", + target_uri=target_uri, + source_count=source_count, + concept=concept, + class_name=class_name or "", + object_name=object_name, + char_count_before=char_count_before, + char_count_after=char_count_after, + source_count_total=source_count_total, + ) + + def opa_generated( + self, + opa_id: str, + title: str, + category: str, + reason: str, + *, + target_uri: str = "", + scope: str = "", + ) -> None: + """An OPA (Open Problem Annotation) was created. + + Reasons include: schema validation violation, entity content + conflict across sources, cross-concept numerical inconsistency, + ADP parsing quality issue, or LLM-detected contradiction. + """ + self._log( + "opa_generated", + opa_id=opa_id, + title=title, + category=category, + reason=reason, + target_uri=target_uri, + scope=scope, + ) + + def ops_stored( + self, + ops_id: str, + opa_id: str, + solution_summary: str, + *, + target_uri: str = "", + ) -> None: + """An OPS (Open Problem Solution) was persisted.""" + self._log( + "ops_stored", + ops_id=ops_id, + opa_id=opa_id, + target_uri=target_uri, + solution_summary=solution_summary, + ) + + def mcp_call( + self, + tool_name: str, + args: dict, + result_summary: str, + duration_ms: float, + *, + error: str = "", + ) -> None: + """An MCP tool was called by the organizer agent.""" + self._log( + "mcp_call", + tool_name=tool_name, + args_summary=str(args)[:500], + result_summary=result_summary[:500], + duration_ms=round(duration_ms, 1), + error=error, + ) + + def phase_timing(self, phase: str, elapsed_ms: float) -> None: + """Record one evaluated build phase duration.""" + self._log("phase_timing", phase=phase, elapsed_ms=round(elapsed_ms, 1)) + + def source_packet_recorded(self, packet_id: str, doc_id: str, source_count: int) -> None: + """A source packet was persisted for a document.""" + self._log( + "source_packet_recorded", packet_id=packet_id, doc_id=doc_id, source_count=source_count + ) + + def mutation_attempt(self, uri: str, op: str) -> None: + """An entity write was attempted.""" + self._log("mutation_attempt", uri=uri, op=op) + + def mutation_applied(self, uri: str, op: str) -> None: + """An entity write completed.""" + self._log("mutation_applied", uri=uri, op=op) diff --git a/src/wolfharness/capabilities/wiki/entities/__init__.py b/src/wolfharness/capabilities/wiki/entities/__init__.py new file mode 100644 index 000000000..c9707b045 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/entities/__init__.py @@ -0,0 +1 @@ +"""wiki capability subpackage: entities.""" diff --git a/src/wolfharness/capabilities/wiki/entities/entities.py b/src/wolfharness/capabilities/wiki/entities/entities.py new file mode 100644 index 000000000..fd13bf1c7 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/entities/entities.py @@ -0,0 +1,736 @@ +"""Entity read/write and symptom profile operations.""" + +from __future__ import annotations + +from difflib import unified_diff +from hashlib import sha256 +import json +import logging +import re +import time + +from wolfharness.capabilities.wiki.auto_repair import materialize_body_links +from wolfharness.capabilities.wiki.io.text_parsers import ( + _ENTITY_URI_HEADING_RE, +) +from wolfharness.capabilities.wiki.quality import ( + entity_status, + extract_sections, + extract_source_uris, + parse_frontmatter, +) +from wolfharness.capabilities.wiki.validation import ( + require_valid_entity, +) + + +logger = logging.getLogger(__name__) + +from wolfharness.capabilities.wiki._helpers import ( + _FORMAL_WRITE_HOOKS, + _conflicting_facts, + _entity_batch_limit, + _internal_conflicting_facts, +) + + +class EntityWriteMixin: + """Entity read/write and symptom profile operations.""" + + def list_entities(self, concept: str, class_name: str = "") -> list[dict]: + """List entities — three-level progressive browsing. + + Usage (matches external MCP docstring):: + + list_entities() → all concepts with entity counts + list_entities("Component") → classes under Component + list_entities("Component", "主泵") → entities under Component/主泵 + + Level 0 (empty *concept*) returns ``[{concept, total}, ...]``. + Level 1/2 returns ``[{object_name, title, class_name, concept, uri}, ...]``. + """ + if not concept: + # Level 0: list all concepts with entity counts + counts: dict[str, int] = {} + for c, _cls, _obj, _uri in self.store.list_entities(None): + counts[c] = counts.get(c, 0) + 1 + return [ + {"concept": c, "total": n} for c, n in sorted(counts.items(), key=lambda x: -x[1]) + ] + + out: list[dict[str, str]] = [] + for c, cls, obj, uri in self.store.list_entities(concept): + logical_class = self.store.logical_class_name(c, cls) or "" + if class_name and logical_class != class_name: + continue + out.append( + { + "object_name": obj, + "title": obj, + "class_name": logical_class, + "concept": c, + "uri": uri, + }, + ) + return out + + def read_entity(self, concept: str, class_name: str, object_name: str) -> str: + """Read the full content of an existing entity; raises if absent.""" + content = self.store.read_entity(concept, class_name or None, object_name) + if content is None: + raise FileNotFoundError( + f"Entity not found: {self.store.entity_uri(concept, class_name or None, object_name)}", + ) + return content + + def _mark_merge_conflict( + self, + concept: str, + uri: str, + current: str, + candidate: str, + ) -> str: + """Record a fact-changing merge and keep the candidate unpublished.""" + if concept not in {"Fault", "Symptom", "Component"}: + return candidate + changed_facts = sorted( + _conflicting_facts(current, candidate) | _internal_conflicting_facts(candidate) + ) + if not changed_facts: + return candidate + evidence = list( + dict.fromkeys( + [ + *extract_source_uris(current), + *extract_source_uris(candidate), + uri, + ], + ), + ) + entity_name = uri.rstrip("/").rsplit("/", 1)[-1].removesuffix(".md") or concept + slug = re.sub(r"[^A-Za-z0-9_-]+", "-", entity_name).strip("-") or "entity" + conflict_key = uri + chr(31).join(changed_facts) + conflict_digest = sha256(conflict_key.encode()).hexdigest()[:12] + opa_id = f"opa-merge-{slug}-{conflict_digest}" + record = self.create_opa( + opa_id=opa_id, + title=f"{entity_name} — {concept} 增量合并事实冲突", + description=f"同一实体的重复构建输入产生了不同事实:{uri}", + category="conflict", + reason_code="fact_conflict", + target_uri=uri, + target_section="增量合并事实", + evidence_uris=evidence, + finding="以下内容在重复构建中出现了不一致:\n" + "\n".join(changed_facts[:20]), + missing="在人工裁决前,无法确认哪一组事实适用于当前机型或配置。", + recommendation="保留双方证据,按机型和来源裁决;不得静默覆盖旧事实。", + ) + marked = self._set_frontmatter_value( + self._set_frontmatter_value(candidate, "conflict_pending", "true"), + "conflict_refs", + json.dumps([record["uri"]], ensure_ascii=False), + ) + marker = f"- 待裁决冲突详情:{record['uri']}" + if marker not in marked: + suffix = "\n" if marked.endswith("\n") else "\n\n" + marked = f"{marked}{suffix}## 冲突说明\n\n{marker}\n" + return marked + + @staticmethod + def _set_frontmatter_value(content: str, key: str, value: str) -> str: + """Set one scalar/list-compatible frontmatter field deterministically.""" + lines = content.splitlines(keepends=True) + if not lines or lines[0].strip() != "---": + return content + end = next( + (index for index, line in enumerate(lines[1:], 1) if line.strip() == "---"), None + ) + if end is None: + return content + rendered = f"{key}: {value}\n" + for index in range(1, end): + if re.match(rf"^{re.escape(key)}\s*:", lines[index]): + lines[index] = rendered + return "".join(lines) + lines.insert(end, rendered) + return "".join(lines) + + def _assert_expert_authority_preserved( + self, + *, + target_uri: str, + current: str, + candidate: str, + reference_replacements: list[tuple[str, str]] | None = None, + ) -> None: + """Verify that expert-confirmed sections are not removed by a candidate write.""" + try: + authorities = self.get_expert_authority(target_uri=target_uri) + except (OSError, ValueError, KeyError): + return + if not authorities: + return + current_sections = {name for name, _ in extract_sections(current)} + for authority in authorities: + section = authority.get("target_section", "") + if section and section in current_sections: + candidate_sections = {name for name, _ in extract_sections(candidate)} + if section not in candidate_sections: + raise ValueError( + f"Expert-confirmed section '{section}' would be removed from {target_uri}", + ) + + def write_entity( + self, + concept: str, + class_name: str, + object_name: str, + content: str, + *, + expected_sha256: str = "", + conflict_policy: str = "detect", + skip_materialization: bool = False, + reference_replacements: list[tuple[str, str]] | None = None, + ) -> str: + """Write an entity file with body-reference resolution. + + A missing entity may be created directly. An existing entity must be + written through the diff/merge protocol and therefore requires the + SHA-256 observed by the caller. This prevents an extraction or + relation worker from silently overwriting a newer page revision. + + Returns the canonical ``{root_uri}/...`` URI (hash-based for non-ASCII + object names). + """ + if conflict_policy not in {"detect", "external_authority"}: + raise ValueError(f"Unsupported conflict_policy: {conflict_policy!r}") + self._invalidate_audit_cache() + clz = class_name or None + uri = self.store.entity_uri(concept, clz, object_name) + current = self.store.read_entity(concept, clz, object_name) + if current is not None: + current_sha256 = sha256(current.encode("utf-8")).hexdigest() + if not expected_sha256: + raise ValueError( + "Existing entity writes require diff_entity followed by merge_entity(expected_sha256=...)", + ) + if expected_sha256 != current_sha256: + raise ValueError( + f"Entity changed since diff; rerun diff_entity before merge (expected={expected_sha256}, actual={current_sha256}).", + ) + # Keep the entity's own identity before dropping unresolved references. + # Extraction may emit a stale path-shaped URI in this heading. + content = _ENTITY_URI_HEADING_RE.sub(f"# {uri}\n", content, count=1) + # Repair YAML frontmatter (concatenation, [简述] prefix, dedup, hash URIs). + content = self.store.repair_frontmatter( + content, + None, + ) + # Resolve human-readable viking:// URIs in body content to hash-based URIs. + content = self.store.resolve_body_refs( + content, + None, + ) + materialization_started = time.perf_counter() + # Materialize relation-field URIs in their canonical body sections at + # write time. This makes new incremental pages self-contained and + # prevents a later audit from treating an already-known relation as a + # dangling body-link gap. Unknown targets are left untouched by the + # repair helper and remain source-honest open gaps. + content = materialize_body_links(content, concept, self) + self._reject_wrong_raw_refs(content) + self._reject_malformed_wiki_refs(content) + self._reject_nonexistent_raw_sources(content) + # Deduplicate inline [viking://uri] citations within each section. + content = self.store.dedup_citations(content) + content = self._dedupe_h2_sections(content) + if current is not None and conflict_policy == "detect": + self._assert_expert_authority_preserved( + target_uri=uri, + current=current, + candidate=content, + reference_replacements=reference_replacements, + ) + self._validate_formal_write( + content=content, + concept=concept, + class_name=class_name, + object_name=object_name, + skip_materialization=skip_materialization, + ) + self._record_phase_timing("materialization", materialization_started) + existing_content = self.store.read_entity(concept, clz, object_name) + if existing_content is not None and conflict_policy == "detect": + content = self._mark_merge_conflict(concept, uri, existing_content, content) + elif existing_content is not None and conflict_policy == "external_authority": + content = self._set_frontmatter_value(content, "conflict_pending", "false") + content = self._set_frontmatter_value(content, "conflict_refs", "[]") + is_new = existing_content is None + char_before = len(existing_content) if existing_content is not None else 0 + parent_existed = self.store.is_dir( + self.store._key_of(self.store.entity_path(concept, clz, object_name).parent), + ) + if self._log: + self._log.mutation_attempt(uri, "write_entity") + # Recheck under the same process-wide store lock used by expert + # authority claims. The initial optimistic check may have happened + # before an expert OPL was reserved/applied by another worker. + with self._relation_sync_lock: + latest = self.store.read_entity(concept, clz, object_name) + if current is None and latest is not None: + raise ValueError( + "Entity was created concurrently; rerun diff_entity before writing" + ) + if current is not None: + latest_sha256 = sha256((latest or "").encode("utf-8")).hexdigest() + if latest_sha256 != expected_sha256: + raise ValueError( + f"Entity changed before commit; rerun diff_entity before merge (expected={expected_sha256}, actual={latest_sha256}).", + ) + if conflict_policy == "detect": + self._assert_expert_authority_preserved( + target_uri=uri, + current=latest or "", + candidate=content, + reference_replacements=reference_replacements, + ) + self.store.write_entity(concept, clz, object_name, content) + if self._log: + self._log.mutation_applied(uri, "write_entity") + # Register natural-key with class_name for collision-free lookups. + self.store.register_natural_key(concept, clz, object_name, uri) + if self._log: + if is_new: + self._log.entity_created( + uri, + concept, + object_name, + len(content), + class_name=clz, + folder_created=not parent_existed, + ) + else: + self._log.entity_updated( + uri, + concept, + object_name, + len(content), + char_before, + class_name=clz, + reason="merge", + ) + logger.info("Entity written: %s (%d chars)", uri, len(content)) + relation_started = time.perf_counter() + if concept == "Component": + self._sync_component_narrative_links(uri) + elif concept in {"Fault", "Procedure"}: + relation_field = "affected_components" if concept == "Fault" else "target_components" + title = parse_frontmatter(content).get("title") + label = title.strip() if isinstance(title, str) and title.strip() else object_name + for component_uri in self._frontmatter_uri_values( + self.store.root_uri, parse_frontmatter(content), relation_field + ): + canonical_component_uri = component_uri.split("#", 1)[0] + if concept == "Fault": + self._sync_component_narrative_links( + canonical_component_uri, + fault_links=[(label, uri)], + ) + else: + self._sync_component_narrative_links( + canonical_component_uri, + procedure_links=[(label, uri)], + ) + self._record_phase_timing("relation", relation_started) + return uri + + def write_entities_batch(self, entities: list[dict[str, object]]) -> dict[str, object]: + """Validate and materialize a bounded set of independent entities. + + The batch removes one model round trip per entity. Each item keeps the + same optimistic-lock contract as ``write_entity`` through an optional + ``expected_sha256`` field. Component narrative mirrors are deferred + until every entity body is visible, then synchronized once per target + component. + """ + if not entities: + raise ValueError("entities must not be empty") + batch_limit = _entity_batch_limit() + if len(entities) > batch_limit: + raise ValueError(f"write_entities_batch accepts at most {batch_limit} entities") + + materialization_started = time.perf_counter() + prepared: list[tuple[str, str, str, str, str, int | None]] = [] + identities: set[tuple[str, str, str]] = set() + for index, item in enumerate(entities): + concept = item.get("concept") + class_name = item.get("class_name", "") + object_name = item.get("object_name") + content = item.get("content") + expected_sha256 = item.get("expected_sha256", "") + if not all( + isinstance(value, str) + for value in (concept, class_name, object_name, content, expected_sha256) + ): + raise TypeError(f"entities[{index}] fields must be strings") + assert isinstance(concept, str) + assert isinstance(class_name, str) + assert isinstance(object_name, str) + assert isinstance(content, str) + assert isinstance(expected_sha256, str) + identity = (concept, class_name, object_name) + if identity in identities: + raise ValueError(f"duplicate entity identity in batch: {identity}") + identities.add(identity) + + current = self.store.read_entity(concept, class_name or None, object_name) + if current is not None: + current_hash = sha256(current.encode("utf-8")).hexdigest() + if not expected_sha256: + raise ValueError( + f"Existing batch entity requires expected_sha256: {identity}", + ) + if expected_sha256 != current_hash: + raise ValueError(f"Batch entity changed since diff: {identity}") + normalized = _ENTITY_URI_HEADING_RE.sub( + f"# {self.store.entity_uri(concept, class_name or None, object_name)}\n", + content, + count=1, + ) + normalized = self.store.repair_frontmatter(normalized, None) + normalized = self.store.resolve_body_refs(normalized, None) + self._reject_wrong_raw_refs(normalized) + self._reject_malformed_wiki_refs(normalized) + normalized = self.store.dedup_citations(normalized) + normalized = self._dedupe_h2_sections(normalized) + self._validate_formal_write( + content=normalized, + concept=concept, + class_name=class_name, + object_name=object_name, + ) + current_content = self.store.read_entity(concept, class_name or None, object_name) + if current_content is not None: + normalized = self._mark_merge_conflict( + concept, + self.store.entity_uri(concept, class_name or None, object_name), + current_content, + normalized, + ) + prepared.append( + ( + concept, + class_name, + object_name, + normalized, + expected_sha256, + len(current_content) if current_content is not None else None, + ), + ) + + # All identities in the batch are already known before publication. + # Let deterministic body-link materialization use that set so links + # between two new entities do not require a write-read-write cycle. + batch_uris = { + self.store.entity_uri(concept, class_name or None, object_name) + for concept, class_name, object_name, _content, _expected_sha256, _char_before in prepared + } + prepared = [ + ( + concept, + class_name, + object_name, + materialize_body_links(content, concept, self, known_uris=batch_uris), + expected_sha256, + char_before, + ) + for concept, class_name, object_name, content, expected_sha256, char_before in prepared + ] + + uris: list[str] = [] + sync_targets: set[str] = set() + if self._log: + for ( + concept, + class_name, + object_name, + _content, + _expected_sha256, + _char_before, + ) in prepared: + self._log.mutation_attempt( + self.store.entity_uri(concept, class_name or None, object_name), + "write_entities_batch", + ) + self.store.write_entities( + [ + (concept, class_name or None, object_name, content) + for concept, class_name, object_name, content, _expected_sha256, _char_before in prepared + ], + ) + for concept, class_name, object_name, content, _expected_sha256, char_before in prepared: + uri = self.store.entity_uri(concept, class_name or None, object_name) + self.store.register_natural_key(concept, class_name or None, object_name, uri) + if self._log: + self._log.mutation_applied(uri, "write_entities_batch") + body_content = content + uris.append(uri) + if concept == "Component": + sync_targets.add(uri) + elif concept in {"Fault", "Procedure"}: + relation_field = ( + "affected_components" if concept == "Fault" else "target_components" + ) + frontmatter = parse_frontmatter(body_content) + sync_targets.update( + value.split("#", 1)[0] + for value in self._frontmatter_uri_values( + self.store.root_uri, + frontmatter, + relation_field, + ) + ) + if self._log: + if char_before is None: + self._log.entity_created( + uri, + concept, + object_name, + len(body_content), + class_name=class_name or None, + ) + else: + self._log.entity_updated( + uri, + concept, + object_name, + len(body_content), + char_before, + class_name=class_name or None, + reason="batch_merge", + ) + self._record_phase_timing("materialization", materialization_started) + relation_started = time.perf_counter() + for component_uri in sorted(sync_targets): + self._sync_component_narrative_links(component_uri) + self._record_phase_timing("relation", relation_started) + self._invalidate_audit_cache() + return { + "written_count": len(uris), + "skipped_count": 0, + "uris": uris, + } + + def write_symptom_profile( + self, + symptom_uri: str, + profile_id: str, + content: str, + *, + expected_sha256: str = "", + skip_materialization: bool = False, + ) -> str: + """Create a Profile or guarded-merge an existing Profile.""" + profile_uri = self.store.symptom_profile_uri(symptom_uri, profile_id) + current = self.store.read_entity_by_uri(profile_uri) + if current is not None: + current_sha256 = sha256(current.encode("utf-8")).hexdigest() + if not expected_sha256: + raise ValueError( + "Existing Symptom Profile writes require diff_entity followed by a guarded merge", + ) + if expected_sha256 != current_sha256: + raise ValueError( + f"Symptom Profile changed since diff; reread before merge (expected={expected_sha256}, actual={current_sha256}).", + ) + content = self.store.repair_frontmatter(content, None) + content = self.store.resolve_body_refs(content, None) + self._reject_wrong_raw_refs(content) + self._reject_malformed_wiki_refs(content) + content = self.store.dedup_citations(content) + content = self._dedupe_h2_sections(content) + parent_info = self.store.lookup_by_uri(symptom_uri) + if parent_info is None or parent_info[0] != "Symptom": + raise ValueError(f"Unknown canonical Symptom URI: {symptom_uri}") + if entity_status(content) == "confirmed": + require_valid_entity( + content=content, + concept="Symptom", + class_name=parent_info[1] or "", + object_name=parent_info[2], + hooks=_FORMAL_WRITE_HOOKS, + ) + else: + self._validate_formal_write( + content=content, + concept="Symptom", + class_name=parent_info[1] or "", + object_name=parent_info[2], + skip_materialization=skip_materialization, + ) + self.store.write_symptom_profile(symptom_uri, profile_id, content) + # Backfill parent index.md with updated Profile 索引 + self._sync_symptom_profile_index(symptom_uri) + logger.info("Symptom Profile written: %s (%d chars)", profile_uri, len(content)) + return profile_uri + + def patch_symptom_profile( + self, + symptom_uri: str, + profile_id: str, + operations: list[dict], + *, + expected_sha256: str = "", + ) -> str: + """Patch one formal Symptom Profile with optimistic locking.""" + profile_uri = self.store.symptom_profile_uri(symptom_uri, profile_id) + content = self.store.read_entity_by_uri(profile_uri) + if content is None: + raise FileNotFoundError(f"Symptom Profile not found: {profile_uri}") + current_sha256 = sha256(content.encode("utf-8")).hexdigest() + if not expected_sha256: + raise ValueError( + "Existing Symptom Profile patches require expected_sha256", + ) + if expected_sha256 != current_sha256: + raise ValueError( + f"Symptom Profile changed before patch; reread before patch (expected={expected_sha256}, actual={current_sha256}).", + ) + content = self._apply_operations(content, operations) + content = self.store.repair_frontmatter(content, None) + content = self.store.resolve_body_refs(content, None) + self._reject_wrong_raw_refs(content) + self._reject_malformed_wiki_refs(content) + content = self.store.dedup_citations(content) + content = self._dedupe_h2_sections(content) + if entity_status(content) == "confirmed": + parent_info = self.store.lookup_by_uri(symptom_uri) + if parent_info is None: + raise ValueError(f"Unknown parent Symptom: {symptom_uri}") + _concept, class_name, object_name = parent_info + require_valid_entity( + content=content, + concept="Symptom", + class_name=class_name or "", + object_name=object_name, + hooks=_FORMAL_WRITE_HOOKS, + ) + else: + parent_info = self.store.lookup_by_uri(symptom_uri) + if parent_info is None: + raise ValueError(f"Unknown parent Symptom: {symptom_uri}") + self._validate_formal_write( + content=content, + concept="Symptom", + class_name=parent_info[1] or "", + object_name=parent_info[2], + ) + if content == self.store.read_entity_by_uri(profile_uri): + return profile_uri + self.write_symptom_profile( + symptom_uri, + profile_id, + content, + expected_sha256=current_sha256, + ) + return profile_uri + + def list_symptom_profiles(self, symptom_uri: str) -> list[dict[str, str]]: + """List Profile subresources belonging to a canonical Symptom.""" + return [ + {"profile_id": profile_id, "uri": uri} + for profile_id, uri in self.store.list_symptom_profiles(symptom_uri) + ] + + def diff_symptom_profile( + self, + symptom_uri: str, + profile_id: str, + candidate_content: str, + ) -> dict[str, object]: + """Return a deterministic diff and revision hashes for a Profile.""" + profile_uri = self.store.symptom_profile_uri(symptom_uri, profile_id) + current = self.store.read_entity_by_uri(profile_uri) + current_hash = sha256((current or "").encode("utf-8")).hexdigest() + candidate_hash = sha256(candidate_content.encode("utf-8")).hexdigest() + diff = "".join( + unified_diff( + (current or "").splitlines(keepends=True), + candidate_content.splitlines(keepends=True), + fromfile="current", + tofile="candidate", + ), + ) + return { + "uri": profile_uri, + "exists": current is not None, + "changed": current != candidate_content, + "current_sha256": current_hash, + "candidate_sha256": candidate_hash, + "diff": diff, + } + + def migrate_legacy_symptom_profiles(self) -> dict[str, int]: + """Move legacy pseudo-Symptom Profile entities into ``profile/``.""" + symptom_root_key = self.store.CONCEPT_DIRS["Symptom"] + migrated = 0 + skipped = 0 + for key in self.store.list_dir(symptom_root_key, recursive=True): + if not key.endswith("/index.md"): + continue + content = self.store.read_text(key) + if content is None: + continue + profile_match = re.search(r"^profile_id:\s*([^\s]+)\s*$", content, re.MULTILINE) + parent_match = re.search( + rf"^parent_symptom:\s*({re.escape(self.store.root_uri)}/Symptom/\S+)\s*$", + content, + re.MULTILINE, + ) + if profile_match is None or parent_match is None: + continue + profile_id = profile_match.group(1) + symptom_uri = parent_match.group(1) + try: + target_path = self.store.symptom_profile_path(symptom_uri, profile_id) + except ValueError: + skipped += 1 + continue + target_key = self.store._key_of(target_path) + target_content = self.store.read_text(target_key) + if target_content is not None and target_content != content: + skipped += 1 + continue + + legacy_uri: str | None = None + for concept, class_name, object_name, uri in self.store.list_entities("Symptom"): + if ( + self.store._key_of(self.store.entity_path(concept, class_name, object_name)) + == key + ): + legacy_uri = uri + break + + target_content = self.store.read_entity_by_uri( + self.store.symptom_profile_uri(symptom_uri, profile_id), + ) + expected_sha256 = ( + sha256(target_content.encode("utf-8")).hexdigest() + if target_content is not None + else "" + ) + self.write_symptom_profile( + symptom_uri, + profile_id, + content, + expected_sha256=expected_sha256, + ) + if key != target_key: + self.store.delete(key) + # ponytail: no empty-dir cleanup on backend keys; Viking has no dirs. + if legacy_uri is not None and legacy_uri != symptom_uri: + self.store.unregister_uri(legacy_uri) + migrated += 1 + return {"migrated": migrated, "skipped": skipped} + + # ── Patch-based entity editing ───────────────────────────────────────── diff --git a/src/wolfharness/capabilities/wiki/entities/finalize.py b/src/wolfharness/capabilities/wiki/entities/finalize.py new file mode 100644 index 000000000..3b84b673f --- /dev/null +++ b/src/wolfharness/capabilities/wiki/entities/finalize.py @@ -0,0 +1,1049 @@ +"""Search, backlinks, change report, and finalize.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from hashlib import sha256 +import json +import logging +import os +import re +import time + +from wolfharness.capabilities.wiki.io.text_parsers import ( + _parse_forward_links, +) +from wolfharness.capabilities.wiki.quality import ( + BuildProfile, + WikiAuditReport, + entity_status, + extract_source_uris, + parse_frontmatter, +) +from wolfharness.capabilities.wiki.schema_loader import get_schema_version + + +logger = logging.getLogger(__name__) + +from typing import TYPE_CHECKING + +from wolfharness.capabilities.wiki._helpers import _with_publication_state + + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + from pathlib import Path + + +# Core-path publication blockers: the diagnostic backbone a Wiki page +# dead-ends without. Mechanism content (Component.working_mechanism, +# Fault.failure_mechanism) and the required Device↔Symptom↔Fault graph edges +# (Device.critical_components, SymptomProfile.device_refs, DTC.related_faults) +# hard-block finalize. Every other error — Procedure associations, +# body-link completeness, isolated/draft entities, hook.* structural findings +# — is reported but never stops publication. This mirrors the OPA/planning +# backbone policy (``is_optional_relation_issue``) that already defers +# non-required relations; the finalize gate must not self-lock on relation +# edges the schema itself treats as optional. +_CORE_PATH_BLOCKER_CODES = frozenset({ + "Component.working_mechanism", # content: body ## 工作机理 + "Fault.failure_mechanism", # content: body ## 失效机理 + "Device.critical_components", # relation: frontmatter (BOM-filled) + body fallback + "DTC.related_faults", # relation: frontmatter OR body ## 可能失效机理 + # ponytail: SymptomProfile.device_refs removed — schema-optional, no body + # fallback, closure defers it. Blocking on it contradicts the backbone + # policy and body-first design. DTC→Device has no field-level code + # (bound via class_name/controller), so DTC.related_faults is the only + # DTC link code the audit can raise. +}) + + +class FinalizeMixin: + """Search, backlinks, change report, and finalize.""" + + def grep_wiki(self, pattern: str, *, limit: int = 256, target_uri: str = "") -> list[dict]: + """Regex text search across all wiki entities. + + Returns matching entities with ``key``, ``uri``, ``score``, ``abstract``. + On local backends returns an empty list. + """ + if target_uri: + allowed = (self.store.root_uri, self._raw_fs.root_uri) + target = target_uri.rstrip("/") + if not any(target == root or target.startswith(root + "/") for root in allowed): + raise ValueError( + "target_uri must belong to the configured wiki or raw OpenViking root" + ) + return self.store._fs.grep(pattern, limit=limit, target_uri=target_uri) + + # ── Backlinks ─────────────────────────────────────────────────────────── + + def rebuild_backlinks(self, entity_uris_with_links: list[tuple[str, list[str]]]) -> int: + """Rebuild ``backlinks_index.json`` from a complete set of entity→links pairs. + + self._invalidate_audit_cache() + Returns the number of backlink entries written. + """ + if not entity_uris_with_links: + return 0 + self.store.rebuild_backlinks(entity_uris_with_links) + return len(entity_uris_with_links) + + def rebuild_all_backlinks(self) -> dict[str, object]: + """Reconcile deterministic graph projections and rebuild backlinks. + + Relation workers only patch their disjoint source entities. Device + diagnostic navigation is a deterministic projection of those links, + so it is synchronized here in-process instead of scheduling a fragile + global join worker. + """ + device_diagnostic_page_count = self._sync_device_diagnostic_links() + component_narrative_page_count = self._sync_component_narrative_links() + pairs: list[tuple[str, list[str]]] = [] + for concept, _class_name, _object_name, uri in self.store.list_entities(): + content = self.store.read_entity_by_uri(uri) + if content: + linked = [u for u in _parse_forward_links(content) if u != uri] + pairs.append((uri, linked)) + if concept == "Symptom": + for profile in self.list_symptom_profiles(uri): + profile_uri = profile["uri"] + profile_content = self.read_resource(profile_uri) + if profile_content: + linked = [ + lu for lu in _parse_forward_links(profile_content) if lu != profile_uri + ] + pairs.append((profile_uri, linked)) + count = self.rebuild_backlinks(pairs) + return { + "backlink_entries": count, + "device_diagnostic_page_count": device_diagnostic_page_count, + "component_narrative_page_count": component_narrative_page_count, + "native_relation_sync": self.store.native_relation_sync_result + or {"status": "not_attempted"}, + } + + def get_backlinks(self, uri: str) -> list[str]: + """Return URIs that link to *uri* (empty if none / no index).""" + return self.store.get_backlinks(uri) + + def prune_stale_index_entries(self) -> dict[str, object]: + """Compatibility no-op after entity identity indexes were retired.""" + self._invalidate_audit_cache() + return { + "removed_count": 0, + "removed_uris": [], + "status": "identity_indexes_retired", + } + + # ── Wiki structure finalization (resource.json, concepts index, etc.) ──── + # + # Ported from builder_legacy.py `_write_to_disk_unlocked` — these methods + # build the wiki directory structure artifacts AFTER all entities have been + # written. Every filesystem op goes through WikiStore. + + def entity_uri(self, concept: str, class_name: str | None, object_name: str) -> str: + """Build the canonical readable ``{root_uri}/<Concept>/<Class>/<Object>`` URI.""" + return self.store.entity_uri(concept, class_name, object_name) + + def write_resource_manifest( + self, + doc_id: str, + device_id: str, + series_id: str, + entities: list[dict], + *, + authoritative: bool = False, + build_id: str = "", + snapshot_id: str = "", + input_snapshot_hash: str = "", + config_hash: str = "", + source_snapshot_id: str = "", + input_docs: tuple[str, ...] = (), + spec_version: str = "", + persist: bool = True, + ) -> dict: + """Write ``resource.json`` — source URI → 文件路径映射. + + ``entities`` is a list of dicts with keys: + ``concept, class_name, object_name, title, description, uri``. + Merges with existing resource.json (preserving other models' entries). + """ + spec_version = spec_version or get_schema_version() + # Dedup by URI + path (same entity written multiple times via merge). + seen: set[tuple[str, str]] = set() + resources: list[dict] = [] + for ent in entities: + uri = ent.get("uri", "") + if not uri: + continue + concept = ent.get("concept", "") + path_value = ent.get("path") + if isinstance(path_value, str) and path_value: + path = path_value + else: + path = self.store._key_of( + self.store.entity_path( + concept, + ent.get("class_name") or None, + ent.get("object_name", ""), + ), + ) + key = (uri, path) + if key in seen: + continue + seen.add(key) + # A leaf hash/name is not globally unique across manuals. Use the + # complete URI so SY75 and SY215C entries cannot overwrite one + # another in the merged resource manifest. + resource_id = sha256(uri.encode("utf-8")).hexdigest()[:24] if uri else "" + resources.append( + { + "resource_id": resource_id, + "kind": f"entity_{concept.lower()}", + "uri": uri, + "path": path, + "mime_type": "text/markdown", + "title": ent.get("title", ent.get("object_name", "")), + "name": ent.get("object_name", ""), + "entity_type": concept, + }, + ) + + manifest = { + "version": 2, + "spec_version": spec_version, + "build_id": build_id, + "snapshot_id": snapshot_id, + "input_snapshot_hash": input_snapshot_hash, + "config_hash": config_hash, + "input_docs": list(input_docs), + "model_id": device_id, + "uri": f"{self.store.root_uri}/resource/{device_id}.json", + "resources": resources, + } + + # Merge with existing resource.json (preserve other models' entries). + existing: dict = {} + existing_raw = self.store.read_text("resource.json") + if existing_raw is not None and not authoritative: + try: + parsed = json.loads(existing_raw) + if isinstance(parsed, dict): + existing = parsed + except json.JSONDecodeError: + pass + old_resources = existing.get("resources") if isinstance(existing, dict) else None + if isinstance(old_resources, list): + # Merge by resource_id — keep ALL existing entries, overwrite + # same resource_ids from current batch (handles multiple docs + # with the same device_id, e.g. SY215C维修手册 + fault-cases). + merged = { + str(e.get("resource_id")): e + for e in old_resources + if isinstance(e, dict) and e.get("resource_id") + } + merged.update( + {str(e["resource_id"]): e for e in resources if e.get("resource_id")}, + ) + manifest["resources"] = list(merged.values()) + + if persist: + self.store.write_text( + "resource.json", + json.dumps(manifest, ensure_ascii=False, indent=2), + ) + logger.info("resource.json: %d resources indexed", len(manifest["resources"])) + return manifest + + def write_concepts_index( + self, + device_id: str, + entities: list[dict], + *, + authoritative: bool = False, + persist: bool = True, + ) -> dict: + """Group entities by concept and write a concepts index. + + Writes ``index/concepts_index.json`` — a ``{concept_name: [entity_uri, ...]}`` + map so downstream consumers can enumerate entities by concept. + Merges with existing index (handles multi-doc builds). + """ + # Load existing index. + groups: dict[str, list[str]] = {} + existing_raw = self.store.read_text("index/concepts_index.json") + if existing_raw is not None and not authoritative: + try: + parsed = json.loads(existing_raw) + if isinstance(parsed, dict): + groups = parsed + except json.JSONDecodeError: + pass + + seen: set[tuple[str, str]] = set() + for concept, uris in groups.items(): + for u in uris: + seen.add((concept, u)) + for ent in entities: + concept = ent.get("concept", "") + uri = ent.get("uri", "") + if concept and uri and (concept, uri) not in seen: + seen.add((concept, uri)) + groups.setdefault(concept, []).append(uri) + + # Write merged concepts_index.json. + if persist: + self.store.write_text( + "index/concepts_index.json", + json.dumps(groups, ensure_ascii=False, indent=2), + ) + + logger.info("Concepts index: %d concepts, %d entities", len(groups), len(entities)) + return groups + + def build_change_report( + self, + *, + persist: bool = True, + limit: int = 10000, + include_op_flow: bool = True, + audit_report: WikiAuditReport | None = None, + ) -> dict[str, object]: + """Summarize the current incremental build and its publication evidence. + + The report is deterministic: entity changes come from the structured + build JSONL, while current counts and content summaries come from the + committed Wiki store. ``event_span_seconds`` is deliberately named + to avoid presenting the first/last logged event span as a full process + wall-clock measurement when a caller did not attach a logger. + """ + if limit < 1 or limit > 10000: + raise ValueError("build_change_report limit must be between 1 and 10000") + checkpoint = self.store.read_json("index/build_checkpoint.json") + build_started_at = ( + str(checkpoint.get("started_at", "")).strip() if isinstance(checkpoint, dict) else "" + ) + log_paths: list[Path] = [] + if self._log: + log_paths = sorted(self._log.log_dir.glob("wiki_build_*.jsonl")) + if self._log.path not in log_paths: + log_paths.append(self._log.path) + events: list[dict[str, object]] = [] + for log_path in log_paths: + if not log_path.exists(): + continue + for line in log_path.read_text(encoding="utf-8").splitlines(): + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(record, dict): + continue + if build_started_at: + event_time = str(record.get("timestamp", "")) + if not event_time or event_time < build_started_at: + continue + events.append(record) + entity_events = [ + event for event in events if event.get("event") in {"entity_created", "entity_updated"} + ] + entity_events = entity_events[-limit:] + formal_records = self._formal_entity_snapshot_records() + counts_by_concept: dict[str, int] = {} + for record in formal_records: + concept = str(record.get("concept", "")) + counts_by_concept[concept] = counts_by_concept.get(concept, 0) + 1 + + changed_entities: list[dict[str, object]] = [] + for event in entity_events: + uri = str(event.get("uri", "")) + content = self.read_resource(uri) if uri else None + frontmatter = parse_frontmatter(content or "") if content else {} + changed_entities.append( + { + "event": event.get("event", ""), + "timestamp": event.get("timestamp", ""), + "uri": uri, + "concept": event.get("concept", ""), + "class_name": event.get("class_name", ""), + "object_name": event.get("object_name", ""), + "char_count_before": event.get("char_count_before", 0), + "char_count_after": event.get("char_count", len(content or "")), + "reason": event.get("reason", ""), + "source_uris": list(extract_source_uris(content or "")), + "applicable_models": frontmatter.get("applicable_models", []), + "conflict_pending": str(frontmatter.get("conflict_pending", "")).lower() + in {"true", "yes", "1"}, + "sections": re.findall(r"^##\s+(.+?)\s*$", content or "", re.MULTILINE), + }, + ) + + event_timestamps: list[datetime] = [] + for event in events: + timestamp = event.get("timestamp") + if not isinstance(timestamp, str): + continue + try: + event_timestamps.append(datetime.fromisoformat(timestamp)) + except ValueError: + continue + event_span_seconds = ( + round((max(event_timestamps) - min(event_timestamps)).total_seconds(), 3) + if len(event_timestamps) >= 2 + else None + ) + build_elapsed_seconds: float | None = None + if isinstance(checkpoint, dict): + checkpoint_times: list[datetime] = [] + for key in ("started_at", "updated_at"): + timestamp = checkpoint.get(key) + if not isinstance(timestamp, str): + continue + try: + checkpoint_times.append(datetime.fromisoformat(timestamp)) + except ValueError: + continue + if len(checkpoint_times) == 2: + build_elapsed_seconds = round( + (checkpoint_times[1] - checkpoint_times[0]).total_seconds(), + 3, + ) + created_count = sum(1 for event in entity_events if event.get("event") == "entity_created") + updated_count = sum(1 for event in entity_events if event.get("event") == "entity_updated") + mutation_attempt_count = sum( + 1 for event in events if event.get("event") == "mutation_attempt" + ) + mutation_applied_count = sum( + 1 for event in events if event.get("event") == "mutation_applied" + ) + source_packet_unique_count = len( + { + str(event.get("packet_id", "")) + for event in events + if event.get("event") == "source_packet_recorded" + and str(event.get("packet_id", "")) + }, + ) + by_change_concept: dict[str, dict[str, int]] = {} + for event in entity_events: + concept = str(event.get("concept", "")) + bucket = by_change_concept.setdefault(concept, {"created": 0, "updated": 0}) + event_name = "created" if event.get("event") == "entity_created" else "updated" + bucket[event_name] += 1 + phase_wall_ms: dict[str, float | None] = { + "extraction": None, + "materialization": None, + "relation": None, + "audit": None, + "finalize": None, + } + phase_totals: dict[str, float] = {} + for event in events: + if event.get("event") != "phase_timing": + continue + phase = str(event.get("phase", "")).strip() + duration = event.get("duration_ms") + if phase and isinstance(duration, (int, float)): + phase_totals[phase] = phase_totals.get(phase, 0.0) + float(duration) + for phase, duration in phase_totals.items(): + if phase in phase_wall_ms: + phase_wall_ms[phase] = round(duration, 3) + issue_counts: dict[str, int] = {} + structural_valid_count: int | None = None + content_confirmed_count = sum( + 1 + for record in formal_records + if entity_status(str(record.get("content", ""))) == "confirmed" + ) + if audit_report is not None: + structural_valid_uris = {str(record.get("uri", "")) for record in formal_records} + audit_issues = audit_report.get("issues", []) + if not isinstance(audit_issues, list): + audit_issues = [] + for issue in audit_issues: + if not isinstance(issue, dict): + continue + if issue.get("severity") == "error": + code = str(issue.get("code", "")) + issue_counts[code] = issue_counts.get(code, 0) + 1 + structural_valid_uris.discard(str(issue.get("uri", ""))) + structural_valid_count = len(structural_valid_uris) + op_flow: dict[str, object] + if include_op_flow: + report_build_id = ( + str(checkpoint.get("build_id", "")).strip() if isinstance(checkpoint, dict) else "" + ) + op_flow = self.op_flow_report( + persist=persist, + limit=limit, + build_id=report_build_id or None, + ) + else: + op_flow = { + "counts": { + "opa": len(self.get_opas(limit=limit)), + "ops": len(self.get_ops(limit=limit)), + "opl": len(self.get_opls(limit=limit)), + }, + "coverage_skipped": True, + } + report: dict[str, object] = { + "version": 1, + "generated_at": datetime.now(UTC).isoformat(), + "event_log_path": str(self._log.path) if self._log else "", + "event_count": len(events), + "event_span_seconds": event_span_seconds, + "build_elapsed_seconds": build_elapsed_seconds, + "entity_created_count": created_count, + "entity_updated_count": updated_count, + "entity_touched_count": created_count + updated_count, + "mutation_attempt_count": mutation_attempt_count, + "mutation_applied_count": mutation_applied_count, + "source_packet_unique_count": source_packet_unique_count, + "structural_valid_count": structural_valid_count, + "content_confirmed_count": content_confirmed_count, + "publication_blocker_counts": issue_counts, + "entity_count_current": len(formal_records), + "entity_count_by_concept": counts_by_concept, + "changes_by_concept": by_change_concept, + "changed_entities": changed_entities, + "phase_wall_ms": phase_wall_ms, + "op_flow": op_flow, + "model_mapping": self.model_mapping_report(), + } + if persist: + self.store.write_json("index/build_delta_report.json", report) + return report + + def finalize_wiki( + self, + doc_id: str, + device_id: str, + series_id: str, + entities: list[dict] | None = None, + *, + authoritative: bool = False, + allow_residual_errors: bool = False, + audit_profile: BuildProfile = "manual", + ) -> dict: + """Build wiki structure artifacts after entity extraction. + + Call this once per doc, after all entities are written/merged. + Returns ``{resource_count, concept_count, entity_count, + unresolved_opa_count}``. + + ``allow_residual_errors`` is retained for caller compatibility but + never bypasses blockers. A source-honest relation gap may remain in + the page/ledger as ``open_gap``; only substantive content/identity + gaps and fact conflicts become OPA review records. Final pages are + stamped as machine-validated with ``review_state=unreviewed``. + """ + finalize_started = time.perf_counter() + audit_profile = self._validate_audit_profile(audit_profile) + current_checkpoint = self.store.read_json("index/build_checkpoint.json") + if current_checkpoint is not None: + checkpoint_identity = tuple( + str(current_checkpoint.get(field, "")) + for field in ("doc_id", "device_id", "series_id") + ) + requested_identity = (doc_id, device_id, series_id) + checkpoint_profile = str(current_checkpoint.get("audit_profile", "manual")) + if checkpoint_identity == requested_identity and checkpoint_profile != audit_profile: + raise ValueError( + f"Build audit profile cannot change between checkpoint and finalize: existing={checkpoint_profile}, requested={audit_profile}", + ) + # Idempotency: if the checkpoint already records a successful finalize + # for the same entity snapshot, return immediately instead of re-auditing. + # Re-auditing after promotion would activate strict hooks on confirmed + # entities and self-lock the gate (the prior finalize's own stamping + # turns warnings into errors on the next pass). + if current_checkpoint is not None: + ckpt_stage = str(current_checkpoint.get("stage", "")).strip() + ckpt_snapshot = str(current_checkpoint.get("snapshot_id", "")).strip() + if ckpt_stage == "finalized" and ckpt_snapshot == self._current_entity_snapshot_id(): + logger.info( + "finalize_wiki: build already finalized (build_id=%s, snapshot=%s); returning cached result.", + current_checkpoint.get("build_id", ""), + ckpt_snapshot, + ) + return { + "status": "already_finalized", + "build_id": str(current_checkpoint.get("build_id", "")), + "snapshot_id": ckpt_snapshot, + "source_snapshot_id": str(current_checkpoint.get("source_snapshot_id", "")), + "audit_profile": str(current_checkpoint.get("audit_profile", audit_profile)), + "entity_count": int(current_checkpoint.get("entity_count", 0)), + "published_count": int(current_checkpoint.get("published_count", 0)), + "remote_sync": {"status": "not_required"}, + } + # ponytail: remote_sync_pending means entities are already promoted + # and committed locally; only the remote upload failed. Skip audit + # and promotion (re-auditing confirmed entities would self-lock the + # gate via strict hooks) and retry just the upload. + if ( + ckpt_stage == "remote_sync_pending" + and ckpt_snapshot == self._current_entity_snapshot_id() + ): + logger.info( + "finalize_wiki: retrying remote sync only (build_id=%s, snapshot=%s).", + current_checkpoint.get("build_id", ""), + ckpt_snapshot, + ) + return self._retry_remote_sync(current_checkpoint, audit_profile) + device_chapter_sync = self.sync_device_system_chapters(doc_id, device_id) + self._invalidate_audit_cache() + audit_started = time.perf_counter() + audit = self._audit_all_pages(profile=audit_profile, limit=500) + audit_elapsed_ms = round((time.perf_counter() - audit_started) * 1000, 3) + if not audit["passed"]: + error_issues = [i for i in audit.get("issues", []) if i.get("severity") == "error"] + # Only the core diagnostic backbone hard-blocks finalize: missing + # mechanism content and the Device↔Symptom↔Fault graph edges the + # user relies on. Every other error (Procedure associations, + # body-link completeness, isolated/draft entities, hook.* + # structural findings) stays visible in the report but never + # blocks publication. + blockers = [ + issue + for issue in error_issues + if str(issue.get("code", "")) in _CORE_PATH_BLOCKER_CODES + ] + if blockers: + codes = ", ".join(sorted({str(issue.get("code", "")) for issue in blockers})) + raise ValueError( + f"Wiki quality gate has non-waivable core-path blockers: {codes}. " + "Resolve the missing mechanism content or " + "Device↔Symptom↔Fault edge before finalizing.", + ) + if error_issues: + logger.warning( + "Finalize: audit has %d non-core-path errors; reporting and proceeding.", + len(error_issues), + ) + audit_snapshot_id = str(audit.get("snapshot_id", "")) + source_snapshot_id = str(audit.get("source_snapshot_id", "")) + if not source_snapshot_id: + raise ValueError("Wiki audit did not produce a raw-source snapshot id.") + if audit_snapshot_id and audit_snapshot_id != self._current_entity_snapshot_id(): + raise ValueError( + "Wiki changed after audit; rerun audit_wiki before finalize so publication uses the same entity snapshot.", + ) + if source_snapshot_id != self._current_source_snapshot_id(): + raise ValueError( + "Raw sources changed after audit; rerun audit_wiki before finalize so publication uses the same source snapshot.", + ) + indexed_entities = [ + { + "concept": record["concept"], + "class_name": record["class_name"], + "object_name": record["object_name"], + "title": record["object_name"], + "description": "", + "uri": record["uri"], + "path": record["path"], + } + for record in self._formal_entity_snapshot_records() + ] + provided_uris = { + str(entity.get("uri", "")) for entity in (entities or []) if entity.get("uri") + } + indexed_uris = {str(entity["uri"]) for entity in indexed_entities} + if provided_uris and provided_uris != indexed_uris: + missing = sorted(indexed_uris - provided_uris) + extra = sorted(provided_uris - indexed_uris) + raise ValueError( + f"Finalize entity set does not match the audited index: " + f"indexed={len(indexed_uris)}, provided={len(provided_uris)}, " + f"missing({len(missing)})={missing}, extra({len(extra)})={extra}.", + ) + # The store/index is the source of truth. Never publish an arbitrary + # caller-supplied subset after auditing a different entity set. + entities = indexed_entities + current_build_id = ( + str(current_checkpoint.get("build_id", "")).strip() + if isinstance(current_checkpoint, dict) + else "" + ) + unresolved = self._unresolved_opa_records(build_id=current_build_id or None) + # OPA/OPS are review sidecars, not publication gates. The authoritative + # blocking checks are the audited entity/index checks above. Keep the + # scoped snapshot for the finalize receipt and observability, but do not + # reject a build because a review item is pending or legacy data has an + # unsupported category. + # OPS is an unconfirmed business-review artifact. Keep the snapshot + # in the finalize receipt for observability, but never require it + # before publishing the Wiki. + op_flow = self.op_flow_status() + input_doc_ids = self._library_doc_ids() or (doc_id,) + input_snapshot_hash = self.input_snapshot_hash(input_doc_ids) + config_hash = self._materialization_config_hash() + code_revision = self._code_revision() + completed_checkpoint = self._build_checkpoint_record( + doc_id, + device_id, + series_id, + "finalized", + input_hash=input_snapshot_hash, + config_hash=config_hash, + snapshot_id=audit_snapshot_id, + source_snapshot_id=source_snapshot_id, + input_docs=input_doc_ids, + schema_version=get_schema_version(), + audit_profile=audit_profile, + ) + build_id = str(completed_checkpoint["build_id"]) + started_at = str(completed_checkpoint.get("started_at", datetime.now(UTC).isoformat())) + manifest = self.write_resource_manifest( + doc_id, + device_id, + series_id, + entities, + authoritative=authoritative, + build_id=build_id, + snapshot_id=audit_snapshot_id, + input_snapshot_hash=input_snapshot_hash, + config_hash=config_hash, + source_snapshot_id=source_snapshot_id, + input_docs=input_doc_ids, + spec_version=get_schema_version(), + persist=False, + ) + concepts = self.write_concepts_index( + device_id, + entities, + authoritative=authoritative, + persist=False, + ) + current_records = self._formal_entity_snapshot_records() + page_writes = self._publication_page_writes(current_records) + publication_allowed_count = sum( + 1 + for record in current_records + if str(parse_frontmatter(str(record.get("content", ""))).get("status", "")).strip() + != "deprecated" + ) + # ``published_count`` is a state count; ``publication_update_count`` + # is the number of files whose publication stamp changed in this run. + # Keeping both prevents the old metric from confusing "changed now" + # with "published after finalize". + published_count = publication_allowed_count + delta_report = self.build_change_report( + persist=False, + include_op_flow=False, + audit_report=audit, + ) + op_flow_summary = delta_report.get("op_flow", {}) + op_flow_counts = ( + op_flow_summary.get("counts", {}) if isinstance(op_flow_summary, dict) else {} + ) + mapping_summary = delta_report.get("model_mapping", {}) + mapping_counts = ( + { + key: mapping_summary.get(key, 0) + for key in ( + "mapping_count", + "confirmed_count", + "pending_count", + "source_backed_count", + "unresolved_device_count", + ) + } + if isinstance(mapping_summary, dict) + else {} + ) + measured_phases = delta_report.get("phase_wall_ms") + phase_wall_ms: dict[str, float | None] = { + "extraction": None, + "materialization": None, + "relation": None, + "audit": audit_elapsed_ms, + "finalize": round((time.perf_counter() - finalize_started) * 1000, 3), + } + if isinstance(measured_phases, dict): + for phase in ("materialization", "relation"): + value = measured_phases.get(phase) + if isinstance(value, (int, float)): + phase_wall_ms[phase] = float(value) + completed_at = datetime.now(UTC).isoformat() + build_elapsed_seconds = round( + ( + datetime.fromisoformat(completed_at) - datetime.fromisoformat(started_at) + ).total_seconds(), + 3, + ) + metrics = { + "version": 1, + "status": "finalized", + "build_id": build_id, + "doc_id": doc_id, + "input_docs": list(input_doc_ids), + "device_id": device_id, + "series_id": series_id, + "snapshot_id": audit_snapshot_id, + "input_snapshot_hash": input_snapshot_hash, + "config_hash": config_hash, + "code_revision": code_revision, + "started_at": started_at, + "completed_at": completed_at, + "entity_count": len(entities), + "resource_count": len(manifest.get("resources", [])), + "concept_count": len(concepts), + "published_count": published_count, + "publication_allowed_count": publication_allowed_count, + "publication_update_count": len(page_writes), + "entity_created_count": delta_report.get("entity_created_count", 0), + "entity_updated_count": delta_report.get("entity_updated_count", 0), + "entity_touched_count": delta_report.get("entity_touched_count", 0), + "mutation_attempt_count": delta_report.get("mutation_attempt_count", 0), + "mutation_applied_count": delta_report.get("mutation_applied_count", 0), + "source_packet_unique_count": delta_report.get("source_packet_unique_count", 0), + "structural_valid_count": delta_report.get("structural_valid_count"), + "content_confirmed_count": int( + audit.get("confirmed_count", delta_report.get("content_confirmed_count", 0)) + ), + "publication_blocker_counts": delta_report.get("publication_blocker_counts", {}), + "event_span_seconds": delta_report.get("event_span_seconds"), + "build_elapsed_seconds": build_elapsed_seconds, + "unresolved_opa_count": len(unresolved), + "audit_error_count": int(audit.get("error_count", 0)), + "audit_warning_count": int(audit.get("warning_count", 0)), + "audit_profile": audit_profile, + "source_snapshot_id": str(audit.get("source_snapshot_id", "")), + "op_flow_counts": op_flow_counts, + "model_mapping_counts": mapping_counts, + "scheduler_mode": os.environ.get("WIKI_SCHEDULER_MODE", "unknown"), + "gate_mode": "hard_fail", + "phase_wall_ms": phase_wall_ms, + "control_plane": { + "task_count": None, + "task_retry_count": None, + "task_reassign_count": None, + "empty_wakeup_count": None, + "owner_mismatch_count": None, + "duplicate_intent_count": None, + "measurement": "wiki-runtime-only; attach team event sink for task counters", + }, + } + control_plane_metrics = metrics.get("control_plane") + if isinstance(control_plane_metrics, dict): + metrics.update( + { + key: control_plane_metrics.get(key) + for key in ( + "task_count", + "task_retry_count", + "task_reassign_count", + "empty_wakeup_count", + "owner_mismatch_count", + "duplicate_intent_count", + ) + }, + ) + publication_writes = [ + *page_writes, + ("resource.json", json.dumps(manifest, ensure_ascii=False, indent=2)), + ("index/concepts_index.json", json.dumps(concepts, ensure_ascii=False, indent=2)), + ( + "index/build_metrics.json", + json.dumps(metrics, ensure_ascii=False, indent=2) + "\n", + ), + ( + "index/build_checkpoint.json", + json.dumps(completed_checkpoint, ensure_ascii=False, indent=2) + "\n", + ), + ] + native_relation_sync = self.store.native_relation_sync_result + if isinstance(native_relation_sync, dict) and native_relation_sync.get("errors"): + raise ValueError( + f"OpenViking native relation sync failed; refusing to finalize: {native_relation_sync['errors']}" + ) + self.store.commit_many(publication_writes) + self.build_change_report(persist=True) + # Batch-upload to Viking if using local cache backend. Local commit is + # recoverable, but remote publication is part of successful finalize: + # a failed upload returns ``finalized_local`` and leaves a durable + # ``remote_sync_pending`` checkpoint instead of authorizing phase=done. + upload_result: dict | None = None + from wolfharness.capabilities.wiki.storage.local_viking_fs import LocalVikingFS + + if isinstance(self.store._fs, LocalVikingFS): + from wolfharness.capabilities.wiki.storage import _viking_client + + try: + executor = ThreadPoolExecutor(max_workers=1) + try: + upload_result = executor.submit( + self.store._fs.finalize_upload, + _viking_client(), + ).result(timeout=200) + finally: + executor.shutdown(wait=False) + logger.info("Wiki batch-uploaded to Viking: %s", upload_result) + except Exception: + logger.exception("Wiki batch-upload to Viking failed; remote sync remains pending") + upload_result = {"status": "upload_failed"} + remote_sync_failed = ( + upload_result is not None and upload_result.get("status") != "completed" + ) + if remote_sync_failed: + self.store.write_json( + "index/build_checkpoint.json", + { + **completed_checkpoint, + "stage": "remote_sync_pending", + "status": "remote_sync_pending", + "remote_sync": upload_result, + }, + ) + self._invalidate_audit_cache() + return { + "resource_count": len(manifest.get("resources", [])), + "concept_count": len(concepts), + "entity_count": len(entities), + "unresolved_opa_count": len(unresolved), + "published_count": published_count, + "publication_allowed_count": publication_allowed_count, + "publication_update_count": len(page_writes), + "entity_created_count": delta_report.get("entity_created_count", 0), + "entity_updated_count": delta_report.get("entity_updated_count", 0), + "entity_touched_count": delta_report.get("entity_touched_count", 0), + "build_elapsed_seconds": build_elapsed_seconds, + "build_id": completed_checkpoint["build_id"], + "snapshot_id": audit_snapshot_id, + "source_snapshot_id": source_snapshot_id, + "audit_profile": audit_profile, + "op_flow_passed": bool(op_flow["passed"]), + "status": "finalized_local" if remote_sync_failed else "finalized", + "remote_sync": upload_result or {"status": "not_required"}, + "device_chapter_sync": device_chapter_sync, + "input_snapshot_hash": input_snapshot_hash, + "config_hash": config_hash, + "metrics_path": "index/build_metrics.json", + "spec_version": get_schema_version(), + } + + def _retry_remote_sync( + self, + checkpoint: dict[str, object], + audit_profile: str, + ) -> dict[str, object]: + """Retry only the remote upload when a prior finalize succeeded locally. + + Entities are already promoted and committed locally; re-auditing would + activate strict hooks on confirmed entities and self-lock the gate. + """ + from wolfharness.capabilities.wiki.storage.local_viking_fs import LocalVikingFS + + upload_result: dict | None = None + if isinstance(self.store._fs, LocalVikingFS): + from wolfharness.capabilities.wiki.storage import _viking_client + + try: + from concurrent.futures import ThreadPoolExecutor + + executor = ThreadPoolExecutor(max_workers=1) + try: + upload_result = executor.submit( + self.store._fs.finalize_upload, + _viking_client(), + ).result(timeout=3600) + finally: + executor.shutdown(wait=False) + logger.info("Wiki remote sync retry succeeded: %s", upload_result) + except Exception: + logger.exception("Wiki remote sync retry failed") + upload_result = {"status": "upload_failed"} + + remote_sync_ok = upload_result is None or upload_result.get("status") == "completed" + if remote_sync_ok: + # Upgrade checkpoint to finalized + finalized_checkpoint = {**checkpoint, "stage": "finalized", "status": "finalized"} + if upload_result: + finalized_checkpoint["remote_sync"] = upload_result + self.store.write_json("index/build_checkpoint.json", finalized_checkpoint) + else: + self.store.write_json( + "index/build_checkpoint.json", + {**checkpoint, "remote_sync": upload_result}, + ) + + return { + "resource_count": 0, + "concept_count": 0, + "entity_count": int(checkpoint.get("entity_count", 0)), + "unresolved_opa_count": 0, + "published_count": int(checkpoint.get("published_count", 0)), + "publication_allowed_count": int(checkpoint.get("published_count", 0)), + "publication_update_count": 0, + "entity_created_count": 0, + "entity_updated_count": 0, + "entity_touched_count": 0, + "build_id": str(checkpoint.get("build_id", "")), + "snapshot_id": str(checkpoint.get("snapshot_id", "")), + "source_snapshot_id": str(checkpoint.get("source_snapshot_id", "")), + "audit_profile": audit_profile, + "op_flow_passed": True, + "status": "finalized" if remote_sync_ok else "finalized_local", + "remote_sync": upload_result or {"status": "not_required"}, + } + + @staticmethod + def _publication_page_writes( + records: Sequence[Mapping[str, object]], + ) -> list[tuple[str, str]]: + """Return only pages whose publication fields actually need changing.""" + writes: list[tuple[str, str]] = [] + for record in records: + path = str(record.get("path", "")).lstrip("/") + content = str(record.get("content", "")) + if not path or path.split("/")[0] in {".", ".."}: + raise ValueError(f"Finalize target escapes wiki root: {path}") + frontmatter = parse_frontmatter(content) + deprecated = str(frontmatter.get("status", "")).strip() == "deprecated" + updated = _with_publication_state(content, deprecated=deprecated) + if updated != content: + writes.append((path, updated)) + return writes + + def finalize_wiki_with_migration( + self, + doc_id: str, + device_id: str, + series_id: str, + *, + allow_residual_errors: bool = False, + audit_profile: BuildProfile = "manual", + ) -> dict: + """Migrate legacy profiles, build entity list, then finalize wiki. + + Combines ``migrate_legacy_symptom_profiles`` + entity enumeration + + ``finalize_wiki`` into a single call so the capability layer has no + orchestration logic. + """ + profile_migration = self.migrate_legacy_symptom_profiles() + all_entities = [ + { + "concept": record["concept"], + "class_name": record["class_name"], + "object_name": record["object_name"], + "title": record["object_name"], + "description": "", + "uri": record["uri"], + "path": record["path"], + } + for record in self._formal_entity_snapshot_records() + ] + result = self.finalize_wiki( + doc_id, + device_id, + series_id, + all_entities, + authoritative=True, + allow_residual_errors=allow_residual_errors, + audit_profile=audit_profile, + ) + result["migrated_profiles"] = profile_migration["migrated"] + result["skipped_profiles"] = profile_migration["skipped"] + return result diff --git a/src/wolfharness/capabilities/wiki/entities/packets.py b/src/wolfharness/capabilities/wiki/entities/packets.py new file mode 100644 index 000000000..103f83b52 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/entities/packets.py @@ -0,0 +1,734 @@ +"""Packet, ledger, and source-registration tools.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from hashlib import sha256 +import json +import logging +import os +from pathlib import Path +import re +from typing import TYPE_CHECKING + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from wolfharness.capabilities.wiki.quality import ( + RawSourceKind, + classify_raw_source_uri, +) + + +if TYPE_CHECKING: + from collections.abc import Mapping + + +logger = logging.getLogger(__name__) + + +class PacketBody(BaseModel): + """Validated packet body for ``record_source_packet``. + + Enforces ``working_mechanism`` when Component candidates are present and + ``failure_mechanism`` when Fault candidates are present. BOM identity + and no-entity packets pass through unchecked via the ``kind`` field. + """ + + model_config = ConfigDict(extra="allow") + + # --- discriminator for internal packet types --- + kind: str | None = None + + # --- chapter extraction fields (all optional individually) --- + packet_kind: str | None = None + source_subject: str = "" + explicit_facts: list[str] = Field(default_factory=list) + normalized_identity_candidates: list[dict[str, object]] = Field(default_factory=list) + entity_candidates: list[dict[str, object]] = Field(default_factory=list) + entities: list[dict[str, object]] = Field(default_factory=list) + working_mechanism: str = "" + failure_mechanism: str = "" + images: list[dict[str, object]] = Field(default_factory=list) + dtc: str = "" + ordered_actions: list[dict[str, object]] = Field(default_factory=list) + parts_and_specs: object = None + evidence_map: list[dict[str, object]] = Field(default_factory=list) + uncertainties: object = None + cross_concept_handoffs: object = None + + @model_validator(mode="after") + def _validate_mechanism_fields(self) -> PacketBody: + """Require mechanism fields when matching candidates exist.""" + # Skip validation for internal packet types + if self.kind in {"bom_identity_plan", "no_entity"}: + return self + + # Collect all candidates from the three possible field names + all_candidates = ( + list(self.normalized_identity_candidates) + + list(self.entity_candidates) + + list(self.entities) + ) + + has_component = any( + str(c.get("concept", "")).lower() == "component" for c in all_candidates + ) + has_fault = any(str(c.get("concept", "")).lower() == "fault" for c in all_candidates) + + if has_component and not self.working_mechanism.strip(): + raise ValueError( + "working_mechanism must not be empty when packet contains " + "Component candidates; infer from diagnostic context and " + "mark with 'inferred' if the source does not state it explicitly", + ) + if has_fault and not self.failure_mechanism.strip(): + raise ValueError( + "failure_mechanism must not be empty when packet contains " + "Fault candidates; infer from diagnostic context and " + "mark with 'inferred' if the source does not state it explicitly", + ) + + return self + + +class PacketMixin: + """Packet, ledger, and source-registration tools.""" + + def source_snapshot( + self, + doc_ids: tuple[str, ...] = (), + *, + refresh: bool = False, + ) -> dict[str, str]: + """Return the current ``raw URI → chapter content hash`` snapshot. + + Raw input is immutable during one build session. Reuse the first + snapshot by default; callers explicitly checking for source changes + pass ``refresh=True`` so the expensive remote walk is not hidden in + every packet write. + """ + selected = tuple(sorted(set(doc_ids) or set(self._library_doc_ids(refresh=refresh)))) + if not refresh: + cached = self._source_snapshot_cache.get(selected) + if cached is not None: + return cached + else: + self._raw_source_hash_cache.clear() + source_files: dict[str, str] = {} + for current_doc_id in selected: + chapters = self.list_chapters(current_doc_id, refresh=refresh) + if not chapters: + # Single-file case document (no chapters/ tree): the source is + # the case file itself under {doc_id}/, not a chapter leaf. + doc_prefix = f"{current_doc_id}/" + for key in self._raw_fs.list_dir(doc_prefix, recursive=True): + if not key.endswith(".md"): + continue + source_files[f"{self._raw_fs.root_uri}/{key}"] = key + continue + for chapter in chapters: + key = str(chapter["md_path"]) + source_uri = f"{self._raw_fs.root_uri}/{key}" + source_files[source_uri] = key + + fingerprints = {uri: self._raw_fs.fingerprint(key) for uri, key in source_files.items()} + cached = self._source_snapshot_cache.get(selected) + cached_fingerprints = self._source_snapshot_fingerprints.get(selected) + if cached is not None and cached_fingerprints == fingerprints: + return cached + + snapshots: dict[str, str] = {} + for source_uri, key in source_files.items(): + content = self._raw_fs.read_text(key) + if content is None: + continue + snapshots[source_uri] = sha256(content.encode("utf-8")).hexdigest() + result = dict(sorted(snapshots.items())) + self._source_snapshot_cache[selected] = result + self._source_snapshot_fingerprints[selected] = fingerprints + return result + + def _input_snapshot_hash(self, doc_id: str) -> str: + """Hash the exact source chapters used by one document build.""" + return self.input_snapshot_hash((doc_id,)) + + @staticmethod + def _materialization_config_hash() -> str: + """Hash schema and taxonomy rules that define materialization behavior.""" + package_root = Path(__file__).resolve().parents[4] + config_paths = ( + Path(__file__).resolve().parent.parent / "templates" / "default_schema.yaml", + package_root / "config" / "component-taxonomy.yaml", + package_root / "config" / "prompts" / "team" / "wiki_extraction_worker.j2", + ) + content = b"".join(path.read_bytes() if path.is_file() else b"" for path in config_paths) + return sha256(content).hexdigest() + + @staticmethod + def _code_revision() -> str: + """Return an externally supplied immutable code revision when available.""" + explicit = os.environ.get("GIT_COMMIT", os.environ.get("WIKI_CODE_REVISION", "")).strip() + if explicit: + return explicit + source_path = Path(__file__) + content = source_path.read_bytes() if source_path.is_file() else b"" + return f"local-{sha256(content).hexdigest()[:16]}" + + def _write_build_metrics(self, metrics: dict[str, object]) -> None: + """Persist a compact durable build summary for restart and operations.""" + self.store.write_text( + "index/build_metrics.json", + json.dumps(metrics, ensure_ascii=False, indent=2) + "\n", + ) + + def _source_ledger_path(self) -> Path: + """Return the legacy aggregate ledger path (read-only compatibility).""" + return self.store.root / "index" / "source_ledger.json" + + @staticmethod + def _source_packet_key(packet_id: str) -> str: + """Return the stable business path for one source packet.""" + if re.fullmatch(r"[A-Za-z0-9_]+", packet_id) is None: + raise ValueError("packet_id must contain only ASCII letters, digits, and underscores") + return f"source_packets/{packet_id}.json" + + @staticmethod + def _relation_manifest_key(build_id: str) -> str: + """Return the durable relation-input manifest key for one build.""" + if not build_id.strip(): + raise ValueError("build_id is required for a relation manifest") + return f"index/relation_manifests/{build_id}.json" + + @staticmethod + def _relation_work_key(scope_id: str) -> str: + """Return the durable work ledger key for one relation scope.""" + if not scope_id.strip(): + raise ValueError("scope_id is required for a relation work ledger") + return f"index/relation_work/{scope_id}.json" + + @staticmethod + def _doc_id_variants(doc_id: str) -> set[str]: + """Return equivalent logical document ids from catalog path variants.""" + normalized = doc_id.strip("/") + variants = {normalized} + parts = normalized.split("/") if normalized else [] + while len(parts) >= 2 and parts[-1] == parts[-2]: + parts.pop() + variants.add("/".join(parts)) + return variants + + def _packet_matches_build( + self, + packet: Mapping[str, object], + checkpoint: Mapping[str, object], + allowed_docs: set[str], + ) -> bool: + """Match a legacy packet to a build despite nested catalog aliases.""" + packet_doc_id = str(packet.get("doc_id", "")).strip() + packet_variants = self._doc_id_variants(packet_doc_id) + if packet_variants & { + variant for doc_id in allowed_docs for variant in self._doc_id_variants(doc_id) + }: + return True + source_uris = packet.get("source_uris") + if not isinstance(source_uris, list): + return False + checkpoint_docs = { + str(value).strip() + for value in [ + checkpoint.get("doc_id"), + *( + checkpoint.get("input_docs", []) + if isinstance(checkpoint.get("input_docs"), list) + else [] + ), + ] + if str(value).strip() + } + return any( + isinstance(source_uri, str) + and any(self._source_belongs_to_doc(source_uri, doc_id) for doc_id in checkpoint_docs) + for source_uri in source_uris + ) + + def _chapter_packet_complete_for_build( + self, + packet_key: str, + packet: dict[str, object] | None, + *, + build_id: str, + doc_id: str, + source_uri: str, + checkpoint: Mapping[str, object], + ) -> bool: + """Return whether a chapter packet is complete for this build. + + Packets written by older workers may omit ``build_id`` even though + they contain a complete receipt. The chapter plan and exact source + URI provide a bounded ownership proof, so adopt that legacy packet + into the current build instead of rejecting the 1A → 1B transition. + Packets explicitly owned by another build remain rejected. + """ + if packet is None or str(packet.get("status", "")) != "complete": + return False + source_uris = packet.get("source_uris") + if not isinstance(source_uris, list) or source_uri not in source_uris: + return False + packet_build_id = str(packet.get("build_id", "")).strip() + if packet_build_id == build_id: + return True + if packet_build_id: + return False + allowed_docs = { + doc_id, + *( + str(value).strip() + for value in [ + checkpoint.get("doc_id"), + *( + checkpoint.get("input_docs", []) + if isinstance(checkpoint.get("input_docs"), list) + else [] + ), + ] + if str(value).strip() + ), + } + if not self._packet_matches_build(packet, checkpoint, allowed_docs): + return False + packet["build_id"] = build_id + self.store.write_json(packet_key, packet) + return True + + def _relation_manifest_packet_ids(self, checkpoint: Mapping[str, object]) -> set[str]: + """Return all source packets owned by the current build. + + The manifest is deliberately stored outside the bounded blackboard + state. It is rebuilt from packet records so a conductor restart or a + lost worker session cannot silently reduce relation scope to the last + packet that happened to remain in context. Legacy packets without a + ``build_id`` are adopted only when they belong to the checkpoint's + input document; the association is persisted for subsequent retries. + """ + build_id = str(checkpoint.get("build_id", "")).strip() + if not build_id: + return set() + allowed_docs = { + str(value).strip() + for value in [ + checkpoint.get("doc_id"), + *( + checkpoint.get("input_docs", []) + if isinstance(checkpoint.get("input_docs"), list) + else [] + ), + ] + if str(value).strip() + } + packet_ids: set[str] = set() + for key in self.store.list_dir("source_packets", recursive=True): + if not key.endswith(".json"): + continue + packet = self.store.read_json(key) + if not isinstance(packet, dict): + continue + packet_id = str(packet.get("packet_id", "")).strip() + if not packet_id or str(packet.get("status", "complete")) == "failed": + continue + packet_build_id = str(packet.get("build_id", "")).strip() + if packet_build_id == build_id: + packet_ids.add(packet_id) + continue + if packet_build_id or not self._packet_matches_build(packet, checkpoint, allowed_docs): + continue + # Rolling upgrades left packet records without build ownership. + # Adopt them exactly once at the materialized checkpoint; this is + # safe for empty builds and gives legacy packets a durable owner. + packet["build_id"] = build_id + self.store.write_json(key, packet) + packet_ids.add(packet_id) + + manifest = { + "version": 1, + "build_id": build_id, + "doc_id": str(checkpoint.get("doc_id", "")), + "input_docs": checkpoint.get("input_docs", []), + "packet_ids": sorted(packet_ids), + "updated_at": datetime.now(UTC).isoformat(), + } + self.store.write_json(self._relation_manifest_key(build_id), manifest) + return packet_ids + + def _all_source_packet_ids(self) -> set[str]: + """Return every readable packet id for checkpoint-free recovery.""" + packet_ids: set[str] = set() + for key in self.store.list_dir("source_packets", recursive=True): + if not key.endswith(".json"): + continue + packet = self.store.read_json(key) + if not isinstance(packet, dict) or str(packet.get("status", "complete")) == "failed": + continue + packet_id = str(packet.get("packet_id", "")).strip() + if packet_id: + packet_ids.add(packet_id) + return packet_ids + + def _relation_scope(self, checkpoint: Mapping[str, object]) -> tuple[set[str], str, bool]: + """Resolve relation input without trusting a missing or hollow checkpoint. + + A complete checkpoint with declared input documents uses build ownership. + A missing checkpoint, or a checkpoint written without input identity, is + a recovery case: relation planning scans the durable packet records and + reports that fact to the conductor. This keeps a stale/empty manifest + from turning a real relation workload into a false zero-candidate result. + """ + checkpoint_exists = bool(checkpoint.get("exists")) + build_id = str(checkpoint.get("build_id", "")).strip() + input_docs = checkpoint.get("input_docs") + has_input_identity = isinstance(input_docs, list) and any( + isinstance(value, str) and value.strip() for value in input_docs + ) + if checkpoint_exists and build_id and has_input_identity: + return self._relation_manifest_packet_ids(checkpoint), "build_manifest", True + return self._all_source_packet_ids(), "packet_store_recovery", False + + def _write_relation_work_ledger( + self, + scope_id: str, + *, + scope_source: str, + scope_trusted: bool, + packet_ids: set[str], + items: list[dict[str, object]], + ) -> str: + """Persist bounded relation work for workers and restart recovery.""" + key = self._relation_work_key(scope_id) + self.store.write_json( + key, + { + "version": 1, + "scope_id": scope_id, + "scope_source": scope_source, + "scope_trusted": scope_trusted, + "packet_ids": sorted(packet_ids), + "items": items, + "updated_at": datetime.now(UTC).isoformat(), + }, + ) + return f"{self.store.root_uri}/{key}" + + def get_source_ledger(self) -> dict[str, object]: + """Project source packets into the legacy coverage response shape. + + New packets are independent records under ``source_packets/``. The + aggregate mapping is built in memory so incremental writes never + read-modify-write one ever-growing JSON document. A legacy aggregate + ledger is merged read-only during rolling upgrades. + """ + legacy = self.store.read_json(self.store._key_of(self._source_ledger_path())) + packets: dict[str, object] = {} + coverage: dict[str, object] = {} + if legacy is not None: + legacy_packets = legacy.get("packets") + legacy_coverage = legacy.get("coverage") + if isinstance(legacy_packets, dict): + packets.update((str(key), value) for key, value in legacy_packets.items()) + if isinstance(legacy_coverage, dict): + coverage.update((str(key), value) for key, value in legacy_coverage.items()) + + for key in self.store.list_dir("source_packets", recursive=False): + if not key.endswith(".json"): + continue + document = self.store.read_json(key) + if not isinstance(document, dict): + continue + packet_id = str(document.get("packet_id", "")).strip() + if not packet_id: + continue + record = {name: value for name, value in document.items() if name != "packet"} + record["packet_uri"] = f"{self.store.root_uri}/{key}" + packets[packet_id] = record + source_hashes = record.get("source_hashes") + if not isinstance(source_hashes, dict): + continue + source_uris = record.get("source_uris") + if not isinstance(source_uris, list): + continue + for source_uri in source_uris: + if not isinstance(source_uri, str): + continue + previous = coverage.get(source_uri) + packet_ids = ( + list(previous.get("packet_ids", [])) if isinstance(previous, dict) else [] + ) + normalized_ids = {str(value) for value in packet_ids} + normalized_ids.add(packet_id) + coverage[source_uri] = { + "packet_ids": sorted(normalized_ids), + "status": record.get("status", "complete"), + "source_hash": source_hashes.get(source_uri, ""), + } + return { + "version": 2, + "packets": dict(sorted(packets.items())), + "coverage": dict(sorted(coverage.items())), + } + + def register_no_entity_chapters( + self, + doc_id: str, + packet_id: str, + source_uris: list[str], + ledger_key: str = "", + build_id: str = "", + ) -> dict[str, object]: + """Register low-value chapters as covered with a placeholder packet. + + Low-value rootsections are pure boilerplate — prefaces, safety + notices, tables of contents, blank/cover pages — that carry no entity + candidates. They are NOT spec sheets, parameter tables, technical + specifications, or schematic/figure chapters: those carry model + identity, threshold values, wiring/line numbers and connection + evidence and must be read by an extraction worker. Instead of making + a worker read pure boilerplate in full just to conclude ``no_entity``, + the conductor/file_operator can register those here — the packet + records the source URIs and hash, and its (empty) body satisfies the + ``packet_body_missing`` coverage gate. + + Args: + doc_id: Document id owning the chapters. + packet_id: Stable ASCII slug identifying this packet. + source_uris: Raw chapter URIs to mark covered. + ledger_key: Deprecated compatibility input; packet storage is + always derived from ``packet_id``. + + Returns: + The ledger record written for this packet. + """ + placeholder_body: dict[str, object] = { + "kind": "no_entity", + "note": "Low-value rootsection registered without model read; raw URIs are wired into Device.system_chapters by file_operator.", + } + # build_id MUST match the value passed to plan_chapter_work, because + # the prefilter marker path is derived from sha256(build_id + doc_id). + # If the conductor omits build_id here, the checkpoint backfill may + # resolve a DIFFERENT build_id than what plan_chapter_work received, + # writing the prefilter marker to a plan_id path that plan_chapter_work + # never reads — pre_filter_required stays true forever. + normalized_build_id = build_id.strip() + if not normalized_build_id: + checkpoint = self.store.read_json("index/build_checkpoint.json") + if isinstance(checkpoint, dict): + checkpoint_build_id = str(checkpoint.get("build_id", "")).strip() + checkpoint_docs = { + str(value).strip() + for value in [ + checkpoint.get("doc_id"), + *( + checkpoint.get("input_docs", []) + if isinstance(checkpoint.get("input_docs", []), list) + else [] + ), + ] + if str(value).strip() + } + if checkpoint_build_id and doc_id in checkpoint_docs: + normalized_build_id = checkpoint_build_id + if not normalized_build_id: + raise ValueError( + "build_id is required for register_no_entity_chapters; pass " + "the same build_id used in plan_chapter_work so the prefilter " + "marker is written to the correct plan_id path", + ) + plan_id = sha256(f"{normalized_build_id}\x1f{doc_id}".encode()).hexdigest()[:24] + self.store.write_json( + f"index/chapter_plans/{plan_id}.prefilter.json", + {"done": True, "packet_id": packet_id, "count": len(source_uris)}, + durable=True, + ) + return self.record_source_packet( + packet_id=packet_id, + doc_id=doc_id, + source_uris=source_uris, + status="complete", + evidence_count=0, + packet_body=placeholder_body, + ledger_key=ledger_key, + build_id=normalized_build_id, + ) + + def record_source_packet( + self, + packet_id: str, + doc_id: str, + source_uris: list[str], + *, + source_hash: str = "", + status: str = "complete", + evidence_count: int = 0, + packet_body: PacketBody | None = None, + ledger_key: str = "", + source_contents: dict[str, str] | None = None, + allow_same_snapshot_replace: bool = False, + build_id: str = "", + ) -> dict[str, object]: + """Persist packet body and deterministic chapter coverage. + + Every packet is stored independently under ``source_packets/`` using + its stable business ``packet_id``. Coverage is projected from those + records instead of mutating a central aggregate ledger. + + ``source_contents`` maps external MCP source URIs to the text the + agent fetched via MCP tools. The system hashes this caller-provided + text instead of trying to re-read the URI locally. + + ``allow_same_snapshot_replace`` is reserved for an explicit + same-task refinement workflow. It permits a stable packet id to + replace its extraction result when the source snapshot is unchanged; + ordinary packet callers retain the conflict guard by default. + """ + if not packet_id.strip() or not doc_id.strip(): + raise ValueError("packet_id and doc_id must not be empty") + if status not in {"complete", "partial", "failed"}: + raise ValueError("source packet status must be complete, partial, or failed") + if evidence_count < 0: + raise ValueError("evidence_count must be non-negative") + if isinstance(packet_body, PacketBody): + packet_body = packet_body.model_dump(exclude_none=True) + sources = sorted({ + self._canonicalize_local_raw_uri(uri.strip()) for uri in source_uris if uri.strip() + }) + normalized_source_contents = { + self._canonicalize_local_raw_uri(uri): content + for uri, content in (source_contents or {}).items() + } + if not sources: + raise ValueError("source_uris must not be empty") + kinds = [ + classify_raw_source_uri(uri, raw_root_uri=self._raw_fs.root_uri) for uri in sources + ] + is_chapters = all( + kind is RawSourceKind.MANUAL_CHAPTER and self._source_belongs_to_doc(uri, doc_id) + for uri, kind in zip(sources, kinds, strict=True) + ) + is_case_file = all( + kind is RawSourceKind.CASE and self._source_belongs_to_doc(uri, doc_id) + for uri, kind in zip(sources, kinds, strict=True) + ) + is_cross_raw = all( + kind is not None and not uri.startswith(self._raw_fs.root_uri + "/") + for uri, kind in zip(sources, kinds, strict=True) + ) + if not (is_chapters or is_case_file or is_cross_raw): + raise ValueError( + "source_uris must be raw chapter URIs, the case file for the selected doc_id, or an addressable cross-namespace Markdown raw resource URI", + ) + missing: list[str] = [] + source_hashes: dict[str, str] = {} + for uri in sources: + provided_content = normalized_source_contents.get(uri) + if provided_content: + source_hashes[uri] = sha256(provided_content.encode("utf-8")).hexdigest() + continue + content_hash = self._source_hash(uri, refresh=True) + if content_hash is not None: + source_hashes[uri] = content_hash + continue + missing.append(uri) + if missing: + raise ValueError( + f"Source packet contains unreadable or unavailable sources outside the current snapshot: {missing[:5]}", + ) + computed_hash = sha256( + "\n".join(f"{uri}\x1f{source_hashes[uri]}" for uri in sources).encode(), + ).hexdigest() + if source_hash and source_hash != computed_hash: + raise ValueError("source_hash does not match the current chapter contents") + extractor_config_hash = self._materialization_config_hash() + record: dict[str, object] = { + "packet_id": packet_id, + "doc_id": doc_id, + "source_uris": sources, + "source_hash": computed_hash, + "source_hashes": source_hashes, + "extractor_config_hash": extractor_config_hash, + "status": status, + "evidence_count": evidence_count, + } + normalized_build_id = build_id.strip() + if normalized_build_id: + record["build_id"] = normalized_build_id + checkpoint = self.store.read_json("index/build_checkpoint.json") + if not normalized_build_id and isinstance(checkpoint, dict): + checkpoint_build_id = str(checkpoint.get("build_id", "")).strip() + checkpoint_docs = { + str(value).strip() + for value in [ + checkpoint.get("doc_id"), + *( + checkpoint.get("input_docs", []) + if isinstance(checkpoint.get("input_docs"), list) + else [] + ), + ] + if str(value).strip() + } + if checkpoint_build_id and doc_id in checkpoint_docs: + record["build_id"] = checkpoint_build_id + if ledger_key and not re.fullmatch( + r"(?:member/source|source_packets)/[A-Za-z0-9_]+", + ledger_key, + ): + raise ValueError("legacy ledger_key must contain an ASCII packet slug") + packet_key = self._source_packet_key(packet_id) + packet_document = {**record, "packet": packet_body} + existing_packet = self.store.read_json(packet_key) + existing_build_id = ( + str(existing_packet.get("build_id", "")).strip() + if isinstance(existing_packet, dict) + else "" + ) + requested_build_id = str(record.get("build_id", "")).strip() + comparable_existing = dict(existing_packet) if isinstance(existing_packet, dict) else None + if comparable_existing is not None: + comparable_existing.pop("build_id", None) + comparable_packet = dict(packet_document) + comparable_packet.pop("build_id", None) + if ( + existing_packet is not None + and comparable_existing != comparable_packet + and str(existing_packet.get("source_hash", "")) == computed_hash + and str(existing_packet.get("extractor_config_hash", "")) == extractor_config_hash + and not ( + existing_build_id and requested_build_id and existing_build_id != requested_build_id + ) + and not allow_same_snapshot_replace + ): + raise ValueError( + f"Source packet has different content for an unchanged source snapshot: {packet_id}", + ) + if existing_build_id and requested_build_id and existing_build_id != requested_build_id: + # ``packet_id`` is the stable identity of a source unit, not of one + # execution. A later build of the same document must replace the + # latest packet in place after re-reading the source; otherwise + # incremental manuals can never advance past the first build. + packet_document["supersedes_build_id"] = existing_build_id + record["supersedes_build_id"] = existing_build_id + # A packet id is a stable business identity. Changed raw input + # replaces the old extraction result instead of creating an + # ever-growing family of version-suffixed packet ids. + # The worker completes its task immediately after this receipt. Do not + # report success until a separate planner read can observe the packet, + # otherwise the same chapter is dispatched again. + self.store.write_json(packet_key, packet_document, durable=True) + if self._log: + self._log.source_packet_recorded(packet_id, doc_id, len(sources)) + record["packet_uri"] = f"{self.store.root_uri}/{packet_key}" + return record + + @staticmethod + def _materialization_receipt_key(build_id: str, entity_uri: str) -> str: + """Return a bounded receipt path for one build/entity input.""" + build_slug = sha256(build_id.encode("utf-8")).hexdigest()[:16] + entity_slug = sha256(entity_uri.encode("utf-8")).hexdigest()[:24] + return f"index/materialization_receipts/{build_slug}/{entity_slug}.json" diff --git a/src/wolfharness/capabilities/wiki/entities/patches.py b/src/wolfharness/capabilities/wiki/entities/patches.py new file mode 100644 index 000000000..5eced5eff --- /dev/null +++ b/src/wolfharness/capabilities/wiki/entities/patches.py @@ -0,0 +1,709 @@ +"""Patch, diff, merge, move, and classification operations.""" + +from __future__ import annotations + +from difflib import unified_diff +from hashlib import sha256 +import logging +from pathlib import Path + +from wolfharness.capabilities.wiki.auto_repair import materialize_body_links +from wolfharness.capabilities.wiki.io.text_parsers import ( + _ENTITY_URI_HEADING_RE, + _parse_forward_links, +) +from wolfharness.capabilities.wiki.quality import ( + entity_status, + parse_frontmatter, +) +from wolfharness.capabilities.wiki.validation import ( + require_valid_entity, +) + + +logger = logging.getLogger(__name__) + +from wolfharness.capabilities.wiki._helpers import _FORMAL_WRITE_HOOKS, _entity_batch_limit + + +class PatchMixin: + """Patch, diff, merge, move, and classification operations.""" + + def patch_entity( + self, + concept: str, + class_name: str, + object_name: str, + operations: list[dict], + *, + expected_sha256: str = "", + reference_replacements: list[tuple[str, str]] | None = None, + ) -> str: + """Apply patch operations to an existing entity in-place. + + Supports three operation families: + + - **Line-based** (1-indexed, relative to entire file): + - ``{"op": "line_replace", "start": int, "end": int, "content": str}`` + - ``{"op": "line_insert", "at": int, "content": str}`` + - ``{"op": "line_delete", "start": int, "end": int}`` + + - **Section-based** (identified by ``## `` heading text): + - ``{"op": "section_replace", "heading": str, "content": str}`` + - ``{"op": "section_insert_after", "heading": str, "heading_new": str, "content": str}`` + + - **Frontmatter** (YAML fields between ``---`` markers): + - ``{"op": "fm_append", "field": str, "values": list[str]}`` + - ``{"op": "fm_set", "field": str, "value": str}`` + - ``{"op": "fm_set_list", "field": str, "values": list[str]}`` + + ``expected_sha256`` is the hash of the page read before preparing the + operations. It is mandatory for an existing page; a stale hash is + rejected so the caller must re-read and re-diff instead of overwriting + a concurrent update. + + Operations are applied sequentially. After all patches, the same + post-processing as ``write_entity`` runs (resolve_body_refs, + repair_frontmatter, dedup_citations). + + Returns the canonical ``{root_uri}/...`` URI. + """ + self._invalidate_audit_cache() + clz = class_name or None + content = self.store.read_entity(concept, clz, object_name) + if content is None: + raise FileNotFoundError( + f"Entity not found: {self.store.entity_uri(concept, clz, object_name)}", + ) + current_sha256 = sha256(content.encode("utf-8")).hexdigest() + if not expected_sha256: + raise ValueError( + "Existing entity patches require expected_sha256 from diff_entity/read_resource before patch_entity", + ) + if expected_sha256 != current_sha256: + raise ValueError( + f"Entity changed before patch; rerun diff_entity before patch (expected={expected_sha256}, actual={current_sha256}).", + ) + + # Capture original model prefix for cross-model contamination guard. + _orig_class_name = "" + if concept == "DTC": + _orig_cn = parse_frontmatter(content).get("class_name") + if isinstance(_orig_cn, str): + _orig_class_name = _orig_cn.strip() + + for op in operations: + op_type = op.get("op", "") + if op_type in ("line_replace", "line_insert", "line_delete"): + content = self._apply_line_op(content, op) + elif op_type in ("section_replace", "section_insert_after"): + content = self._apply_section_op(content, op) + elif op_type in ("fm_append", "fm_set", "fm_set_list"): + content = self._apply_fm_op(content, op) + else: + raise ValueError(f"Unknown patch op: {op_type!r}") + + # Post-process — same pipeline as write_entity. + uri = self.store.entity_uri(concept, clz, object_name) + content = _ENTITY_URI_HEADING_RE.sub(f"# {uri}\n", content, count=1) + content = self.store.repair_frontmatter(content, None) + content = self.store.resolve_body_refs(content, None) + content = materialize_body_links(content, concept, self) + self._reject_wrong_raw_refs(content) + self._reject_malformed_wiki_refs(content) + content = self.store.dedup_citations(content) + content = self._dedupe_h2_sections(content) + self._assert_expert_authority_preserved( + target_uri=uri, + current=self.store.read_entity(concept, clz, object_name) or "", + candidate=content, + reference_replacements=reference_replacements, + ) + if entity_status(content) == "confirmed": + if concept == "Symptom" and not self.list_symptom_profiles(uri): + raise ValueError( + "Symptom cannot become confirmed without at least one Profile", + ) + require_valid_entity( + content=content, + concept=concept, + class_name=class_name, + object_name=object_name, + hooks=_FORMAL_WRITE_HOOKS, + ) + else: + self._validate_formal_write( + content=content, + concept=concept, + class_name=class_name, + object_name=object_name, + ) + # Guard: block cross-model DTC patch — different model prefix means a + # different entity that must be created via write_entity, not merged. + if concept == "DTC" and _orig_class_name: + _new_cn = parse_frontmatter(content).get("class_name") + _new_class_name = _new_cn.strip() if isinstance(_new_cn, str) else "" + _orig_prefix = _orig_class_name.split("_", 1)[0] + _new_prefix = _new_class_name.split("_", 1)[0] + if _orig_prefix and _new_prefix and _orig_prefix != _new_prefix: + raise ValueError( + f"Cross-model DTC patch blocked: class_name model prefix " + f"changed {_orig_prefix!r} → {_new_prefix!r}. " + f"Use write_entity to create a separate DTC for {_new_prefix}.", + ) + if content == self.store.read_entity(concept, clz, object_name): + logger.info("Entity patch skipped (no change): %s", uri) + return uri + self.merge_entity( + concept, + class_name, + object_name, + content, + expected_sha256=current_sha256, + reference_replacements=reference_replacements, + ) + logger.info("Entity patched: %s (%d ops, %d chars)", uri, len(operations), len(content)) + if concept == "Component": + self._sync_component_narrative_links(uri) + elif concept in {"Fault", "Procedure"}: + relation_field = "affected_components" if concept == "Fault" else "target_components" + title = parse_frontmatter(content).get("title") + label = title.strip() if isinstance(title, str) and title.strip() else object_name + for component_uri in self._frontmatter_uri_values( + self.store.root_uri, parse_frontmatter(content), relation_field + ): + canonical_component_uri = component_uri.split("#", 1)[0] + if concept == "Fault": + self._sync_component_narrative_links( + canonical_component_uri, + fault_links=[(label, uri)], + ) + else: + self._sync_component_narrative_links( + canonical_component_uri, + procedure_links=[(label, uri)], + ) + return uri + + def patch_entities_batch( + self, + patches: list[dict[str, object]], + *, + sync_component_links: bool = True, + preloaded_contents: dict[str, str] | None = None, + ) -> dict[str, object]: + """Apply guarded patches to independent existing entities in one commit. + + Relation closure is the main caller: it already groups all relation + updates by source URI, so sending one remote write per page only adds + transport latency. Every item still carries its own optimistic hash; + the batch is prepared and validated before any page is committed. + """ + if not patches: + raise ValueError("patches must not be empty") + batch_limit = _entity_batch_limit() + if len(patches) > batch_limit: + raise ValueError(f"patch_entities_batch accepts at most {batch_limit} patches") + + prepared: list[tuple[str, str, str, str, str, int]] = [] + sync_targets: set[str] = set() + known_uris = {record[3] for record in self.store.list_entities()} + identities: set[tuple[str, str, str]] = set() + for index, item in enumerate(patches): + concept = item.get("concept") + class_name = item.get("class_name", "") + object_name = item.get("object_name") + operations = item.get("operations") + expected_sha256 = item.get("expected_sha256") + if not all( + isinstance(value, str) + for value in (concept, class_name, object_name, expected_sha256) + ): + raise TypeError(f"patches[{index}] identity/hash fields must be strings") + if not isinstance(operations, list) or any( + not isinstance(op, dict) for op in operations + ): + raise TypeError(f"patches[{index}].operations must be a list of objects") + assert isinstance(concept, str) + assert isinstance(class_name, str) + assert isinstance(object_name, str) + assert isinstance(expected_sha256, str) + identity = (concept, class_name, object_name) + if identity in identities: + raise ValueError(f"duplicate entity identity in batch: {identity}") + identities.add(identity) + uri = self.store.entity_uri(concept, class_name or None, object_name) + if preloaded_contents is not None and uri in preloaded_contents: + current = preloaded_contents[uri] + else: + current = self.store.read_entity(concept, class_name or None, object_name) + if current is None: + raise FileNotFoundError( + f"Entity not found: {uri}", + ) + current_sha256 = sha256(current.encode("utf-8")).hexdigest() + if not expected_sha256: + raise ValueError( + f"Existing batch entity requires expected_sha256: {identity}", + ) + if expected_sha256 != current_sha256: + raise ValueError(f"Batch entity changed since diff: {identity}") + + original_class_name = "" + if concept == "DTC": + original_class_value = parse_frontmatter(current).get("class_name") + if isinstance(original_class_value, str): + original_class_name = original_class_value.strip() + + content = self._apply_operations(current, operations) + uri = self.store.entity_uri(concept, class_name or None, object_name) + content = _ENTITY_URI_HEADING_RE.sub(f"# {uri}\n", content, count=1) + content = self.store.repair_frontmatter(content, None) + content = self.store.resolve_body_refs(content, None) + content = materialize_body_links(content, concept, self, known_uris=known_uris) + self._reject_wrong_raw_refs(content) + self._reject_malformed_wiki_refs(content) + content = self.store.dedup_citations(content) + content = self._dedupe_h2_sections(content) + self._assert_expert_authority_preserved( + target_uri=uri, + current=current, + candidate=content, + ) + if entity_status(content) == "confirmed": + if concept == "Symptom" and not self.list_symptom_profiles(uri): + raise ValueError( + "Symptom cannot become confirmed without at least one Profile", + ) + require_valid_entity( + content=content, + concept=concept, + class_name=class_name, + object_name=object_name, + hooks=_FORMAL_WRITE_HOOKS, + ) + else: + self._validate_formal_write( + content=content, + concept=concept, + class_name=class_name, + object_name=object_name, + ) + if concept == "DTC" and original_class_name: + new_class_value = parse_frontmatter(content).get("class_name") + new_class_name = new_class_value.strip() if isinstance(new_class_value, str) else "" + original_prefix = original_class_name.split("_", 1)[0] + new_prefix = new_class_name.split("_", 1)[0] + if original_prefix and new_prefix and original_prefix != new_prefix: + raise ValueError( + "Cross-model DTC patch blocked: class_name model prefix " + f"changed {original_prefix!r} → {new_prefix!r}. " + f"Use write_entity to create a separate DTC for {new_prefix}.", + ) + if content != current: + content = self._mark_merge_conflict(concept, uri, current, content) + frontmatter = parse_frontmatter(content) + if concept == "Component": + sync_targets.add(uri) + elif concept in {"Fault", "Procedure"}: + relation_field = ( + "affected_components" if concept == "Fault" else "target_components" + ) + sync_targets.update( + value.split("#", 1)[0] + for value in self._frontmatter_uri_values( + self.store.root_uri, + frontmatter, + relation_field, + ) + ) + prepared.append(( + concept, + class_name, + object_name, + content, + expected_sha256, + len(current), + )) + + self._invalidate_audit_cache() + if self._log: + for concept, class_name, object_name, _content, _expected, _before in prepared: + self._log.mutation_attempt( + self.store.entity_uri(concept, class_name or None, object_name), + "patch_entities_batch", + ) + with self._relation_sync_lock: + for concept, class_name, object_name, content, expected, _before in prepared: + latest = self.store.read_entity(concept, class_name or None, object_name) + latest_sha256 = sha256((latest or "").encode("utf-8")).hexdigest() + if latest is None or latest_sha256 != expected: + raise ValueError( + f"Batch entity changed before commit; rerun diff_entity before patch ({concept}/{class_name}/{object_name}).", + ) + self._assert_expert_authority_preserved( + target_uri=self.store.entity_uri(concept, class_name or None, object_name), + current=latest, + candidate=content, + ) + self.store.write_entities( + [ + (concept, class_name or None, object_name, content) + for concept, class_name, object_name, content, _expected, _before in prepared + ], + ) + uris: list[str] = [] + for concept, class_name, object_name, content, _expected, char_before in prepared: + uri = self.store.entity_uri(concept, class_name or None, object_name) + self.store.register_natural_key(concept, class_name or None, object_name, uri) + uris.append(uri) + if self._log: + self._log.mutation_applied(uri, "patch_entities_batch") + self._log.entity_updated( + uri, + concept, + object_name, + len(content), + char_before, + class_name=class_name or None, + reason="batch_patch", + ) + if sync_component_links: + for component_uri in sorted(sync_targets): + self._sync_component_narrative_links(component_uri, force=True) + return {"patched_count": len(uris), "uris": uris, "idempotent": True} + + def _apply_operations(self, content: str, operations: list[dict]) -> str: + """Apply the shared line/section/frontmatter patch vocabulary.""" + for op in operations: + op_type = op.get("op", "") + if op_type in ("line_replace", "line_insert", "line_delete"): + content = self._apply_line_op(content, op) + elif op_type in ("section_replace", "section_insert_after"): + content = self._apply_section_op(content, op) + elif op_type in ("fm_append", "fm_set", "fm_set_list"): + content = self._apply_fm_op(content, op) + else: + raise ValueError(f"Unknown patch op: {op_type!r}") + return content + + def diff_entity( + self, + concept: str, + class_name: str, + object_name: str, + candidate_content: str, + ) -> dict[str, object]: + """Return a deterministic diff and content hashes before a merge.""" + current = self.store.read_entity(concept, class_name or None, object_name) + current_hash = sha256((current or "").encode("utf-8")).hexdigest() + candidate_hash = sha256(candidate_content.encode("utf-8")).hexdigest() + diff = "".join( + unified_diff( + (current or "").splitlines(keepends=True), + candidate_content.splitlines(keepends=True), + fromfile="current", + tofile="candidate", + ), + ) + return { + "uri": self.store.entity_uri(concept, class_name or None, object_name), + "exists": current is not None, + "changed": current != candidate_content, + "current_sha256": current_hash, + "candidate_sha256": candidate_hash, + "diff": diff, + } + + def merge_entity( + self, + concept: str, + class_name: str, + object_name: str, + content: str, + *, + expected_sha256: str = "", + conflict_policy: str = "detect", + skip_materialization: bool = False, + reference_replacements: list[tuple[str, str]] | None = None, + ) -> str: + """Materialize an explicit candidate with an optimistic-lock check.""" + current = self.store.read_entity(concept, class_name or None, object_name) + current_hash = sha256((current or "").encode("utf-8")).hexdigest() + if expected_sha256 and expected_sha256 != current_hash: + raise ValueError( + f"Entity changed since diff; rerun diff_entity before merge (expected={expected_sha256}, actual={current_hash}).", + ) + if current is not None and not expected_sha256: + raise ValueError( + "Existing entity merges require expected_sha256 from diff_entity", + ) + return self.write_entity( + concept, + class_name, + object_name, + content, + expected_sha256=expected_sha256, + conflict_policy=conflict_policy, + skip_materialization=skip_materialization, + reference_replacements=reference_replacements, + ) + + def delete_entity(self, concept: str, class_name: str, object_name: str) -> bool: + """Delete an entity and its persistent identity-index entries.""" + self._invalidate_audit_cache() + clz = class_name or None + uri = self.store.lookup_natural_key(concept, clz, object_name) + if uri is not None: + self.rebuild_all_backlinks() + backlinks = [source for source in self.get_backlinks(uri) if source != uri] + if backlinks: + raise ValueError( + f"Delete blocked: entity is still referenced by {len(backlinks)} page(s). Repair those references first: {backlinks[:5]}", + ) + deleted = False + path = self.store.entity_path(concept, clz, object_name) + key = self.store._key_of(path) + if self.store.exists(key): + if concept in self.store.DIRECTORY_CONCEPTS: + prefix = self.store._key_of(path.parent) + for child_key in self.store.list_dir(prefix, recursive=True): + self.store.delete(child_key) + if self.store.exists(key): + raise OSError(f"Failed to remove entity directory: {path.parent}") + logger.info("Entity deleted (directory): %s", path.parent) + else: + self.store.delete(key) + logger.info("Entity deleted: %s", path) + deleted = True + legacy = self.store.legacy_entity_path(concept, clz, object_name) + if not deleted and legacy is not None and self.store.exists(self.store._key_of(legacy)): + self.store.delete(self.store._key_of(legacy)) + logger.info("Entity deleted (legacy): %s", legacy) + deleted = True + if uri is not None: + self.store.unregister_uri(uri) + self.rebuild_all_backlinks() + deleted = True + if deleted: + self.store._invalidate_entity_content_cache() + return deleted + + def move_entity( + self, + src_concept: str, + src_class: str, + src_name: str, + dst_concept: str, + dst_class: str, + dst_name: str, + ) -> str: + """Move an entity file to a new location and update the natural key. + + For DIRECTORY_CONCEPTS (Symptom), moves the entire entity directory + (including profile/). Falls back to legacy .md path if the new + index.md path does not exist. + + Returns the new ``{root_uri}/...`` URI. + """ + self._invalidate_audit_cache() + old_uri = self.store.entity_uri(src_concept, src_class or None, src_name) + src_path = self.store.entity_path(src_concept, src_class or None, src_name) + if not self.store.exists(self.store._key_of(src_path)): + legacy = self.store.legacy_entity_path(src_concept, src_class or None, src_name) + if legacy is not None and self.store.exists(self.store._key_of(legacy)): + src_path = legacy + else: + raise FileNotFoundError(f"Source entity not found: {src_path}") + dst_path = self.store.entity_path(dst_concept, dst_class or None, dst_name) + if self.store.read_entity(dst_concept, dst_class or None, dst_name) is not None: + raise FileExistsError(f"Destination entity already exists: {dst_path}") + src_key = self.store._key_of(src_path) + dst_key = self.store._key_of(dst_path) + if src_concept in self.store.DIRECTORY_CONCEPTS: + src_dir = src_path.parent + dst_dir = dst_path.parent + if src_dir == dst_dir: + return self.store.entity_uri(dst_concept, dst_class or None, dst_name) + # Move every .md under the directory concept's folder. + src_prefix = self.store._key_of(src_dir) + dst_prefix = self.store._key_of(dst_dir) + moved_any = False + for key in self.store.list_dir(src_prefix, recursive=True): + self.store.move(key, f"{dst_prefix}/{Path(key).name}") + moved_any = True + if not moved_any: + raise FileNotFoundError(f"Source entity not found: {src_path}") + else: + self.store.move(src_key, dst_key) + src_parent_key = "/".join(src_key.split("/")[:-1]) + if src_parent_key != "/".join(dst_key.split("/")[:-1]): + self.store.remove_empty_dir(src_parent_key) + new_uri = self.store.entity_uri(dst_concept, dst_class or None, dst_name) + self.store.unregister_uri(old_uri) + self.store.register_natural_key(dst_concept, dst_class or None, dst_name, new_uri) + self.store.register_redirect(old_uri, new_uri) + affected = self._rewrite_persisted_uri(old_uri, new_uri) + # ponytail: re-sync OpenViking native edges only for the pages whose + # content changed, via SDK link/unlink. No full-library remote JSON + # backlink rebuild — a move touches a handful of referrers. + pairs = [ + (uri, [u for u in _parse_forward_links(self.read_resource(uri) or "") if u != uri]) + for uri in affected + ] + self.store.sync_native_relations(pairs) + if self._log: + self._log.entity_moved(src_key, dst_key, new_uri) + logger.info("Entity moved: %s → %s (%s)", src_path, dst_path, new_uri) + return new_uri + + def plan_entity_move( + self, + src_concept: str, + src_class_name: str, + src_object_name: str, + dst_class_name: str, + dst_object_name: str = "", + ) -> dict[str, object]: + """Read-only preview of a storage-side entity move (no writes). + + Mirrors ``move_entity`` semantics so an expert can review the + before/after layout — entity paths and URIs, directory creation + and pruning, redirect registration, affected backlinks — before + confirming an OPS that carries ``candidate_operations`` with + ``{"op": "move_entity", ...}``. + """ + if src_concept not in self.store.CONCEPT_DIRS: + raise ValueError(f"Unknown Wiki concept: {src_concept}") + dst = dst_object_name.strip() or src_object_name + old_uri = self.store.entity_uri(src_concept, src_class_name or None, src_object_name) + src_path = self.store.entity_path(src_concept, src_class_name or None, src_object_name) + if not self.store.exists(self.store._key_of(src_path)): + legacy = self.store.legacy_entity_path( + src_concept, src_class_name or None, src_object_name + ) + if legacy is not None and self.store.exists(self.store._key_of(legacy)): + src_path = legacy + else: + return { + "action": "error", + "reason": f"Source entity not found: {old_uri}", + "source": {"uri": old_uri, "path": str(src_path)}, + } + new_uri = self.store.entity_uri(src_concept, dst_class_name or None, dst) + dst_path = self.store.entity_path(src_concept, dst_class_name or None, dst) + same_dir = src_path.parent == dst_path.parent + dst_exists = same_dir or self.store.read_entity_by_uri(new_uri) is not None + # ponytail: preview must be cheap — read the persisted backlink index + # (one file read) instead of scanning the whole library. + affected_backlinks = self.store.get_backlinks(old_uri) + return { + "action": "noop" if same_dir and dst == src_object_name else "move", + "reason": "destination is the current location" + if same_dir and dst == src_object_name + else ( + "destination identity already exists; plan a guarded merge instead" + if dst_exists and not same_dir + else "storage relocation is safe to apply" + ), + "directory_concept": src_concept in self.store.DIRECTORY_CONCEPTS, + "source": { + "uri": old_uri, + "path": str(src_path), + "class_name": src_class_name, + "object_name": src_object_name, + }, + "destination": { + "uri": new_uri, + "path": str(dst_path), + "class_name": dst_class_name, + "object_name": dst, + }, + "directory": { + "src_dir": str(src_path.parent), + "dst_dir": str(dst_path.parent), + "same_dir": same_dir, + "dst_dir_created_by_execute": not dst_path.parent.is_dir() + if hasattr(dst_path.parent, "is_dir") + else True, + "src_dir_pruned_after_move": not same_dir, + }, + "redirect": {"old_uri": old_uri, "new_uri": new_uri} if not same_dir else {}, + "affected_backlinks": affected_backlinks, + "rollback": {"source_uri": new_uri, "target_uri": old_uri}, + "execute_required": bool(not same_dir or dst != src_object_name), + } + + def plan_component_classification( + self, + component_uri: str, + candidate_class_name: str, + evidence_uris: list[str] | None = None, + ) -> dict[str, object]: + """Return a deterministic BOM-backed class move plan without writing. + + The planner only classifies a nested path change. A cross-branch + change is deliberately ``ambiguous`` and must become an OPA before a + caller may invoke ``move_entity``. + """ + identity = self.store.lookup_by_uri(component_uri) + if identity is None or identity[0] != "Component": + raise ValueError(f"Unknown Component URI: {component_uri}") + _concept, current_class, object_name = identity + current_class_name = current_class or "" + candidate = self.store.normalize_class_name("Component", candidate_class_name.strip()) or "" + if not candidate: + raise ValueError("candidate_class_name must not be empty") + evidence = list(dict.fromkeys(uri.strip() for uri in (evidence_uris or []) if uri.strip())) + if any(not uri.startswith(self._raw_fs.root_uri + "/") for uri in evidence): + raise ValueError("evidence_uris must contain raw chapter URIs") + bom_record = self.get_bom_taxonomy().get(component_uri) + bom_path = str(bom_record.get("bom_path", "")) if bom_record is not None else "" + + if candidate == current_class_name: + action = "noop" + reason = "candidate class is already canonical" + elif candidate.startswith(current_class_name + "/"): + action = "move_down" + reason = "candidate is a deeper BOM assembly path" + elif current_class_name.startswith(candidate + "/"): + action = "move_up" + reason = "candidate is an ancestor BOM assembly path" + else: + action = "ambiguous" + reason = ( + "candidate crosses a BOM branch; require OPA or explicit classification decision" + ) + + target_uri = self.store.entity_uri("Component", candidate, object_name) + destination = self.store.entity_path("Component", candidate, object_name) + destination_exists = self.store.read_entity_by_uri(target_uri) is not None + if action != "noop" and not evidence: + action = "ambiguous" + reason = "non-noop classification requires at least one BOM/raw evidence URI" + if bom_path and candidate != bom_path and not candidate.startswith(bom_path + "/"): + action = "ambiguous" + reason = "candidate is outside the registered global BOM branch; require OPA" + if destination_exists and target_uri != component_uri: + action = "ambiguous" + reason = "destination identity already exists; plan a guarded merge instead of a move" + + # ponytail: preview must be cheap — read the persisted backlink index. + affected_backlinks = self.store.get_backlinks(component_uri) + return { + "action": action, + "reason": reason, + "component_uri": component_uri, + "target_uri": target_uri, + "current_class_name": current_class_name, + "candidate_class_name": candidate, + "object_name": object_name, + "destination_exists": destination_exists, + "directory_created_by_execute": not destination.parent.is_dir(), + "evidence_uris": evidence, + "bom_record": bom_record or {}, + "bom_path": bom_path, + "affected_backlinks": affected_backlinks, + "rollback": {"source_uri": target_uri, "target_uri": component_uri}, + "execute_required": action != "noop", + } diff --git a/src/wolfharness/capabilities/wiki/harness/__init__.py b/src/wolfharness/capabilities/wiki/harness/__init__.py new file mode 100644 index 000000000..0ad8cfaad --- /dev/null +++ b/src/wolfharness/capabilities/wiki/harness/__init__.py @@ -0,0 +1,19 @@ +"""Wiki team orchestration capabilities — event handlers, guards, and compaction.""" + +from .entity_write_validation import EntityWriteValidationCapability +from .event_handlers import per_session_file_handler +from .ghost_tool_guard import GhostToolCallGuardCapability +from .message_count_compaction import MessageCountCompactionCapability +from .op_flow_guard import OPFlowGuardCapability +from .team_wake import TeamWakeCapability +from .team_workflow_guard import TeamWorkflowGuardCapability + +__all__ = [ + "EntityWriteValidationCapability", + "GhostToolCallGuardCapability", + "MessageCountCompactionCapability", + "OPFlowGuardCapability", + "TeamWakeCapability", + "TeamWorkflowGuardCapability", + "per_session_file_handler", +] diff --git a/src/wolfharness/capabilities/wiki/harness/entity_write_validation.py b/src/wolfharness/capabilities/wiki/harness/entity_write_validation.py new file mode 100644 index 000000000..d1241a6c1 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/harness/entity_write_validation.py @@ -0,0 +1,334 @@ +"""EntityWriteValidationCapability — pre-write content validation hooks. + +Splits the content-validation responsibility out of the monolithic +``WikiHarnessCapability``: this capability wraps entity write tools, +auto-cleans ``open_gap`` placeholders, runs the deterministic hook chain, and +raises ``ModelRetry`` before the filesystem handler when structural errors +are found. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +import tempfile +from typing import TYPE_CHECKING, Any + +from pydantic_ai.capabilities import AbstractCapability +from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.tools import AgentDepsT + +from wolfharness.capabilities.agent_context import AgentContextDeps, resolve_agent_context_from_deps +from wolfharness.capabilities.file_team_state import FileTeamState +from wolfharness.capabilities.wiki.auto_repair import clean_open_gap +from wolfharness.capabilities.wiki.quality import parse_frontmatter +from wolfharness.capabilities.wiki.validation import ( + ENTITY_VALIDATION_HOOKS, + FORMAL_WRITE_EXCLUDED_HOOKS, + run_entity_validation, + validation_feedback, +) + + +if TYPE_CHECKING: + from pydantic_ai.capabilities.abstract import WrapToolExecuteHandler + from pydantic_ai.messages import ToolCallPart + from pydantic_ai.tools import RunContext, ToolDefinition + +_TOOLS_THAT_TRIGGER_HOOKS: frozenset[str] = frozenset( + { + "write_entity", + "write_entities_batch", + "write_symptom_profile", + }, +) + +# Tools where we auto-clean open_gap placeholders before the write hits disk. +# write tools receive full ``content``; patch tools receive ``operations`` +# (not full content) so cleaning is deferred to the batch tool. +_TOOLS_THAT_AUTO_CLEAN: frozenset[str] = frozenset( + { + "write_entity", + "write_entities_batch", + "write_symptom_profile", + }, +) + +_TASK_SCOPED_MUTATION_TOOLS: frozenset[str] = frozenset( + { + "write_entity", + "write_entities_batch", + "write_symptom_profile", + "patch_entity", + "patch_entities_batch", + "patch_symptom_profile", + "merge_entity", + "delete_entity", + "move_entity", + "create_subdir", + "register_bom_component", + "register_bom_identity_batch", + "register_no_entity_chapters", + "record_source_packet", + "create_opa", + "create_ops", + "update_ops", + "apply_ops", + "ingest_external_ops", + "create_opl", + "apply_opl", + "resolve_opa", + "refine_opa_reason_code", + "apply_opa", + }, +) + + +class EntityWriteValidationCapability(AbstractCapability[AgentDepsT]): + """Run validation hooks before entity writes and append warning feedback. + + A validation error raises ``ModelRetry`` before ``handler`` is called, + guaranteeing that a rejected write has not touched the filesystem. + """ + + def get_toolset(self) -> None: + """Return ``None`` — this capability provides no tools.""" + return + + def get_instructions(self) -> str | None: + """Return ``None`` — instructions come from the agent's system prompt.""" + return None + + @staticmethod + def _wiki_tool_name(tool_name: str) -> str | None: + if tool_name in _TOOLS_THAT_TRIGGER_HOOKS: + return tool_name + matches = [ + candidate + for candidate in _TOOLS_THAT_TRIGGER_HOOKS + if tool_name.endswith(f"_{candidate}") + ] + return max(matches, key=len) if matches else None + + async def wrap_tool_execute( + self, + ctx: RunContext[AgentDepsT], + *, + call: ToolCallPart, + tool_def: ToolDefinition, + args: Any, + handler: WrapToolExecuteHandler, + ) -> Any: + """Validate entity content before the write handler runs.""" + agent_ctx = resolve_agent_context_from_deps( + ctx.deps, + capability_name="EntityWriteValidationCapability", + ) + task_feedback = self.validate_task_scope(call.tool_name, agent_ctx) + if task_feedback: + raise ModelRetry(task_feedback) + tool_name = self._wiki_tool_name(call.tool_name) + + # Auto-clean open_gap in content before validation and write. + if tool_name in _TOOLS_THAT_AUTO_CLEAN and isinstance(args, dict): + content = args.get("content", "") + if isinstance(content, str) and content: + cleaned = clean_open_gap(content) + if cleaned != content: + args = {**args, "content": cleaned} + + if tool_name not in _TOOLS_THAT_TRIGGER_HOOKS: + return await handler(args) + + if tool_name == "write_entities_batch" and isinstance(args, dict): + items = args.get("entities", []) + if not isinstance(items, list): + raise ModelRetry("Entity batch REJECTED: `entities` must be a list.") + feedback_parts: list[str] = [] + has_batch_errors = False + cleaned_items: list[object] = [] + for index, item in enumerate(items): + if not isinstance(item, dict): + raise ModelRetry(f"Entity batch REJECTED: entities[{index}] must be an object.") + cleaned_item = item + content = item.get("content", "") + if isinstance(content, str): + cleaned = clean_open_gap(content) + if cleaned != content: + cleaned_item = {**item, "content": cleaned} + feedback, has_errors = self._run_hooks("write_entity", cleaned_item) + if feedback: + feedback_parts.append(f"entities[{index}]:\n{feedback}") + has_batch_errors = has_batch_errors or has_errors + cleaned_items.append(cleaned_item) + if has_batch_errors: + raise ModelRetry( + "Entity batch REJECTED by validation hooks:\n\n" + "\n\n".join(feedback_parts), + ) + result = await handler({**args, "entities": cleaned_items}) + if feedback_parts: + return f"{result!s}\n\n" + "\n\n".join(feedback_parts) + return result + + hook_feedback, has_errors = self._run_hooks(tool_name, args) + if has_errors: + raise ModelRetry( + f"Entity write REJECTED by validation hooks:\n\n{hook_feedback}\n\nPlease fix the issues above and retry the write.", + ) + + result = await handler(args) + if not hook_feedback: + return result + return f"{result!s}\n\n{hook_feedback}" + + @staticmethod + def validate_task_scope(tool_name: str, agent_ctx: AgentContextDeps) -> str: + """Require team members to own active work before mutating the Wiki.""" + canonical = next( + ( + candidate + for candidate in _TASK_SCOPED_MUTATION_TOOLS + if tool_name == candidate or tool_name.endswith(f"_{candidate}") + ), + None, + ) + metadata = agent_ctx.session.metadata + if canonical is None or metadata.get("team_role") != "member": + return "" + team_id = str(metadata.get("team_id", "")) + member_name = str(metadata.get("team_member_name", "")) + base_dir = str(metadata.get("team_base_dir", "")) + if not base_dir and agent_ctx.team_mode_config is not None: + base_dir = agent_ctx.team_mode_config.effective_base_dir + if not base_dir: + # Match AgentPool's FileTeamState runtime fallback. Older spawned + # sessions did not persist team_base_dir even though their shared + # task board was correctly rooted in the process temp directory. + base_dir = tempfile.gettempdir() + if not team_id or not member_name: + return "Wiki mutation REJECTED: member task identity is incomplete." + tasks = FileTeamState(base_dir).list_tasks(team_id) + active = [ + task + for task in tasks + if task.get("owner") == member_name and task.get("status") == "in_progress" + ] + if active: + lease_tokens: dict[str, str] = metadata.setdefault("_task_lease_tokens", {}) + lease_seconds = int(getattr(agent_ctx.team_mode_config, "lease_ttl_seconds", 300)) + for task in active: + task_id = str(task.get("task_id", "")) + lease_token = str(task.get("lease_token", "")) + lease_expires_at = str(task.get("lease_expires_at", "")) + if not lease_token: + continue # Backward-compatible read of pre-lease task files. + held_token = str(lease_tokens.get(task_id, "")) + if held_token and held_token != lease_token: + return "Wiki mutation REJECTED: task lease was replaced; do not write with a stale worker session." + try: + expires = datetime.fromisoformat(lease_expires_at) + except ValueError: + return "Wiki mutation REJECTED: task lease metadata is invalid." + if expires.tzinfo is None: + expires = expires.replace(tzinfo=UTC) + if expires <= datetime.now(UTC): + try: + reclaimed = FileTeamState(base_dir).update_task( + team_id, + task_id, + {"status": "in_progress"}, + expected_lease_token=held_token or lease_token, + lease_owner=member_name, + claim=True, + lease_seconds=lease_seconds, + ) + except (FileNotFoundError, OSError, ValueError): + return "Wiki mutation REJECTED: task lease expired or was replaced; reclaim the task before writing." + refreshed_token = str(reclaimed.get("lease_token", "")) + if not refreshed_token: + return "Wiki mutation REJECTED: task lease renewal did not return a token." + lease_tokens[task_id] = refreshed_token + return "" + return ( + "Wiki mutation REJECTED: team member " + f"{member_name!r} must own an in_progress task before calling {canonical}. " + "Call task_list(mine_only=True), then task_update the exact assigned task to in_progress." + ) + + def _run_hooks(self, tool_name: str, args: Any) -> tuple[str, bool]: + """Run all validation hooks against entity content from tool args. + + Returns ``(feedback_text, has_errors)`` where ``has_errors`` + indicates whether any hook returned severity ``"error"``. + """ + if not isinstance(args, dict): + return "", False + + content = args.get("content", "") + if not content or not isinstance(content, str): + return "", False + + concept = args.get("concept", "") + class_name = args.get("class_name", "") + object_name = args.get( + "object_name", + args.get("name", args.get("symptom_name", "")), + ) + # Workers commonly put class_name/object_name in the content frontmatter + # rather than as explicit tool args. Fall back to the frontmatter so a + # structurally complete write isn't falsely rejected for "no class_name". + fm = parse_frontmatter(content) + if not class_name and isinstance(fm.get("class_name"), str): + class_name = fm["class_name"] + if not object_name and isinstance(fm.get("object_name"), str): + object_name = fm["object_name"] + # Tool args may carry non-str extras (Pydantic ``Unknown``); coerce to + # strings so the validation chain always receives plain strings. + class_name = str(class_name) if class_name else "" + object_name = str(object_name) if object_name else "" + + results = run_entity_validation( + content=content, + concept=concept, + class_name=class_name, + object_name=object_name, + ) + feedback, has_errors = validation_feedback(results) + + # For confirmed/machine-validated content, re-run with the formal + # hook subset (excluding hooks that are not in the formal write gate) + # so the capability layer matches the MCP server's _FORMAL_WRITE_HOOKS. + # For non-confirmed/draft content, downgrade errors to warnings so + # drafts can land on disk and be fixed in later passes. The finalize + # audit (not promotion-time re-validation) is the strict backstop. + is_downgraded = False + if has_errors: + status = str(fm.get("status", "")).strip() + pub_state = str(fm.get("publication_state", "")).strip() + val_state = str(fm.get("validation_state", "")).strip() + is_formal = ( + status in {"confirmed", "deprecated"} + or pub_state == "published" + or val_state == "machine_validated" + ) + if is_formal: + formal_hooks = tuple( + h for h in ENTITY_VALIDATION_HOOKS if h.name not in FORMAL_WRITE_EXCLUDED_HOOKS + ) + results = run_entity_validation( + content=content, + concept=concept, + class_name=class_name, + object_name=object_name, + hooks=formal_hooks, + ) + feedback, has_errors = validation_feedback(results) + else: + has_errors = False + is_downgraded = True + + if is_downgraded and "❌" in feedback: + feedback = feedback.replace("❌", "⚠️").replace( + "(write blocked)", "(draft — non-blocking)" + ) + + return feedback, has_errors diff --git a/src/wolfharness/capabilities/wiki/harness/event_handlers.py b/src/wolfharness/capabilities/wiki/harness/event_handlers.py new file mode 100644 index 000000000..0013662ab --- /dev/null +++ b/src/wolfharness/capabilities/wiki/harness/event_handlers.py @@ -0,0 +1,270 @@ +"""Per-session file logging event handler for wiki build team agents. + +Each agent instance (identified by session_id) gets its own log file, +ensuring that parallel extraction_worker instances don't interleave output. + +File naming: ./logs/{agent_name}_{session_id[:8]}.log + +Ported from xeno_nmt_harness.event_handlers with wiki-specific adaptations: +- Tool result truncation limits tuned for wiki-build capability tools +- Handles wiki team tool names (write_entity, read_resource, etc.) +""" + +from __future__ import annotations + +import datetime +import math +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from pydantic_ai import ( + PartDeltaEvent, + PartStartEvent, + TextPart, + TextPartDelta, + ThinkingPart, + ThinkingPartDelta, +) + +from wolfharness.agents.events import ( + CompactionEvent, + RunErrorEvent, + RunFailedEvent, + RunStartedEvent, + SpawnSessionStart, + StreamCompleteEvent, + ToolCallCompleteEvent, + ToolCallStartEvent, + UserMessageInsertedEvent, +) + + +if TYPE_CHECKING: + from wolfharness.agents.context import AgentContext + from wolfharness.agents.events import RichAgentStreamEvent + +LOG_DIR = Path("./logs") + +# Module-level file handle cache: "{agent_name}_{session_id}" -> file handle +_file_handles: dict[str, Any] = {} + +# Thinking buffer: "{agent_name}_{session_id}" -> accumulated thinking text. +# Thinking arrives as many small ThinkingPartDelta fragments; we buffer them +# and flush as a single [thinking] block when text output begins or the turn +# completes, producing readable consolidated thinking sections. +_thinking_buffers: dict[str, str] = {} + +# Maximum characters of a tool result to write to the log file. Longer +# results are truncated with a notice showing the original length. +_MAX_TOOL_RESULT_CHARS = 2000 + +# Per-tool overrides for the truncation limit. Tools listed here use the +# specified limit instead of _MAX_TOOL_RESULT_CHARS. Use math.inf for no +# truncation. +_TOOL_RESULT_LIMITS: dict[str, int | float] = { + "read_resource": math.inf, + "get_resource": math.inf, + "read_blackboard": math.inf, + "list_children": math.inf, + "read_chapter": math.inf, + "get_backlinks": math.inf, +} + + +def _timestamp() -> str: + return datetime.datetime.now(datetime.UTC).astimezone().strftime("%H:%M:%S.%f")[:-3] + + +def _get_file_handle(agent_name: str, session_id: str) -> Any: + key = f"{agent_name}_{session_id}" + if key not in _file_handles: + LOG_DIR.mkdir(parents=True, exist_ok=True) + file_path = LOG_DIR / f"{agent_name}_{session_id}.log" + _file_handles[key] = file_path.open("a", encoding="utf-8") + return _file_handles[key] + + +def _close_file_handle(agent_name: str, session_id: str) -> None: + key = f"{agent_name}_{session_id}" + handle = _file_handles.pop(key, None) + if handle is not None: + handle.close() + _thinking_buffers.pop(key, None) + + +def _flush_thinking(agent_name: str, session_id: str, file_handle: Any) -> None: + """Flush accumulated thinking content to the log file as a single block.""" + key = f"{agent_name}_{session_id}" + buffered = _thinking_buffers.pop(key, "") + if buffered: + file_handle.write(f"\n[{_timestamp()}] [thinking] {buffered}\n") + file_handle.flush() + + +def _content_to_str(content: str | list[Any]) -> str: + if isinstance(content, str): + return content + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + text = item.get("text") or item.get("content") or "" + if text: + parts.append(str(text)) + return " ".join(parts) + + +def _truncate(content: Any, max_chars: float = _MAX_TOOL_RESULT_CHARS) -> str: + """Truncate content to *max_chars*, appending a notice if truncated. + + ``max_chars`` accepts ``float('inf')`` to disable truncation entirely. + """ + text = str(content) + if math.isinf(max_chars) or len(text) <= max_chars: + return text + return f"{text[: int(max_chars)]}\n... (truncated, {len(text)} chars total)" + + +async def per_session_file_handler( + ctx: AgentContext[Any], + event: RichAgentStreamEvent[Any], +) -> None: + """Event handler that writes agent output to per-session log files. + + Each agent session gets its own log file, so parallel instances + (e.g., multiple extraction_worker instances processing different chapters) + produce isolated logs without interleaving. + + Log file path: ./logs/{agent_name}_{session_id[:8]}.log + + Records: + - Run start markers with run_id and agent name + - Model text output (streaming) + - Thinking content (buffered, flushed as a single block) + - Tool calls and results (prefixed with [tool] / [result]) + - Team member spawns (SpawnSessionStart) + - Team messages and user messages (UserMessageInsertedEvent) + - Errors (prefixed with [error]) + - Turn-complete separators + """ + agent_name = ctx.node_name + session_id = "" + if ctx.run_ctx is not None: + session_id = ctx.run_ctx.session_id + + # Fallback: try to get session_id from the event + if not session_id: + session_id = getattr(event, "session_id", "") or "" + + if not session_id: + return + + file_handle = _get_file_handle(agent_name, session_id) + + match event: + case RunStartedEvent(run_id=run_id, agent_name=ev_agent_name): + file_handle.write( + f"\n{'=' * 70}\n[{_timestamp()}] [run-start] run_id={run_id} agent={ev_agent_name or agent_name}\n{'=' * 70}\n", + ) + file_handle.flush() + + case ( + PartStartEvent(part=TextPart(content=delta)) + | PartDeltaEvent(delta=TextPartDelta(content_delta=delta)) + ): + # Flush any pending thinking before writing text output + _flush_thinking(agent_name, session_id, file_handle) + if delta: + file_handle.write(delta) + file_handle.flush() + + case ( + PartStartEvent(part=ThinkingPart(content=delta)) + | PartDeltaEvent(delta=ThinkingPartDelta(content_delta=delta)) + ): + # Buffer thinking fragments instead of writing each one separately + if delta: + key = f"{agent_name}_{session_id}" + _thinking_buffers[key] = _thinking_buffers.get(key, "") + delta + + case ToolCallStartEvent() as ev: + # Flush any pending thinking before writing tool call + _flush_thinking(agent_name, session_id, file_handle) + if ev.tool_name == "send_message": + to = ev.raw_input.get("to", "?") + msg_type = ev.raw_input.get("type", "private") + content = ev.raw_input.get("content", "") + file_handle.write( + f"\n[{_timestamp()}] [team-message-sent] to={to} type={msg_type}: {content}\n", + ) + else: + kwargs_str = ", ".join(f"{k}={v!r}" for k, v in ev.raw_input.items()) + file_handle.write( + f"\n[{_timestamp()}] [tool] {ev.tool_name}({kwargs_str})\n", + ) + file_handle.flush() + + case ToolCallCompleteEvent() as ev: + # If start event had empty raw_input (streaming timing issue), + # log the complete tool_input here so params are visible. + if ev.tool_input: + kwargs_str = ", ".join(f"{k}={v!r}" for k, v in ev.tool_input.items()) + file_handle.write( + f"[{_timestamp()}] [tool-args] {ev.tool_name}({kwargs_str})\n", + ) + limit = _TOOL_RESULT_LIMITS.get(ev.tool_name, _MAX_TOOL_RESULT_CHARS) + truncated = _truncate(ev.tool_result, max_chars=limit) + file_handle.write(f"[{_timestamp()}] [result] {truncated}\n") + file_handle.flush() + + case RunErrorEvent(message=message): + _flush_thinking(agent_name, session_id, file_handle) + file_handle.write(f"\n[{_timestamp()}] [error] {message}\n") + file_handle.flush() + + case SpawnSessionStart( + child_session_id=child_sid, + source_name=source, + description=desc, + ): + short_child = child_sid[:8] if len(child_sid) >= 8 else child_sid + file_handle.write( + f"\n[{_timestamp()}] [spawn] {source} -> {short_child}: {desc}\n", + ) + file_handle.flush() + + case UserMessageInsertedEvent(content=content, delivery=delivery) as ev: + text = _content_to_str(content) + if "<team-message" in text: + file_handle.write( + f"\n[{_timestamp()}] [team-message] delivery={delivery}:\n{text}\n", + ) + else: + file_handle.write( + f"\n[{_timestamp()}] [user-message] delivery={delivery}:\n{text}\n", + ) + file_handle.flush() + + case StreamCompleteEvent(): + _flush_thinking(agent_name, session_id, file_handle) + file_handle.write( + f"\n{'\u2500' * 70}\n[{_timestamp()}] [turn-complete]\n{'\u2500' * 70}\n", + ) + file_handle.flush() + _close_file_handle(agent_name, session_id) + + case RunFailedEvent(exception=exc): + _flush_thinking(agent_name, session_id, file_handle) + file_handle.write( + f"\n[{_timestamp()}] [run-failed] {type(exc).__name__}: {exc}\n", + ) + file_handle.flush() + _close_file_handle(agent_name, session_id) + + case CompactionEvent(trigger=trig, phase=phase): + _flush_thinking(agent_name, session_id, file_handle) + file_handle.write( + f"\n[{_timestamp()}] [compaction] trigger={trig} phase={phase}\n", + ) + file_handle.flush() diff --git a/src/wolfharness/capabilities/wiki/harness/ghost_tool_guard.py b/src/wolfharness/capabilities/wiki/harness/ghost_tool_guard.py new file mode 100644 index 000000000..949b8fbec --- /dev/null +++ b/src/wolfharness/capabilities/wiki/harness/ghost_tool_guard.py @@ -0,0 +1,184 @@ +"""Reject textual tool calls and degenerate model reasoning loops. + +When a model fails to make a proper structured tool call and instead +emits the call as text (e.g. ``<invoke name="...">`` or +``task_create(...)``), this capability raises ``ModelRetry`` in +``after_model_request`` so the model gets another chance to call the +tool correctly. The original (ghost) response is preserved in message +history so the model can see what it did wrong. + +This prevents the "ghost tool call stall" where a turn ends with only +text output, no real tool call fires, and the pipeline silently deadlocks +because nothing triggers the next turn. +""" + +from __future__ import annotations + +from collections import Counter +import re +from typing import TYPE_CHECKING + +from pydantic_ai.capabilities import AbstractCapability +from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.messages import ModelResponse, TextPart, ThinkingPart, ToolCallPart +from pydantic_ai.tools import AgentDepsT + + +if TYPE_CHECKING: + from collections.abc import Iterable + + from pydantic_ai.models import ModelRequestContext + from pydantic_ai.tools import RunContext + +# XML-style patterns that indicate a ghost tool call. These are the +# tags that models commonly emit when they fail to use the structured +# tool-calling interface and instead write the call as text. +_GHOST_TOOL_RE = re.compile( + r"<(?:invoke|task_calls|function_call|tool_call|call|parameter)" + r"[\s>]", + re.IGNORECASE, +) + +_REPETITION_MIN_CHARS = 800 +_REPETITION_MIN_OCCURRENCES = 12 +_REPETITION_MIN_SHARE = 0.20 +_SEGMENT_SPLIT_RE = re.compile(r"[.!?。!?\n\r—]+") +_SEGMENT_NORMALIZE_RE = re.compile(r"[^\w\u3400-\u9fff]+", re.UNICODE) + +_RETRY_MESSAGE = ( + "你上一轮把工具调用写成了 XML 文本(如 <invoke>、<task_calls>、<parameter>)," + "这不是有效的工具调用。你必须通过工具接口调用工具,不要输出 XML 格式的工具调用标签。" + "请重新执行你刚才意图的工具调用。" +) + +_PLAIN_TOOL_RETRY_MESSAGE = ( + "你上一轮把已声明的工具调用写成了普通文本,而没有发出结构化工具调用。" + "不要描述、模拟或打印调用;请直接通过工具接口执行刚才意图的调用。" +) + +_REPETITION_RETRY_MESSAGE = ( + "你上一轮的内部推理陷入了高频重复,持续声明即将行动但没有产生真实工具调用。" + "请停止复述计划,只选择一个下一步:直接发出一个结构化工具调用,或用一句话明确结束任务。" +) + +_EMPTY_RESPONSE_MESSAGE = ( + "你上一轮没有产出任何内容(无文本输出、无工具调用)。请产出有效内容或调用工具,不能返回空响应。" +) + +_INSTRUCTIONS = """\ +## 工具调用纪律(强制) +- 必须通过工具接口调用工具,绝不要把工具调用写成 XML 文本(如 `<invoke>`、`<task_calls>`、`<function_call>`、`<parameter>`)。 +- 每一轮必须以一个真实的工具调用结束,或明确声明任务完成。 +- 创建多个任务时使用 `task_create_batch` 工具,不要手写 XML。 +""" + + +def _normalized_segments(content: str) -> list[str]: + """Return language-neutral sentence/line fingerprints for loop detection.""" + segments: list[str] = [] + for raw_segment in _SEGMENT_SPLIT_RE.split(content.casefold()): + segment = _SEGMENT_NORMALIZE_RE.sub(" ", raw_segment).strip() + if len(segment) >= 2: + segments.append(segment) + return segments + + +def _is_degenerate_repetition(content: str) -> bool: + """Detect sustained exact segment repetition without relying on model phrases.""" + if len(content) < _REPETITION_MIN_CHARS: + return False + + segments = _normalized_segments(content) + if not segments: + return False + + counts = Counter(segments) + repeated_occurrences = sum( + count for count in counts.values() if count >= _REPETITION_MIN_OCCURRENCES + ) + return repeated_occurrences / len(segments) >= _REPETITION_MIN_SHARE + + +def _contains_plain_tool_call(content: str, tool_names: Iterable[str]) -> bool: + """Detect a declared tool rendered as ``name(...)`` in assistant text.""" + names = [re.escape(name) for name in tool_names if name] + if not names: + return False + pattern = re.compile(rf"(?<![\w])(?:{'|'.join(names)})\s*\(") + return pattern.search(content) is not None + + +class GhostToolCallGuardCapability(AbstractCapability[AgentDepsT]): + """Detect ghost tool calls in model text output and force a retry. + + When the model emits XML-style tool call patterns as text instead of + making real structured tool calls, this capability raises + ``ModelRetry`` so the model gets another chance to call the tool + correctly. The original (ghost) response is preserved in message + history so the model can see what it did wrong. + + Apply this capability to every agent that participates in an + orchestrated pipeline where a stalled turn (text-only, no tool call) + causes a silent deadlock. + """ + + def get_instructions(self) -> str | None: + """Return tool call discipline instructions injected into the system prompt.""" + return _INSTRUCTIONS + + async def after_model_request( + self, + ctx: RunContext[AgentDepsT], + *, + request_context: ModelRequestContext, + response: ModelResponse, + ) -> ModelResponse: + """Check model response for ghost tool calls. + + If the response contains text with XML-style tool call patterns + and no real ``ToolCallPart``s, raise ``ModelRetry`` to force the + model to retry with proper structured tool calls. + + If the response contains both real tool calls and ghost text, the + real calls are allowed to proceed — the ghost text is treated as + narration, not a stalled call. + + Thinking content is checked only for sustained repetition. Merely + mentioning tool syntax in normal reasoning remains allowed. + """ + has_real_tool_call = False + has_ghost_text = False + has_text_content = False + text_parts: list[str] = [] + thinking_parts: list[str] = [] + + for part in response.parts: + if isinstance(part, ToolCallPart): + has_real_tool_call = True + elif isinstance(part, TextPart): + text_parts.append(part.content) + if _GHOST_TOOL_RE.search(part.content): + has_ghost_text = True + if part.content.strip(): + has_text_content = True + elif isinstance(part, ThinkingPart): + thinking_parts.append(part.content) + + if has_ghost_text and not has_real_tool_call: + raise ModelRetry(_RETRY_MESSAGE) + + response_content = "\n".join((*thinking_parts, *text_parts)) + if _is_degenerate_repetition(response_content): + raise ModelRetry(_REPETITION_RETRY_MESSAGE) + + if not has_real_tool_call: + declared_tool_names = ( + tool.name for tool in request_context.model_request_parameters.function_tools + ) + if _contains_plain_tool_call("\n".join(text_parts), declared_tool_names): + raise ModelRetry(_PLAIN_TOOL_RETRY_MESSAGE) + + if not has_text_content and not has_real_tool_call: + raise ModelRetry(_EMPTY_RESPONSE_MESSAGE) + + return response diff --git a/src/wolfharness/capabilities/wiki/harness/message_count_compaction.py b/src/wolfharness/capabilities/wiki/harness/message_count_compaction.py new file mode 100644 index 000000000..adc21d4f5 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/harness/message_count_compaction.py @@ -0,0 +1,124 @@ +"""Message-count compaction — trigger compression by message count, not tokens. + +DCP's token-based compaction may never trigger because ``[pruned]`` placeholders +are ~1 token each. A conversation with 252 pruned messages reports a low token +count, so the watermark stays below CRITICAL and ``compact_conversation`` never +fires. The context fills with useless ``[pruned]`` noise that the token counter +cannot detect. + +This capability closes that gap by triggering compaction when the **message +count** exceeds a threshold, regardless of token size. It runs in +``before_model_request`` alongside DCP and uses ``KeepLastMessages`` to slash +the conversation to the most recent N pairs. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from pydantic_ai.capabilities import AbstractCapability + +from wolfharness.messaging.compaction import ( + CompactionPipeline, + FilterToolCalls, + KeepLastMessages, + TruncateToolCallInputs, + compact_conversation, +) + + +if TYPE_CHECKING: + from pydantic_ai import RunContext + from pydantic_ai.models import ModelRequestContext + + from wolfharness.agents.context import AgentContext + + +logger = logging.getLogger(__name__) + +_EXCLUDE_TOOLS = ( + "team_status", + "task_list", + "read_blackboard", + "read_resource", + "list_chapters", + "inspect_wiki_state", + "inspect_build_checkpoint", + "send_message", + "task_create", + "task_create_batch", + "task_update", + "task_get", + "team_add_member", + "shutdown_request", + "prune_tool", + "distill_tool", + "decompress_tool", + "discover_opa", + "create_opa", + "create_ops", + "update_ops", + "apply_ops", + "ingest_external_ops", + "patch_entities_batch", + "create_opl", + "resolve_opa", + "refine_opa_reason_code", + "ops_dispatch_plan", +) + + +class MessageCountCompactionCapability(AbstractCapability[Any]): + """Compact the persistent conversation when message count exceeds a limit. + + Runs independently of DCP's token-watermark compaction. The pipeline first + strips noisy tool calls (``FilterToolCalls``) then keeps only the last N + request-response pairs (``KeepLastMessages``), giving the model a clean, + recent context window. + """ + + def __init__( + self, + *, + max_messages: int = 40, + keep_last_pairs: int = 12, + max_input_length: int = 1500, + ) -> None: + self._max_messages = max_messages + self._pipeline = CompactionPipeline( + steps=[ + FilterToolCalls(exclude_tools=list(_EXCLUDE_TOOLS)), + TruncateToolCallInputs(max_length=max_input_length), + KeepLastMessages(count=keep_last_pairs, count_pairs=True), + ], + ) + + async def before_model_request( + self, + ctx: RunContext[AgentContext], + request_context: ModelRequestContext, + ) -> ModelRequestContext: + """Compact the persistent conversation if message count exceeds limit.""" + conversation = None + try: + conversation = ctx.deps.native_agent.conversation + except (AttributeError, AssertionError): + return request_context + + if conversation is None: + return request_context + + chat_messages = conversation.get_history() + total_model_msgs = sum(len(cm.messages) if cm.messages else 0 for cm in chat_messages) + if total_model_msgs <= self._max_messages: + return request_context + + original, compacted = await compact_conversation(self._pipeline, conversation) + logger.info( + "MessageCountCompaction: %d → %d model messages (threshold=%d)", + original, + compacted, + self._max_messages, + ) + return request_context diff --git a/src/wolfharness/capabilities/wiki/harness/op_flow_guard.py b/src/wolfharness/capabilities/wiki/harness/op_flow_guard.py new file mode 100644 index 000000000..d379e94f5 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/harness/op_flow_guard.py @@ -0,0 +1,70 @@ +"""Harness validation for the OPA → OPS → OPL proposal flow.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from pydantic_ai.capabilities import AbstractCapability +from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.tools import AgentDepsT + + +if TYPE_CHECKING: + from pydantic_ai.capabilities.abstract import WrapToolExecuteHandler + from pydantic_ai.messages import ToolCallPart + from pydantic_ai.tools import RunContext, ToolDefinition + + +class OPFlowGuardCapability(AbstractCapability[AgentDepsT]): + """Reject empty or directly publishable OP records before the tool call.""" + + def get_toolset(self) -> None: + return + + def get_instructions(self) -> str | None: + return None + + def validate(self, tool_name: str, args: object) -> str: + if not isinstance(args, dict): + return "" + if tool_name.endswith("create_ops"): + if str(args.get("status", "unconfirmed")) != "unconfirmed": + return "OPS proposals must be created with status=unconfirmed." + missing = [ + name + for name in ("parent_opa", "title", "retrieval_query", "analysis", "solution") + if not str(args.get(name, "")).strip() + ] + if not isinstance(args.get("retrieved_uris"), list): + missing.append("retrieved_uris") + if not isinstance(args.get("evidence_uris"), list) or not args["evidence_uris"]: + missing.append("evidence_uris") + return "OPS is incomplete: " + ", ".join(missing) if missing else "" + if tool_name.endswith("create_opl"): + if str(args.get("status", "unconfirmed")) != "unconfirmed": + return "OPL proposals must remain status=unconfirmed." + missing = [ + name + for name in ("parent_opa", "title", "proposal", "rationale") + if not str(args.get(name, "")).strip() + ] + if not isinstance(args.get("ops_uris"), list) or not args["ops_uris"]: + missing.append("ops_uris") + if not isinstance(args.get("evidence_uris"), list) or not args["evidence_uris"]: + missing.append("evidence_uris") + return "OPL is incomplete: " + ", ".join(missing) if missing else "" + return "" + + async def wrap_tool_execute( + self, + ctx: RunContext[AgentDepsT], + *, + call: ToolCallPart, + tool_def: ToolDefinition, + args: Any, + handler: WrapToolExecuteHandler, + ) -> Any: + feedback = self.validate(call.tool_name, args) + if feedback: + raise ModelRetry(f"OP flow harness rejected the call:\n\n{feedback}") + return await handler(args) diff --git a/src/wolfharness/capabilities/wiki/harness/team_wake.py b/src/wolfharness/capabilities/wiki/harness/team_wake.py new file mode 100644 index 000000000..5af255f63 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/harness/team_wake.py @@ -0,0 +1,845 @@ +"""TeamWakeCapability — surface stalled / errored members to the lead. + +A long-running team build silently hangs when a member stops working: + +1. A member errors: ``NativeTurn.execute()`` emits ``RunErrorEvent``, the run + ends, and ``after_run`` is never called. ``TeamCommCapability.after_run`` + (which would remind the member) only fires on success, so the lead never + learns the member stopped — unless some other hook notifies it. + +2. A member idles with unfinished work: ``TeamCommCapability.after_run`` sends + one QUEUE reminder per turn via the member's own session, but stops once + ``_task_reminder_count >= max_task_reminders``. After that the member is + silent and the build hangs. + +This capability plugs both holes at the harness level (no team-mode rewrite): + +- ``on_run_error`` fires whenever an agent run fails (unlike ``after_run``, + which only fires on success). It STEERs a ``<team-message>`` notification + to the lead agent's session so the conductor can reassign, retry, or take + over the failing member's task. +- ``after_run`` detects the reminder-exhaustion window (unfinished tasks + + the TeamComm reminder counter pinned at the fingerprint) and notifies the + lead instead of staying silent; it re-arms the fingerprint tracking so the + lead is kept abreast of a member that keeps failing to finish. + +Both paths are full-defensive: if the current agent is not in a team, has no +``team_id``/conductor session, or no ``session_pool`` is available, the +capability no-ops and never modifies run control flow. ``on_run_error`` still +re-raises the original error so the run's failure semantics are unchanged. +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import re +from typing import TYPE_CHECKING, Any + +from pydantic_ai.capabilities import AbstractCapability +from pydantic_ai.messages import ModelResponse, TextPart, ToolCallPart + +from wolfharness.capabilities.agent_context import ( + AgentContextDeps, + resolve_agent_context_from_deps, +) +from wolfharness.capabilities.file_team_state import FileTeamState +from wolfharness.lifecycle.types import DeliveryMode + + +if TYPE_CHECKING: + from pydantic_ai._run_context import RunContext + from pydantic_ai.run import AgentRunResult + +_MAX_CONSECUTIVE_EMPTY_RUNS = 3 +_PHASE_DONE_RE = re.compile(r'"phase"\s*:\s*"done"') + + +@dataclass(frozen=True, slots=True) +class StalledTaskLease: + """A worker lease that the patrol released or permanently blocked.""" + + task_id: str + owner: str + blocked: bool + + +class TeamWakeCapability(AbstractCapability[Any]): + """Notify the lead when a member errors or exhausts its task reminders. + + Adds no tools and no instructions — it is a pure run-lifecycle observer + that routes alerts to the team lead (conductor) via + ``SessionPool.send_message(..., mode=DeliveryMode.STEER)``. + """ + + def __init__( + self, + *, + conductor_name: str = "wiki_conductor", + patrol_interval: int = 60, + stall_patrol_limit: int = 3, + max_task_takeovers: int = 2, + heartbeat_interval_seconds: float = 30.0, + ) -> None: + if stall_patrol_limit < 1: + raise ValueError("stall_patrol_limit must be positive") + if max_task_takeovers < 0: + raise ValueError("max_task_takeovers must not be negative") + self._conductor_name = conductor_name + self._patrol_interval = patrol_interval + self._stall_patrol_limit = stall_patrol_limit + self._max_task_takeovers = max_task_takeovers + self._heartbeat_interval_seconds = heartbeat_interval_seconds + + # -- helpers ---------------------------------------------------------- + + @staticmethod + def _is_empty_run(result: AgentRunResult[Any]) -> bool: + """Check if a run produced no text content and no tool calls.""" + try: + new_msgs = result.new_messages() + except (RuntimeError, ValueError, TypeError, KeyError): + return False + for msg in new_msgs: + if isinstance(msg, ModelResponse): + for part in msg.parts: + if isinstance(part, ToolCallPart): + return False + if isinstance(part, TextPart) and part.content.strip(): + return False + return True + + @staticmethod + def _resolve_agent_context( + ctx: RunContext[Any], + ) -> AgentContextDeps: + """Extract agent context from a pydantic-ai ``RunContext``.""" + return resolve_agent_context_from_deps( + ctx.deps, + capability_name="TeamWakeCapability", + ) + + def _team_state(self, agent_ctx: AgentContextDeps) -> FileTeamState | None: + """Build a FileTeamState for the current team, if any.""" + team_id: str | None = agent_ctx.session.metadata.get("team_id") + if team_id is None: + return None + base_dir: str = agent_ctx.session.metadata.get( + "team_base_dir", + "", + ) + if not base_dir and agent_ctx.team_mode_config is not None: + base_dir = agent_ctx.team_mode_config.effective_base_dir + if not base_dir: + return None + + return FileTeamState(base_dir) + + @staticmethod + def _read_build_state( + team_state: FileTeamState, + team_id: str, + *, + max_chars: int = 3000, + ) -> str: + """Read build_state from the blackboard for post-compaction recovery. + + Returns a truncated text snapshot of the conductor's persisted build + state, or an empty string if unavailable. This is injected into + patrol messages so the conductor can recover its plan after DCP + compaction destroys conversation history. + """ + try: + entry = team_state.read_blackboard(team_id, "build_state") + except (ValueError, OSError, TypeError, KeyError): + return "" + if not entry: + return "" + text = "" + value = entry.get("value", {}) + if isinstance(value, dict): + text = value.get("text", "") + elif isinstance(value, str): + text = value + if not text: + return "" + if len(text) > max_chars: + text = text[:max_chars] + "\n...(truncated, call read_blackboard for full)" + return text + + @staticmethod + def _build_is_done(build_state_text: str) -> bool: + """Return whether the durable build state records terminal success.""" + return bool(_PHASE_DONE_RE.search(build_state_text)) + + @staticmethod + def _unfinished_team_tasks(tasks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return tasks that still require execution or reassignment.""" + return [ + task for task in tasks if task.get("status") in {"pending", "in_progress", "blocked"} + ] + + @staticmethod + def _patrol_attention_fingerprint( + tasks: list[dict[str, Any]], + released: list[StalledTaskLease], + ) -> str: + """Describe only states that require a conductor wake-up. + + Active workers report completion through team messages, so a patrol + must not inject another prompt merely because work is progressing. + Pending/blocked work, a released lease, or an empty task board before + terminal ``build_state`` all require conductor action. + """ + released_ids = sorted( + f"{lease.task_id}:{'blocked' if lease.blocked else 'pending'}" for lease in released + ) + attention_tasks = sorted( + f"{task.get('task_id', '')}:{task.get('status', '')}:{task.get('owner', '')}" + for task in tasks + if task.get("status") in {"pending", "blocked"} + ) + if released_ids: + return "released=" + "|".join(released_ids) + if attention_tasks: + return "attention=" + "|".join(attention_tasks) + if not TeamWakeCapability._unfinished_team_tasks(tasks): + return "needs-next-phase" + return "" + + async def _notify_lead( + self, + agent_ctx: AgentContextDeps, + *, + team_id: str, + body: str, + ) -> bool: + """STEER a team-message notice to the conductor, best-effort. + + Returns ``True`` when the notification was handed to the session pool. + """ + team_state = self._team_state(agent_ctx) + if team_state is None: + return False + lead_sid = team_state.get_member_session_id( + team_id, + self._conductor_name, + ) + if not lead_sid: + return False + session_pool = agent_ctx.host.session_pool + if session_pool is None: + return False + + wrapped = f'<team-message from="system" type="member_alert">\n\n{body}\n\n</team-message>' + await session_pool.send_message( + lead_sid, + wrapped, + mode=DeliveryMode.STEER, + source="accepted", + meta={"from": "system", "team_id": team_id}, + ) + return True + + def _finish_body( + self, + agent_ctx: AgentContextDeps, + ) -> str: + member_name: str = agent_ctx.session.metadata.get( + "team_member_name", + "", + ) + return ( + f"member '{member_name or agent_ctx.scope.session_id}' ran but " + f"could not clear its in_progress tasks (TaskComm reminders " + f"exhausted). Please reassign, retry, or take over via " + f"task_create / send_message." + ) + + @staticmethod + def _task_progress_fingerprint(task: dict[str, Any]) -> str: + """Fingerprint the task board's real, observable progress fields. + + Only fields a working worker can actually mutate are included. The + legacy fingerprint also read ``artifact_revision`` and + ``tool_call_count``, neither of which any code writes, so they were + permanently empty noise that could not reflect artifact activity. + """ + fields = ( + "status", + "owner", + "progress_current", + "progress_total", + "last_note", + "updated_at", + ) + return "|".join(f"{field}={task.get(field, '')}" for field in fields) + + def _release_stalled_tasks( + self, + agent_ctx: AgentContextDeps, + team_state: FileTeamState, + team_id: str, + tasks: list[dict[str, Any]], + ) -> list[StalledTaskLease]: + """Release or block task leases after configurable no-progress patrols.""" + previous: dict[str, str] = agent_ctx.session.metadata.setdefault( + "_task_progress_fingerprints", + {}, + ) + counts: dict[str, int] = agent_ctx.session.metadata.setdefault( + "_task_stall_patrol_counts", + {}, + ) + active_ids: set[str] = set() + released: list[StalledTaskLease] = [] + for task in tasks: + task_id = str(task.get("task_id", "")) + owner = str(task.get("owner", "")) + if ( + not task_id + or not owner + or owner == self._conductor_name + or task.get("status") != "in_progress" + ): + continue + active_ids.add(task_id) + fingerprint = self._task_progress_fingerprint(task) + counts[task_id] = ( + counts.get(task_id, 0) + 1 if previous.get(task_id) == fingerprint else 1 + ) + previous[task_id] = fingerprint + if counts[task_id] < self._stall_patrol_limit: + continue + takeover_count = int(task.get("takeover_count", 0)) + 1 + blocked = takeover_count > self._max_task_takeovers + status = "blocked" if blocked else "pending" + team_state.update_task( + team_id, + task_id, + { + "status": status, + "owner": "", + "takeover_count": takeover_count, + "last_note": ( + "automatic takeover limit exceeded; conductor must diagnose and replan" + if blocked + else "lease released after patrols observed no external progress" + ), + }, + ) + released.append(StalledTaskLease(task_id=task_id, owner=owner, blocked=blocked)) + counts.pop(task_id, None) + previous.pop(task_id, None) + for task_id in set(previous) - active_ids: + previous.pop(task_id, None) + counts.pop(task_id, None) + return released + + async def _terminate_released_workers( + self, + agent_ctx: AgentContextDeps, + team_state: FileTeamState, + team_id: str, + released: list[StalledTaskLease], + ) -> None: + """Stop sessions whose task lease was revoked before reassignment.""" + session_pool = agent_ctx.host.session_pool + if session_pool is None: + return + for owner in {lease.owner for lease in released}: + session_id = team_state.get_member_session_id(team_id, owner) + if session_id: + await session_pool.close_session(session_id) + + # -- lifecycle hooks -------------------------------------------------- + + async def on_run_error( + self, + ctx: RunContext[Any], + *, + error: BaseException, + ) -> AgentRunResult[Any]: + """Notify the lead that the member errored, then re-raise. + + For the lead itself, re-arm the patrol so a timeout/error doesn't + leave it permanently asleep with no pending self-wake. + """ + try: + agent_ctx = self._resolve_agent_context(ctx) + except RuntimeError: + raise error from None + + team_id: str | None = agent_ctx.session.metadata.get("team_id") + if team_id is not None: + if agent_ctx.session.metadata.get("team_role") == "lead": + self._schedule_patrol(agent_ctx) + else: + self._release_owned_tasks(agent_ctx, team_id) + member_name: str = agent_ctx.session.metadata.get( + "team_member_name", + "", + ) + body_lines = ( + f"member '{member_name or agent_ctx.scope.session_id}' " + f"errored during a run:" + f"\n\n{type(error).__name__}: {error}" + f"\n\nPlease reassign, retry, or take over this member's " + f"task via task_create / send_message." + ) + await self._notify_lead( + agent_ctx, + team_id=team_id, + body=body_lines, + ) + + raise error + + async def after_run( + self, + ctx: RunContext[Any], + *, + result: AgentRunResult[Any], + ) -> AgentRunResult[Any]: + """Notify the lead when a member idles with unfinished work. + + For the lead itself, arms the recurring patrol AND runs an + immediate post-turn health check so stalled members are caught + without waiting 60 s for the first patrol round. + """ + try: + agent_ctx = self._resolve_agent_context(ctx) + except RuntimeError: + return result + + if agent_ctx.session.metadata.get("team_role") == "lead": + if self._is_empty_run(result): + count = ( + agent_ctx.session.metadata.get( + "_consecutive_empty_runs", + 0, + ) + + 1 + ) + agent_ctx.session.metadata["_consecutive_empty_runs"] = count + else: + agent_ctx.session.metadata["_consecutive_empty_runs"] = 0 + agent_ctx.session.metadata.pop("_last_patrol_attention_fp", None) + self._schedule_patrol(agent_ctx) + empty_count = agent_ctx.session.metadata.get( + "_consecutive_empty_runs", + 0, + ) + if empty_count >= _MAX_CONSECUTIVE_EMPTY_RUNS: + sent = await self._send_recovery_prompt(agent_ctx) + if sent: + agent_ctx.session.metadata["_consecutive_empty_runs"] = 0 + else: + await self._immediate_health_check(agent_ctx) + return result + team_id: str | None = agent_ctx.session.metadata.get("team_id") + if team_id is None: + return result + + unfinished = self._unfinished_tasks(agent_ctx, team_id) + if not unfinished: + return result + + fingerprint = self._worker_progress_fingerprint(unfinished) + last_fingerprint: str | None = agent_ctx.session.metadata.get( + "_task_reminder_fingerprint", + ) + stall_turns: int = ( + agent_ctx.session.metadata.get("_task_stall_turn_count", 0) + 1 + if last_fingerprint == fingerprint + else 1 + ) + agent_ctx.session.metadata["_task_reminder_fingerprint"] = fingerprint + agent_ctx.session.metadata["_task_stall_turn_count"] = stall_turns + + # Only treat the worker as stalled once its unfinished tasks have made + # NO progress for `stall_patrol_limit` consecutive turns. A worker that + # finishes a turn with its task still `in_progress` but is actively + # making progress (a normal multi-turn task) must NOT be reported as a + # stall: the old `last_fingerprint is None / != fingerprint` condition + # fired on almost every turn, wiping the worker's OWN task ownership + # (`_release_owned_tasks`) and flooding the lead with false + # member_alerts — whose "reminders exhausted" text was inaccurate — + # which drove the conductor to repeatedly reassign and spawn new + # workers (the fuse/reassign storm during phase 1B). + if stall_turns < self._stall_patrol_limit: + return result + + # Genuinely stalled: release ownership so the lead can reassign, then + # notify. Reset the counter so repeated alerts only fire after another + # full no-progress window. + self._release_owned_tasks(agent_ctx, team_id) + await self._notify_lead( + agent_ctx, + team_id=team_id, + body=self._finish_body(agent_ctx), + ) + agent_ctx.session.metadata["_task_stall_turn_count"] = 0 + + return result + + async def _send_recovery_prompt(self, agent_ctx: AgentContextDeps) -> bool: + """Break the conductor zombie loop with a focused recovery message. + + When the conductor produces ``_MAX_CONSECUTIVE_EMPTY_RUNS`` empty + turns, the model is likely stuck (compacted context, lost plan). + Send a single recovery message with build_state + task summary via + QUEUE so the next run has a concrete prompt to act on, instead of + relying on the 60 s patrol timer. + + Returns ``True`` when the message was handed to the session pool. + """ + team_id: str | None = agent_ctx.session.metadata.get("team_id") + if team_id is None: + return False + team_state = self._team_state(agent_ctx) + if team_state is None: + return False + own_sid = agent_ctx.scope.session_id + session_pool = agent_ctx.host.session_pool + if session_pool is None: + return False + + build_state_text = self._read_build_state( + team_state, + team_id, + max_chars=3000, + ) + state_block = "" + if build_state_text: + state_block = f"\n== build_state (from blackboard) ==\n{build_state_text}\n\n" + tasks = team_state.list_tasks(team_id) + pending = [t for t in tasks if t.get("status") in {"pending", "blocked"}] + completed = [t for t in tasks if t.get("status") == "completed"] + in_progress = [t for t in tasks if t.get("status") == "in_progress"] + summary = f"Tasks: {len(completed)} completed, {len(in_progress)} in_progress, {len(pending)} pending/blocked" + await session_pool.send_message( + own_sid, + ( + '<team-message from="system" type="recovery">\n\n' + f"Conductor zombie loop detected — {summary}.{state_block}\n" + "== 恢复指令(强制,最高优先级)==\n" + "1. 读取上方 build_state 确定当前阶段\n" + "2. 调用 task_list(include_children=True) 获取任务板\n" + "3. 根据 build_state + 任务板派发下一步工作\n" + "4. 有 pending/blocked task → 立即创建 1B task 并派发\n" + "5. 全部 in_progress → end turn 等待完成通知\n" + "6. 禁止只调用 team_status 就 end turn\n\n" + "</team-message>" + ), + mode=DeliveryMode.QUEUE, + source="accepted", + meta={"from": "system", "team_id": team_id}, + ) + return True + + async def _immediate_health_check(self, agent_ctx: AgentContextDeps) -> None: + """Wake the conductor only when it must make a dispatch decision. + + Workers report completion through team messages, and the patrol + handles stalled members, so the conductor must not be woken merely + because tasks are ``in_progress`` (that produced a tight self-feedback + loop: conductor wakes, re-queries the whole board to re-verify an + already-known state, and is immediately re-nudged). + + This fires only for states that genuinely require conductor action: + pending/blocked tasks awaiting dispatch, or an empty-but-not-done + board. It deliberately omits the stale build_state snapshot, which + disagreed with the conductor's own (newer) memory and forced repeated + re-verification. + """ + team_id: str | None = agent_ctx.session.metadata.get("team_id") + if team_id is None: + return + team_state = self._team_state(agent_ctx) + if team_state is None: + return + + tasks = team_state.list_tasks(team_id) + released: list[StalledTaskLease] = [] + attention_fingerprint = self._patrol_attention_fingerprint(tasks, released) + if not attention_fingerprint: + return + + last_fp: str | None = agent_ctx.session.metadata.get( + "_conductor_health_fp", + ) + if last_fp == attention_fingerprint: + return + agent_ctx.session.metadata["_conductor_health_fp"] = attention_fingerprint + + own_sid = agent_ctx.scope.session_id + session_pool = agent_ctx.host.session_pool + if session_pool is None: + return + + actionable = [ + t + for t in sorted(tasks, key=lambda t: t.get("task_id", "")) + if t.get("status") in {"pending", "blocked"} + ] + summary = ( + "\n".join( + f"- {t.get('task_id', '')}: {t.get('status', '')} owner={t.get('owner', '')} {t.get('subject', '')}" + for t in actionable + ) + or "- 任务板已清空;继续 audit、修复、backlinks、finalize。" + ) + body = ( + '<team-message from="system" type="health_check">\n\n' + "Post-turn: work awaits conductor action:\n" + f"{summary}\n" + f"attention={attention_fingerprint}\n" + "根据下方待办派发或处理即可;若无待决策项可直接 end turn。\n\n" + "</team-message>" + ) + await session_pool.send_message( + own_sid, + body, + mode=DeliveryMode.QUEUE, + source="accepted", + meta={"from": "system", "team_id": team_id}, + ) + + def _schedule_patrol(self, agent_ctx: AgentContextDeps) -> None: + """Arm a recurring self-wake so the lead patrols team status. + + The patrol loops until durable ``build_state.phase=done``. Unlike a one-shot + timer, this does not depend on the conductor completing a turn + to re-arm — the loop continues independently as long as there is + unfinished work, surviving transient turn failures that would + strand a one-shot timer. + + A completed task board is not terminal: the conductor may still need + to audit, repair, rebuild backlinks, or finalize. A metadata flag + prevents stacking overlapping timers across consecutive turns. + """ + team_id: str | None = agent_ctx.session.metadata.get("team_id") + if team_id is None: + return + team_state = self._team_state(agent_ctx) + if team_state is None: + return + build_state_text = self._read_build_state(team_state, team_id) + if self._build_is_done(build_state_text): + return + if agent_ctx.session.metadata.get("_patrol_armed"): + return + own_sid = agent_ctx.scope.session_id + session_pool = agent_ctx.host.session_pool + if session_pool is None: + return + + agent_ctx.session.metadata["_patrol_armed"] = True + + async def _patrol() -> None: + try: + while True: + await asyncio.sleep(self._patrol_interval) + empty_count = agent_ctx.session.metadata.get( + "_consecutive_empty_runs", + 0, + ) + if empty_count >= _MAX_CONSECUTIVE_EMPTY_RUNS: + agent_ctx.session.metadata["_consecutive_empty_runs"] = 0 + build_state_text = self._read_build_state( + team_state, + team_id, + max_chars=3000, + ) + state_block = "" + if build_state_text: + state_block = ( + f"\n== build_state (from blackboard) ==\n{build_state_text}\n\n" + ) + await session_pool.send_message( + own_sid, + ( + '<team-message from="system"' + ' type="stall_alert">\n\n' + f"Conductor produced {empty_count} consecutive" + " empty turns (复读 loop). Context was compacted" + " — you lost your build plan." + f"{state_block}\n" + "== 恢复指令(强制)==\n" + "1. 读取上方 build_state 确定当前阶段\n" + "2. 调用 task_list(include_children=True) 获取任务板\n" + "3. 根据 build_state + 任务板派发下一步工作\n" + "4. 禁止只调用 team_status 就 end turn\n\n" + "</team-message>" + ), + mode=DeliveryMode.QUEUE, + source="accepted", + meta={"from": "system", "team_id": team_id}, + ) + # ponytail: continue patrolling instead of breaking. + # Breaking permanently disarmed the conductor after 3 + # empty turns, leaving it in a zombie state with no + # self-wake mechanism. Continue so the patrol keeps + # checking; the empty-run counter was already reset. + continue + current = team_state.list_tasks(team_id) + released = self._release_stalled_tasks( + agent_ctx, + team_state, + team_id, + current, + ) + await self._terminate_released_workers( + agent_ctx, + team_state, + team_id, + released, + ) + build_state_text = self._read_build_state( + team_state, + team_id, + max_chars=3000, + ) + if self._build_is_done(build_state_text): + break + attention_fingerprint = self._patrol_attention_fingerprint( + current, + released, + ) + if not attention_fingerprint: + # Workers are making progress. Their completion/error + # notifications will wake the conductor; another patrol + # prompt would only grow context and encourage repetition. + agent_ctx.session.metadata.pop( + "_last_patrol_attention_fp", + None, + ) + continue + if ( + agent_ctx.session.metadata.get("_last_patrol_attention_fp") + == attention_fingerprint + ): + continue + agent_ctx.session.metadata["_last_patrol_attention_fp"] = attention_fingerprint + state_block = "" + if build_state_text: + state_block = ( + f"\n== build_state (from blackboard) ==\n{build_state_text}\n\n" + ) + release_block = "" + if released: + retry_ids = [lease.task_id for lease in released if not lease.blocked] + blocked_ids = [lease.task_id for lease in released if lease.blocked] + release_block = ( + "\n连续无进展 worker 会话已终止。" + + ( + "任务已释放为 pending:" + + ", ".join(retry_ids) + + "。请立即重新分配。" + if retry_ids + else "" + ) + + ( + " 超过自动接管上限、已转 blocked,conductor 必须诊断并拆小/换策略:" + + ", ".join(blocked_ids) + + "。" + if blocked_ids + else "" + ) + + "\n" + ) + await session_pool.send_message( + own_sid, + ( + '<team-message from="system"' + ' type="patrol">\n\n' + f"Patrol: 恢复构建状态后检查成员。{state_block}{release_block}\n" + "== 指令 ==\n" + "1. 根据 build_state 确定当前阶段和下一步\n" + "2. 检查 team_status 确认 worker 状态\n" + "3. 有未派发或 blocked 的工作 → 立即修复、重试或重新派发\n" + "4. 任务板已清空/全完成但 build_state 未 done → 继续 audit、修复、backlinks、finalize\n" + "5. 所有工作已派发且 worker 正在执行 → end turn\n" + "6. 禁止只检查 team_status 就 end turn\n\n" + "</team-message>" + ), + mode=DeliveryMode.QUEUE, + source="accepted", + meta={"from": "system", "team_id": team_id}, + ) + finally: + agent_ctx.session.metadata["_patrol_armed"] = False + agent_ctx.session.metadata.pop("_patrol_task", None) + + agent_ctx.session.metadata["_patrol_task"] = asyncio.create_task(_patrol()) + + def _release_owned_tasks( + self, + agent_ctx: AgentContextDeps, + team_id: str, + ) -> None: + """Clear the owner of this member's unfinished tasks. + + When a member errors or idles with work left undone, keeping its + ``in_progress`` tasks owned blocks other members from updating them + (the agentpool ``task_update`` tool rejects writes to another owner). + Releasing ownership lets the lead reassign or another worker claim + them. Best-effort: parsing is done here, so only grab the tasks we + currently own and clear that field. + """ + team_state = self._team_state(agent_ctx) + if team_state is None: + return + member_name: str = agent_ctx.session.metadata.get( + "team_member_name", + "", + ) + if not member_name: + return + for task in self._unfinished_tasks(agent_ctx, team_id): + team_state.update_task(team_id, task["task_id"], {"owner": ""}) + + def _unfinished_tasks( + self, + agent_ctx: AgentContextDeps, + team_id: str, + ) -> list[dict[str, Any]]: + team_state = self._team_state(agent_ctx) + if team_state is None: + return [] + member_name: str = agent_ctx.session.metadata.get( + "team_member_name", + "", + ) + if not member_name: + return [] + return [ + task + for task in team_state.list_tasks(team_id) + if task.get("owner") == member_name and task.get("status") in {"pending", "in_progress"} + ] + + @staticmethod + def _worker_progress_fingerprint(tasks: list[dict[str, Any]]) -> str: + """Aggregate a worker's unfinished-task progress into one fingerprint. + + Includes per-task progress observables (status/owner/notes/progress) + so a worker that is actively making progress across turns — even + without completing its task — produces a *different* fingerprint each + turn. An idle worker whose task state never changes produces the + same fingerprint, which the stall counter then treats as stalled. + """ + rows = [] + for task in sorted(tasks, key=lambda t: t.get("task_id", "")): + row_fields = ( + task.get("task_id", ""), + task.get("status", ""), + task.get("owner", ""), + task.get("progress_current", ""), + task.get("progress_total", ""), + task.get("last_note", ""), + task.get("updated_at", ""), + ) + rows.append("|".join(str(f) for f in row_fields)) + return "\n".join(rows) diff --git a/src/wolfharness/capabilities/wiki/harness/team_workflow_guard.py b/src/wolfharness/capabilities/wiki/harness/team_workflow_guard.py new file mode 100644 index 000000000..27098d418 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/harness/team_workflow_guard.py @@ -0,0 +1,575 @@ +"""TeamWorkflowGuardCapability — conductor team-orchestration guardrails. + +Splits the team-workflow validation responsibility out of the monolithic +``WikiHarnessCapability``: this capability validates ``task_create`` calls +made by the conductor before they are dispatched, rejecting under-specified +or oversized extraction tasks via ``ModelRetry``. +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING, Any + +from pydantic_ai.capabilities import AbstractCapability +from pydantic_ai.exceptions import ModelRetry +from pydantic_ai.tools import AgentDepsT + +from wolfharness.capabilities.agent_context import AgentContextDeps, resolve_agent_context_from_deps +from wolfharness.capabilities.file_team_state import FileTeamState + + +if TYPE_CHECKING: + from collections.abc import Mapping + + from pydantic_ai.capabilities.abstract import WrapToolExecuteHandler + from pydantic_ai.messages import ToolCallPart + from pydantic_ai.tools import RunContext, ToolDefinition + +_PROCEDURE_ONLY_CLASS_NAMES: frozenset[str] = frozenset( + { + "diagnosis", + "inspection", + "measurement", + "maintenance", + "removal", + "installation", + "replacement", + "adjustment", + "repair", + "test", + }, +) +_COMMON_INVALID_PROCEDURE_CLASS_NAMES: frozenset[str] = frozenset( + { + "assembly", + "calibration", + "disassembly", + }, +) +_CHAPTER_REF_RE = re.compile(r"\bch\d{4}\b") +_CHAPTER_COUNT_RE = re.compile( + r"(?:\bchapter_count\s*[:=]\s*(\d+)" + r"|\b(?:chapter|chapters|章节)\s*(?:count|数量|数)?\s*[:=]?\s*(\d+)" + r"|\b(\d+)\s*(?:chapters?|章节))", + re.IGNORECASE, +) +_CLASS_LINE_RE = re.compile(r"\bclass(?:_name)?\s*[::]\s*([^\n]+)", re.IGNORECASE) +_PACKET_ID_RE = re.compile(r"^[a-zA-Z0-9_]+$") +_PACKET_DECL_RE = re.compile( + r"\bpacket_id\s*[:=]\s*['\"]?([A-Za-z0-9_]+)(?=$|[\s,.;。;,}\]])", + re.IGNORECASE, +) +_PROFILE_DECL_RE = re.compile(r"\baudit_profile\s*[:=]\s*['\"]?(manual|case)\b", re.IGNORECASE) +_EXPECTED_ARTIFACTS_RE = re.compile(r"\bexpected_artifacts?\s*[:=]", re.IGNORECASE) +_PHASE_1A_RE = re.compile( + r"(?:\b1a\b|phase\s*1a|source[- ]analysis|source\s+packet)", re.IGNORECASE +) +_PHASE_0_RE = re.compile(r"(?:\bphase\s*[:=]?\s*(?:phase)?0\b|\bphase0\b)", re.IGNORECASE) +_PHASE_0_OPERATION_RE = re.compile( + r"\bphase0_operation\s*[:=]\s*['\"]?" + r"(component_write|device_write|no_entity_register)\b", + re.IGNORECASE, +) +_PHASE_0_IDENTITY_OPERATION_RE = re.compile( + r"\bphase0_operation\s*[:=]\s*['\"]?identity_plan\b", + re.IGNORECASE, +) +_BOM_SOURCE_COUNT_RE = re.compile(r"\bbom_source_count\s*[:=]\s*(\d+)\b", re.IGNORECASE) +_RESOLVED_COMPONENT_COUNT_RE = re.compile( + r"\bresolved_component_count\s*[:=]\s*(\d+)\b", + re.IGNORECASE, +) +_EXTRACTION_TASK_RE = re.compile( + r"(?:phase\s*[:=]?\s*1[ab]|source[- ]analysis|source\s+packet|entity_type\s*[:=])", + re.IGNORECASE, +) +_CHUNK_DECL_RE = re.compile(r"(?:^|\s)chunk_id\s*[:=]\s*([A-Za-z0-9_.-]+)", re.IGNORECASE) + + +def _chunk_id_of(description: str) -> str: + """Extract the declared ``chunk_id`` from a task description, if any.""" + match = _CHUNK_DECL_RE.search(description) + return match.group(1).strip() if match is not None else "" + + +_PHASE_DONE_RE = re.compile(r'"phase"\s*:\s*"done"') +_WORKER_ROLE_RE = re.compile( + r"\bworker_role\s*[:=]\s*['\"]?" + r"(wiki_(?:(?:extraction|relation|opa|ops|opl)_worker|file_operator))\b", + re.IGNORECASE, +) +_OP_SUBJECT_RE = re.compile(r"\b(OPA|OPS|OPL)\b", re.IGNORECASE) +_OP_REQUIRED_FIELDS: dict[str, tuple[str, ...]] = { + "wiki_opa_worker": ("target_uri", "evidence_uris", "expected_artifacts"), + "wiki_ops_worker": ( + "parent_opa", + "retrieval_query", + "evidence_uris", + "expected_artifacts", + ), + "wiki_opl_worker": ("parent_opa", "ops_uris", "evidence_uris", "expected_artifacts"), +} + + +class TeamWorkflowGuardCapability(AbstractCapability[AgentDepsT]): + """Validate conductor team-tool calls (single and batch creation). + + Only fires for the ``wiki_conductor`` role; other roles pass through + untouched. Violations raise ``ModelRetry`` before the task is created. + """ + + def __init__( + self, + *, + role: str = "wiki_conductor", + max_build_state_bytes: int = 65536, + max_phase0_components_per_task: int = 5, + ) -> None: + if max_build_state_bytes < 1024: + raise ValueError("max_build_state_bytes must be at least 1024") + if max_phase0_components_per_task < 1: + raise ValueError("max_phase0_components_per_task must be positive") + self._role = role + self._max_build_state_bytes = max_build_state_bytes + self._max_phase0_components_per_task = max_phase0_components_per_task + + def get_toolset(self) -> None: + """Return ``None`` — this capability provides no tools.""" + return + + def get_instructions(self) -> str | None: + """Return ``None`` — team conventions live in the workflow skill.""" + return None + + async def wrap_tool_execute( + self, + ctx: RunContext[AgentDepsT], + *, + call: ToolCallPart, + tool_def: ToolDefinition, + args: Any, + handler: WrapToolExecuteHandler, + ) -> Any: + """Reject under-specified extraction tasks before dispatch.""" + feedback = self.validate(call.tool_name, args) + if not feedback and call.tool_name.endswith("write_blackboard"): + feedback = self.validate_build_state_write(args) + if feedback: + raise ModelRetry( + f"Team workflow action REJECTED by wiki harness:\n\n{feedback}\n\nPlease fix the orchestration issue and retry.", + ) + agent_ctx = resolve_agent_context_from_deps( + ctx.deps, + capability_name="TeamWorkflowGuardCapability", + ) + assignment_feedback = "" + if call.tool_name.endswith("task_create") or call.tool_name.endswith("task_create_batch"): + assignment_feedback = self._validate_runtime_assignment(args, agent_ctx) + if assignment_feedback: + raise ModelRetry( + f"OP task assignment REJECTED by wiki harness:\n\n{assignment_feedback}\n\nAssign the task to a member running the declared OP worker role.", + ) + is_done_write = call.tool_name.endswith("write_blackboard") and self._is_done_write(args) + if not is_done_write: + # Never let a previous successful finalize authorize a done write + # after another tool call (including a failed finalize attempt). + agent_ctx.session.metadata.pop("_wiki_finalize_receipt", None) + if is_done_write: + done_feedback = self.validate_done_transition( + args, + agent_ctx.session.metadata.get("_wiki_finalize_receipt"), + ) + if done_feedback: + raise ModelRetry( + f"Build completion REJECTED by wiki harness:\n\n{done_feedback}\n\nCall finalize_wiki successfully and persist its receipt before retrying.", + ) + + result = await handler(args) + if call.tool_name.endswith("finalize_wiki") and isinstance(result, dict): + if ( + result.get("status") in ("finalized", "finalized_local") + and result.get("op_flow_passed") is True + ): + agent_ctx.session.metadata["_wiki_finalize_receipt"] = { + "audit_profile": str(result.get("audit_profile", "")), + "source_snapshot_id": str(result.get("source_snapshot_id", "")), + } + elif is_done_write: + agent_ctx.session.metadata.pop("_wiki_finalize_receipt", None) + return result + + def _validate_runtime_assignment(self, args: Any, agent_ctx: AgentContextDeps) -> str: + """Bind specialized task contracts to the owner's registered agent.""" + if not isinstance(args, dict): + return "" + tasks_value = args.get("tasks") + raw_tasks: list[Any] = tasks_value if isinstance(tasks_value, list) else [args] + errors: list[str] = [] + team_id = str(agent_ctx.session.metadata.get("team_id", "")) + base_dir = str(agent_ctx.session.metadata.get("team_base_dir", "")) + if not base_dir and agent_ctx.team_mode_config is not None: + base_dir = agent_ctx.team_mode_config.effective_base_dir + team_state = FileTeamState(base_dir) if team_id and base_dir else None + existing_tasks = team_state.list_tasks(team_id) if team_state else [] + roster = team_state.list_member_names(team_id) if team_state else set() + for index, task in enumerate(raw_tasks, 1): + if not isinstance(task, dict): + continue + subject = str(task.get("subject", "")) + owner = str(task.get("owner", task.get("assigned_to", ""))).strip() + description = str(task.get("description", "")) + new_chunk_id = _chunk_id_of(description) + # Reject tasks for workers that were never created via team_add_member. + # task_create/task_create_batch is only for existing idle workers with + # live sessions; a conductor that missed team_add_member would spin + # forever creating tasks nobody can claim. + if team_state and owner and owner not in roster: + errors.append( + f"Task {index}: owner '{owner}' is not a registered team member. " + f"Use team_add_member(initial_task=...) to create this worker " + f"before assigning tasks." + ) + if team_state: + same_subject = [ + existing + for existing in existing_tasks + if existing.get("owner") == owner + and str(existing.get("subject", "")).strip() == subject.strip() + and existing.get("status") not in {"completed", "cancelled", "deleted"} + ] + duplicate = [ + existing + for existing in same_subject + if not new_chunk_id + or _chunk_id_of(str(existing.get("description", ""))) == new_chunk_id + ] + if duplicate: + sample_id = str(duplicate[0].get("task_id", "?")) + sample_status = str(duplicate[0].get("status", "?")) + errors.append( + f"Task {index}: duplicate dispatch — an active task with the same " + f"owner and subject already exists ({sample_id}, status={sample_status}). " + "Recheck the task board before re-dispatching; reuse the existing task_id.", + ) + text = f"{subject}\n{description}" + role_match = _WORKER_ROLE_RE.search(text) + if role_match is None: + if _OP_SUBJECT_RE.search(subject): + errors.append(f"Task {index}: OP tasks must declare `worker_role`. ") + elif _EXTRACTION_TASK_RE.search(text): + owner = str(task.get("owner", task.get("assigned_to", ""))).strip() + owner_agent = ( + team_state.get_member_agent(team_id, owner) + if team_state and owner + else None + ) + if owner_agent != "wiki_extraction_worker": + errors.append( + f"Task {index}: extraction task owner {owner!r} runs {owner_agent or 'unknown'!r}, expected 'wiki_extraction_worker'.", + ) + continue + role = role_match.group(1).lower() + owner = str(task.get("owner", task.get("assigned_to", ""))).strip() + owner_agent = ( + team_state.get_member_agent(team_id, owner) if team_state and owner else None + ) + if not owner: + errors.append(f"Task {index}: {role} task must have an explicit owner.") + elif owner_agent != role: + errors.append( + f"Task {index}: owner {owner!r} runs {owner_agent or 'unknown'!r}, expected {role!r}.", + ) + if role in _OP_REQUIRED_FIELDS: + missing = [field for field in _OP_REQUIRED_FIELDS[role] if field not in text] + if missing: + errors.append(f"Task {index}: {role} task is missing {', '.join(missing)}.") + if role in {"wiki_ops_worker", "wiki_opl_worker"} and not task.get("blocked_by"): + errors.append( + f"Task {index}: {role} task must declare `blocked_by` for the OP DAG." + ) + return "\n".join(errors) + + def _validate_op_assignment(self, args: Any, agent_ctx: AgentContextDeps) -> str: + """Backward-compatible alias for the expanded runtime assignment guard.""" + return self._validate_runtime_assignment(args, agent_ctx) + + @staticmethod + def _is_done_write(args: Any) -> bool: + """Return whether a blackboard write requests the terminal phase.""" + if not isinstance(args, dict) or str(args.get("key", "")) != "build_state": + return False + return _PHASE_DONE_RE.search(str(args.get("value", ""))) is not None + + def validate_build_state_write(self, args: Any) -> str: + """Keep the durable recovery state bounded and structurally readable.""" + if not isinstance(args, dict) or str(args.get("key", "")) != "build_state": + return "" + if str(args.get("mode", "overwrite")) != "overwrite": + return "`build_state` is a snapshot and must use mode='overwrite', never append." + value = args.get("value", "") + if not isinstance(value, str): + return "`build_state` must be serialized as one JSON object string." + size = len(value.encode("utf-8")) + if size > self._max_build_state_bytes: + return ( + f"`build_state` is {size} bytes, exceeding the configured " + f"{self._max_build_state_bytes}-byte recovery-state limit. " + "Keep only phase, snapshot ids, counts, task ids, packet ids, and blockers." + ) + try: + parsed = json.loads(value) + except json.JSONDecodeError: + return "`build_state` must be valid JSON so patrol recovery is deterministic." + if not isinstance(parsed, dict): + return "`build_state` must be a JSON object." + return "" + + @staticmethod + def validate_done_transition(args: Any, receipt: object) -> str: + """Require a matching, same-session finalize receipt for ``done``.""" + if not isinstance(args, dict) or not isinstance(receipt, dict): + return "`build_state.phase=done` requires a successful same-session finalize receipt." + value = str(args.get("value", "")) + try: + build_state = json.loads(value) + except (json.JSONDecodeError, TypeError): + return "The terminal build_state must be a JSON object." + if not isinstance(build_state, dict): + return "The terminal build_state must be a JSON object." + audit_profile = str(receipt.get("audit_profile", "")) + source_snapshot_id = str(receipt.get("source_snapshot_id", "")) + if not audit_profile or not source_snapshot_id: + return "Finalize receipt is incomplete; audit profile and source snapshot are required." + if ( + build_state.get("audit_profile") != audit_profile + or build_state.get("source_snapshot_id") != source_snapshot_id + ): + return "The done build_state must persist the finalize audit profile and source snapshot id." + return "" + + def validate(self, tool_name: str, args: Any) -> str: + """Return rejection feedback for an invalid team-tool call, else ``""``.""" + if self._role != "wiki_conductor": + return "" + is_batch = tool_name.endswith("task_create_batch") + if not is_batch and not tool_name.endswith("task_create"): + return "" + if not isinstance(args, dict): + return "" + + if is_batch: + raw_tasks = args.get("tasks", args.get("items", args.get("task_specs", []))) + if not isinstance(raw_tasks, list): + return "Batch task creation must provide a list under `tasks`." + errors: list[str] = [] + for index, task in enumerate(raw_tasks, 1): + feedback = self.validate("task_create", task) + if feedback: + errors.append(f"Task {index}:\n{feedback}") + return "\n".join(errors) + + owner = str(args.get("owner", args.get("assigned_to", ""))) + subject = str(args.get("subject", "")) + description = str(args.get("description", "")) + text = f"{subject}\n{description}" + if owner.startswith(("file_op", "file_operator")) and _PHASE_0_RE.search(text): + return self._validate_phase0_file_operation(text) + if not owner.startswith(("extraction_worker", "extraction_")): + return "" + if _PHASE_0_RE.search(text): + return self._validate_phase0_identity_operation(args, text) + + is_phase_1a = _PHASE_1A_RE.search(text) is not None + is_procedure_task = "Procedure" in text or "1B-2" in text + + errors: list[str] = [] + packet_id = str(args.get("packet_id", "")).strip() + if not packet_id: + packet_match = _PACKET_DECL_RE.search(text) + packet_id = packet_match.group(1) if packet_match is not None else "" + if not packet_id: + errors.append( + "Extraction tasks must declare the stable source `packet_id` they consume or produce." + ) + elif is_phase_1a and not _PACKET_ID_RE.fullmatch(packet_id): + errors.append( + "Phase 1A `packet_id` must use only ASCII letters, digits, and `_`; use a stable source-domain slug or content-derived identifier.", + ) + if _PROFILE_DECL_RE.search(text) is None: + errors.append( + "Extraction tasks must declare the immutable `audit_profile` (`manual` or `case`)." + ) + if _EXPECTED_ARTIFACTS_RE.search(text) is None: + errors.append( + "Extraction tasks must declare `expected_artifacts` for completion checks." + ) + chapter_refs = set(_CHAPTER_REF_RE.findall(text)) + if is_phase_1a: + declared_counts = { + int(value) + for match in _CHAPTER_COUNT_RE.finditer(text) + for value in match.groups() + if value is not None + } + if not declared_counts: + errors.append( + "Phase 1A source-analysis tasks must declare `chapter_count` for bounded dispatch.", + ) + if len(chapter_refs) > 6 or any(count > 6 for count in declared_counts): + errors.append( + "Phase 1A source-analysis tasks must contain at most 6 chapters per source packet. " + f"This task references {len(chapter_refs)} explicit chapters and declares counts " + f"{sorted(declared_counts)}.", + ) + return "\n".join(f"- {error}" for error in errors) + is_symptom_task = "1B-4" in text or "Symptom/Profile" in text + if not is_procedure_task and not is_symptom_task: + return "\n".join(f"- {error}" for error in errors) + if len(chapter_refs) > 6: + errors.append( + f"Procedure extraction tasks must be chunked to at most 6 explicit chapters. This task references {len(chapter_refs)} chapters: {', '.join(sorted(chapter_refs))}.", + ) + + if "chunk_id" not in text or "chunk_of" not in text: + errors.append( + "Procedure extraction tasks must declare `chunk_id` and `chunk_of` so conductor can track, retry, or reassign the chunk independently.", + ) + + if "heartbeat" not in text.lower() or "task_update" not in text: + errors.append( + "Procedure extraction tasks must require a first-turn `task_update` heartbeat before entity writes.", + ) + + if "depends_on_stage" not in text: + errors.append("Procedure extraction tasks must declare `depends_on_stage: 1B-2`.") + + if is_procedure_task: + invalid_classes: set[str] = set() + for match in _CLASS_LINE_RE.finditer(text): + class_text = match.group(1).lower() + for raw_token in re.split(r"[^a-z_]+", class_text): + token = raw_token.strip("_") + if token in _COMMON_INVALID_PROCEDURE_CLASS_NAMES or ( + token and token.endswith("ing") and token not in _PROCEDURE_ONLY_CLASS_NAMES + ): + invalid_classes.add(token) + if invalid_classes: + errors.append( + "Procedure class_name must use the canonical 10-class vocabulary; invalid values: " + + ", ".join(sorted(invalid_classes)) + + ". Map 拆解/分解/装配内容 to removal/installation/replacement/repair as appropriate.", + ) + + # Symptom identity is part of the URI hash. Allowing the worker to + # write a Symptom with an empty/temporary class creates dangling Fault + # and Profile links. Fail the task before it starts so the conductor + # chooses one source-backed class and reuses it for write_entity and + # write_symptom_profile. + if "1B-4" in text or "Symptom/Profile" in text: + class_matches = re.findall( + r"class_name\s*[:=]\s*['\"]?([^'\"\n,)]*)", + text, + flags=re.IGNORECASE, + ) + normalized = {match.strip() for match in class_matches if match.strip()} + if not normalized or any(value in {"", "none", "null"} for value in normalized): + errors.append( + "Symptom/Profile tasks must declare one non-empty source-backed class_name and reuse it for write_entity and write_symptom_profile.", + ) + elif len(normalized) > 1: + errors.append( + f"Symptom/Profile task declares multiple class_name values ({', '.join(sorted(normalized))}); choose one stable class for the batch.", + ) + if "same class_name" not in text and "稳定" not in text and "reuse" not in text.lower(): + errors.append( + "Symptom/Profile task must state that the same class_name is reused for all entity writes.", + ) + + return "\n".join(f"- {error}" for error in errors) + + @staticmethod + def _validate_phase0_identity_operation(args: Mapping[str, object], text: str) -> str: + """Require one resolved BOM input for the semantic identity planner.""" + errors: list[str] = [] + if _PHASE_0_IDENTITY_OPERATION_RE.search(text) is None: + errors.append( + "Phase 0 extraction tasks must declare `phase0_operation=identity_plan`; structural writes belong in separate file-operator tasks.", + ) + packet_id = str(args.get("packet_id", "")).strip() + if not packet_id: + packet_match = _PACKET_DECL_RE.search(text) + packet_id = packet_match.group(1) if packet_match is not None else "" + if not packet_id or _PACKET_ID_RE.fullmatch(packet_id) is None: + errors.append("Phase 0 identity plans require a stable ASCII `packet_id`.") + if _PROFILE_DECL_RE.search(text) is None: + errors.append("Phase 0 identity plans must declare the immutable `audit_profile`.") + if _EXPECTED_ARTIFACTS_RE.search(text) is None: + errors.append("Phase 0 identity plans must declare `expected_artifacts`.") + if "target_model" not in text: + errors.append("Phase 0 identity plans must declare the `target_model`.") + if "config_scope" not in text: + errors.append("Phase 0 identity plans must declare a bounded `config_scope`.") + if "bom_source_uri" not in text: + errors.append("Phase 0 identity plans require one resolved `bom_source_uri`.") + source_count_match = _BOM_SOURCE_COUNT_RE.search(text) + if source_count_match is None or int(source_count_match.group(1)) != 1: + errors.append( + "Phase 0 identity plans must declare `bom_source_count=1`; split multiple BOM files into independent packets.", + ) + return "\n".join(f"- {error}" for error in errors) + + def _validate_phase0_file_operation(self, text: str) -> str: + """Keep Phase 0 file tasks deterministic, resumable, and bounded.""" + errors: list[str] = [] + operation_match = _PHASE_0_OPERATION_RE.search(text) + if operation_match is None: + errors.append( + "Phase 0 file tasks must declare one `phase0_operation`: " + "`component_write`, `device_write`, or `no_entity_register`. " + "BOM identity planning belongs in a persisted extraction packet before file writes; " + "do not combine identity planning, Component writes, Device creation, and coverage registration.", + ) + return "\n".join(f"- {error}" for error in errors) + + operation = operation_match.group(1).lower() + if _PROFILE_DECL_RE.search(text) is None: + errors.append("Phase 0 file tasks must declare the immutable `audit_profile`.") + if _EXPECTED_ARTIFACTS_RE.search(text) is None: + errors.append("Phase 0 file tasks must declare `expected_artifacts`.") + + if operation == "component_write": + if "resolved_components" not in text: + errors.append( + "`component_write` requires `resolved_components` from the persisted BOM identity packet.", + ) + if "source_packet_uri" not in text: + errors.append( + "`component_write` requires a real `source_packet_uri` for resumable input." + ) + count_match = _RESOLVED_COMPONENT_COUNT_RE.search(text) + if count_match is None: + errors.append("`component_write` must declare `resolved_component_count`.") + else: + count = int(count_match.group(1)) + if count < 1 or count > self._max_phase0_components_per_task: + errors.append( + "Phase 0 component writes must contain at most " + f"{self._max_phase0_components_per_task} resolved Components; got {count}. " + "Split by target Component URI so each chunk can retry independently.", + ) + elif operation == "device_write": + if "resolved_component_uris" not in text: + errors.append("`device_write` requires `resolved_component_uris`.") + if "resolved_system_chapter_uris" not in text: + errors.append("`device_write` requires `resolved_system_chapter_uris`.") + if "source_packet_uri" not in text: + errors.append("`device_write` requires the Phase 0 identity `source_packet_uri`.") + elif operation == "no_entity_register": + if "resolved_source_uris" not in text: + errors.append("`no_entity_register` requires explicit `resolved_source_uris`.") + if "packet_id" not in text: + errors.append("`no_entity_register` requires a stable ASCII `packet_id`.") + + return "\n".join(f"- {error}" for error in errors) diff --git a/src/wolfharness/capabilities/wiki/hooks/__init__.py b/src/wolfharness/capabilities/wiki/hooks/__init__.py new file mode 100644 index 000000000..f76358aed --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/__init__.py @@ -0,0 +1,18 @@ +"""Wiki build quality hooks — post-tool-call validation for entity writes.""" + +from __future__ import annotations + +from .base import BaseHook, HookResult +from .diagnostic_closure import DiagnosticClosureHook +from .frontmatter_schema import FrontmatterSchemaHook +from .source_ref import SourceReferenceHook +from .uri_integrity import URIIntegrityHook + +__all__ = [ + "BaseHook", + "DiagnosticClosureHook", + "FrontmatterSchemaHook", + "HookResult", + "SourceReferenceHook", + "URIIntegrityHook", +] diff --git a/src/wolfharness/capabilities/wiki/hooks/base.py b/src/wolfharness/capabilities/wiki/hooks/base.py new file mode 100644 index 000000000..6dfa8a0b0 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/base.py @@ -0,0 +1,64 @@ +"""Base classes for post-tool-call wiki entity validation hooks. + +Unlike the NMT harness hooks (which check translation pairs), wiki hooks +validate entity content after ``write_entity`` or ``patch_entity`` calls. +Each hook receives the entity's Markdown content and metadata, and returns +a :class:`HookResult` indicating pass/fail with a descriptive message. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class HookResult: + """Result of a wiki entity validation hook check. + + Attributes: + hook_name: Name of the hook that produced this result. + passed: Whether the check passed. + message: Human-readable description of the result. + severity: ``"error"`` or ``"warning"`` (default ``"warning"``). + """ + + hook_name: str + passed: bool + message: str + severity: str = "warning" + + +class BaseHook(ABC): + """Base class for post-tool-call wiki entity validation hooks. + + Each subclass implements :meth:`check` to validate entity content + (Markdown with YAML frontmatter) and return a :class:`HookResult`. + """ + + @property + @abstractmethod + def name(self) -> str: + """Short identifier for this hook.""" + ... + + @abstractmethod + def check( + self, + content: str, + concept: str = "", + class_name: str = "", + object_name: str = "", + ) -> HookResult: + """Check wiki entity content. + + Args: + content: Full entity Markdown content (frontmatter + body). + concept: Concept type (e.g. ``"Component"``, ``"Fault"``). + class_name: Class name within the concept. + object_name: Object name within the class. + + Returns: + A :class:`HookResult` with pass/fail and a descriptive message. + """ + ... diff --git a/src/wolfharness/capabilities/wiki/hooks/body_evidence.py b/src/wolfharness/capabilities/wiki/hooks/body_evidence.py new file mode 100644 index 000000000..2a34366c1 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/body_evidence.py @@ -0,0 +1,319 @@ +"""Validate executable body sections and their raw evidence. + +The wiki keeps standards and measurement facts in Markdown body sections, +not in mandatory frontmatter fields. This hook therefore checks the body +contract that frontmatter-only relationship validation cannot see: + +* composite diagnosis steps must link reusable Procedure entities; +* Profile mechanisms and diagnostic steps must use typed Fault/Procedure + links or an explicit source-backed ``open_gap``; +* fact-bearing operation and criterion sections must retain a raw citation. + +The hook only blocks confirmed entities. Draft pages may be assembled in +dependency order and are checked again by the full audit before publication. +""" + +from __future__ import annotations + +import re + +from wolfharness.capabilities.wiki.quality import ( + entity_status, + extract_sections, + extract_source_uris, + extract_wiki_uris, + has_usable_procedure_criteria, + is_raw_source_uri, + parse_frontmatter, + wiki_uri_prefix, +) +from wolfharness.capabilities.wiki.section_constants import ( + GAP_RE, + SECTION_COMMON_FAULTS, + SECTION_DIAGNOSTIC_FLOW, + SECTION_JUDGMENT_CRITERIA, + SECTION_OPERATION_STEPS, + SECTION_POSSIBLE_FAILURE, +) + +from .base import BaseHook, HookResult + + +_LIST_ITEM_RE = re.compile(r"^\s*(?:\d+[.)]|[-*])\s+(.+?)\s*$") +_TOP_LEVEL_LIST_ITEM_RE = re.compile(r"^(?:\d+[.)]|[-*])\s+(.+?)\s*$") +_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?(?:\s*:?-+:?\s*\|)+\s*$") +_GAP_RE = re.compile(GAP_RE, re.IGNORECASE) + + +def _top_level_items(section: str) -> list[str]: + """Return top-level Markdown list item bodies from a section.""" + items: list[str] = [] + for line in section.splitlines(): + if line[:1].isspace(): + continue + match = _LIST_ITEM_RE.match(line) + if match is not None: + items.append(match.group(1).strip()) + return items + + +def _top_level_item_blocks(section: str) -> list[str]: + """Return each top-level list item together with its continuation lines.""" + blocks: list[list[str]] = [] + for line in section.splitlines(): + match = _TOP_LEVEL_LIST_ITEM_RE.match(line) + if match is not None: + blocks.append([match.group(1).strip()]) + elif blocks and (not line.strip() or line[:1].isspace()): + blocks[-1].append(line.strip()) + return ["\n".join(block).strip() for block in blocks] + + +def _has_raw_citation(section: str) -> bool: + """Return whether a body section contains at least one raw URI.""" + return any(is_raw_source_uri(uri) for uri in extract_source_uris(section)) + + +def _has_gap(item: str) -> bool: + """Return whether an item explicitly records an unresolved source gap.""" + return _GAP_RE.search(item) is not None + + +def _missing_item_evidence(section: str) -> list[str]: + """Find list items or specification rows without local raw evidence. + + ponytail: a section-level raw citation already backs the whole section; + only flag items when the entire section lacks any raw URI or unresolved + gap marker (paragraph-level granularity), not per-item URIs. + """ + if _has_raw_citation(section) or _has_gap(section): + return [] + missing = [item for item in _top_level_item_blocks(section) if not _has_gap(item)] + table_rows = [line.strip() for line in section.splitlines() if "|" in line] + if len(table_rows) > 1: + data_rows = [row for row in table_rows[2:] if not _TABLE_SEPARATOR_RE.match(row)] + missing.extend(row for row in data_rows if not _has_gap(row)) + return missing + + +def _section_source_has_raw(frontmatter: dict[str, object], section_name: str) -> bool: + """Accept a structured section_sources citation as local evidence.""" + records = frontmatter.get("section_sources") + if isinstance(records, dict): + values = records.get(section_name, []) + return any(is_raw_source_uri(uri) for uri in extract_source_uris(str(values))) + if not isinstance(records, list): + return False + for record in records: + if not isinstance(record, dict): + continue + section = record.get("section", record.get("target_section")) + values = record.get("source_uri", record.get("source_uris", record.get("sources"))) + if section == section_name and any( + is_raw_source_uri(uri) for uri in extract_source_uris(str(values)) + ): + return True + return False + + +def _parameter_rows_without_local_evidence(section: str) -> list[str]: + """Find table rows whose parameter facts lack a row-local raw URI. + + ponytail: same section-level granularity as item evidence — a section + with any raw citation (or an explicit gap marker) is considered sourced. + """ + if _has_raw_citation(section) or _has_gap(section): + return [] + table_rows = [line.strip() for line in section.splitlines() if "|" in line] + if len(table_rows) < 3: + return [] + data_rows = [row for row in table_rows[2:] if not _TABLE_SEPARATOR_RE.match(row)] + return [row for row in data_rows if not _has_gap(row)] + + +class BodyEvidenceHook(BaseHook): + """Check body-level diagnostic links and evidence citations.""" + + @property + def name(self) -> str: + return "body_evidence" + + def check( + self, + content: str, + concept: str = "", + class_name: str = "", + object_name: str = "", + ) -> HookResult: + if entity_status(content) != "confirmed": + return HookResult( + hook_name=self.name, + passed=True, + message="Body evidence closure is enforced when status is confirmed.", + ) + + sections = extract_sections(content) + issues: list[str] = [] + frontmatter = parse_frontmatter(content) + + if concept == "Procedure": + scope = frontmatter.get("procedure_scope") + if class_name == "diagnosis" and scope == "composite": + steps = _top_level_items(sections.get(SECTION_OPERATION_STEPS, "")) + missing = [ + step + for step in steps + if not _has_gap(step) + and not any( + uri.startswith(wiki_uri_prefix() + "/Procedure/") + for uri in extract_wiki_uris(step) + ) + ] + if missing: + issues.append( + "Composite diagnosis steps without a Procedure URI or explicit open_gap: " + + "; ".join(missing[:3]), + ) + + for section_name in (SECTION_OPERATION_STEPS, SECTION_JUDGMENT_CRITERIA): + section = sections.get(section_name, "").strip() + if not section: + issues.append( + f"Procedure section '{section_name}' is missing; add body content or an explicit open_gap.", + ) + continue + if _has_gap(section): + continue + if section_name == SECTION_JUDGMENT_CRITERIA and not has_usable_procedure_criteria( + section + ): + issues.append( + f"Procedure section '{SECTION_JUDGMENT_CRITERIA}' contains only a source pointer; write the actual pass/fail criterion in the body.", + ) + continue + missing_evidence = _missing_item_evidence(section) + if missing_evidence and not _section_source_has_raw(frontmatter, section_name): + issues.append( + f"Procedure section '{section_name}' has steps/rows without local raw-source evidence: " + + "; ".join(missing_evidence[:3]), + ) + parameter_rows = _parameter_rows_without_local_evidence(section) + if parameter_rows: + issues.append( + f"Procedure section '{section_name}' has parameter rows without a row-local raw-source locator: " + + "; ".join(parameter_rows[:3]), + ) + elif not _has_raw_citation(section) and not _section_source_has_raw( + frontmatter, section_name + ): + issues.append( + f"Procedure section '{section_name}' contains executable facts but no raw-source citation.", + ) + + elif concept == "Symptom" and "profile_id" in frontmatter: + mechanisms = _top_level_items(sections.get(SECTION_POSSIBLE_FAILURE, "")) + missing_faults = [ + item + for item in mechanisms + if not _has_gap(item) + and not any( + uri.startswith(wiki_uri_prefix() + "/Fault/") for uri in extract_wiki_uris(item) + ) + ] + if missing_faults: + issues.append( + "Profile mechanisms without a Fault URI or explicit open_gap: " + + "; ".join(missing_faults[:3]), + ) + missing_mechanism_evidence = _missing_item_evidence( + sections.get(SECTION_POSSIBLE_FAILURE, "") + ) + if missing_mechanism_evidence and not _section_source_has_raw( + frontmatter, SECTION_POSSIBLE_FAILURE + ): + issues.append( + "Profile mechanism items without local raw-source evidence: " + + "; ".join(missing_mechanism_evidence[:3]), + ) + + diagnostic_steps = _top_level_items(sections.get(SECTION_DIAGNOSTIC_FLOW, "")) + missing_procedures = [ + item + for item in diagnostic_steps + if not _has_gap(item) + and not any( + uri.startswith(wiki_uri_prefix() + "/Procedure/") + for uri in extract_wiki_uris(item) + ) + ] + if missing_procedures: + issues.append( + "Profile diagnostic steps without a Procedure URI or explicit open_gap: " + + "; ".join(missing_procedures[:3]), + ) + if ( + diagnostic_steps + and not _has_raw_citation(sections.get(SECTION_DIAGNOSTIC_FLOW, "")) + and not _section_source_has_raw(frontmatter, SECTION_DIAGNOSTIC_FLOW) + ): + issues.append( + f"Profile section '{SECTION_DIAGNOSTIC_FLOW}' has no raw-source citation." + ) + missing_diagnostic_evidence = _missing_item_evidence( + sections.get(SECTION_DIAGNOSTIC_FLOW, "") + ) + if missing_diagnostic_evidence and not _section_source_has_raw( + frontmatter, SECTION_DIAGNOSTIC_FLOW + ): + issues.append( + "Profile diagnostic items without local raw-source evidence: " + + "; ".join(missing_diagnostic_evidence[:3]), + ) + + elif concept == "Component": + for section_name in ("性能参数", "specifications"): + section = sections.get(section_name, "").strip() + if not section or _has_gap(section): + continue + if not _has_raw_citation(section): + issues.append( + f"Component section '{section_name}' contains facts but no raw-source citation.", + ) + parameter_rows = _parameter_rows_without_local_evidence(section) + if parameter_rows: + issues.append( + f"Component section '{section_name}' has parameter rows without a row-local raw-source locator: " + + "; ".join(parameter_rows[:3]), + ) + + elif concept == "Device": + for section_name in (SECTION_COMMON_FAULTS, "控制器与故障码"): + section = sections.get(section_name, "").strip() + if not section or _has_gap(section): + continue + missing_evidence = _missing_item_evidence(section) + if missing_evidence and not _section_source_has_raw(frontmatter, section_name): + issues.append( + f"Device section '{section_name}' has rows without local raw-source evidence: " + + "; ".join(missing_evidence[:3]), + ) + elif not _has_raw_citation(section) and not _section_source_has_raw( + frontmatter, + section_name, + ): + issues.append( + f"Device section '{section_name}' contains diagnostic links but no raw-source citation.", + ) + + if issues: + return HookResult( + hook_name=self.name, + passed=False, + message=" | ".join(issues), + severity="error", + ) + return HookResult( + hook_name=self.name, + passed=True, + message="Body diagnostic links and raw evidence are complete.", + ) diff --git a/src/wolfharness/capabilities/wiki/hooks/body_sections.py b/src/wolfharness/capabilities/wiki/hooks/body_sections.py new file mode 100644 index 000000000..f091cc8f3 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/body_sections.py @@ -0,0 +1,283 @@ +"""Body sections validation hook. + +Validates that entity body contains all required sections per design_717.md +§3 Concept schemas, and that each section has non-empty content (not just +a heading with no body text). + +Each Concept type has a defined set of required ``## `` sections. This hook +extracts all ``## `` headings from the body (excluding frontmatter), checks +that all required sections are present, and flags sections that are empty +(heading followed immediately by another heading or end of file with no +content between them). + +For Symptom, the hook detects whether the content is a Canonical index.md +or a Profile file by checking for ``profile_id`` / ``parent_symptom`` in +frontmatter, and applies the appropriate section set. + +Required section sets are loaded dynamically from +``xeno_adp_agentic/wiki/templates/default_schema.yaml`` via +:mod:`wolfharness.capabilities.wiki.schema_loader`, eliminating hardcoded drift. +``_CASE_REQUIRED_SECTIONS`` is a manual relaxation for fault-annotated / +case sources and is intentionally NOT in the YAML — it subtracts sections +those source types cannot provide. +""" + +from __future__ import annotations + +import re + +import yaml + +from wolfharness.capabilities.wiki.quality import is_case_source_uri +from wolfharness.capabilities.wiki.schema_loader import ( + get_all_concept_body_sections, + get_profile_body_sections, +) +from wolfharness.capabilities.wiki.section_constants import ( + SECTION_DIAGNOSIS_FLOW, + SECTION_JUDGMENT_CRITERIA, + SECTION_MECHANISM, + SECTION_OPERATION_STEPS, + SECTION_OVERVIEW, + SECTION_POSSIBLE_FAILURE, + SECTION_SOURCE, +) + +from .base import BaseHook, HookResult + + +# ── Required body sections ─────────────────────────────────────────────── +# Loaded once from the YAML schema (cached via lru_cache in schema_loader). + +_REQUIRED_SECTIONS: dict[str, frozenset[str]] = get_all_concept_body_sections() + +_PROFILE_SECTIONS: frozenset[str] = get_profile_body_sections() + +# Manual relaxation for fault-annotated/case sources — these sources lack +# teardown/assembly/manufacturer-spec sections. Kept as explicit overrides +# because the YAML schema defines the *full* requirement; the case subset +# is a policy decision, not a schema property. +_CASE_REQUIRED_SECTIONS: dict[str, frozenset[str]] = { + "Component": frozenset({SECTION_OVERVIEW, SECTION_MECHANISM, SECTION_SOURCE}), + "DTC": frozenset({ + "故障码定义", + SECTION_POSSIBLE_FAILURE, + SECTION_DIAGNOSIS_FLOW, + SECTION_SOURCE, + }), + "Procedure": frozenset({ + "操作目的", + SECTION_OPERATION_STEPS, + SECTION_JUDGMENT_CRITERIA, + "后续处理", + SECTION_SOURCE, + }), + "Device": frozenset({"基础信息", "包含系统", SECTION_SOURCE}), +} + +# Section heading pattern: "## Title" (exactly 2 hashes, not 3+) +_SECTION_HEADING_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) + +# Frontmatter detection +_FM_START_RE = re.compile(r"^---\s*$", re.MULTILINE) + + +def _extract_body(content: str) -> str: + """Strip YAML frontmatter, return body only.""" + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return content + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + return "\n".join(lines[i + 1 :]) + return content # No closing ---, return as-is + + +def _is_profile(content: str) -> bool: + """Detect if content is a Symptom Profile file.""" + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return False + for line in lines[1:]: + if line.strip() == "---": + break + if "profile_id" in line or "parent_symptom" in line: + return True + return False + + +def _extract_sections(body: str) -> dict[str, str]: + """Extract all ## level sections and their content. + + Returns ``{heading: content}`` where content is the text between + this heading and the next ## heading (or end of body). + """ + sections: dict[str, str] = {} + matches = list(_SECTION_HEADING_RE.finditer(body)) + + for i, m in enumerate(matches): + heading = m.group(1).strip() + start = m.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(body) + content = body[start:end].strip() + sections[heading] = content + + return sections + + +class BodySectionsHook(BaseHook): + """Check that entity body has all required sections with non-empty content. + + Per design_717.md §3, each Concept type has a defined set of required + ``## `` sections. This hook: + + 1. Detects whether the content is a regular entity or a Symptom Profile. + 2. Checks all required sections are present. + 3. Flags sections that are empty (heading with no body content). + + Sections are considered empty if they contain only whitespace, or only + a single line like "待补充" / "待关联补充" without substantive content. + This is a warning for required sections: keep the section and replace the + line with a source-honest gap note when no evidence exists. + """ + + # Patterns that indicate placeholder/empty content. + # "无" and "来源未说明" are valid source-honest gap notes, not placeholders. + _PLACEHOLDER_PATTERNS = re.compile( + r"^(待补充|待关联补充|待后续补充|—|-|\.\.\.)\s*$", + re.MULTILINE, + ) + + # Component 工作机理 是身份核心字段:裸注记(来源未说明/无/—)不等于内容。 + # 真实来源缺失必须写成说明性 open_gap 并挂 gap OPA,见 _BARE_MECHANISM_GAP_RE。 + _BARE_MECHANISM_GAP_RE = re.compile( + r"^(?:来源未说明|无|—|-|\.\.\.|n/?a)\s*[。.]?\s*$", + re.IGNORECASE, + ) + + @property + def name(self) -> str: + return "body_sections" + + def check( + self, + content: str, + concept: str = "", + class_name: str = "", + object_name: str = "", + ) -> HookResult: + body = _extract_body(content) + + # Determine section set: Profile vs regular entity + is_profile = _is_profile(content) + if is_profile: + required = _PROFILE_SECTIONS + else: + frontmatter = _parse_frontmatter_for_source(content) + source_values = frontmatter.get("sources", []) + is_case_source = isinstance(source_values, list) and any( + isinstance(source, str) and is_case_source_uri(source) for source in source_values + ) + required = ( + _CASE_REQUIRED_SECTIONS.get(concept) + if is_case_source + else _REQUIRED_SECTIONS.get(concept) + ) + + if not required: + return HookResult( + hook_name=self.name, + passed=True, + message=f"No required sections defined for concept '{concept}'.", + ) + + sections = _extract_sections(body) + + # Check missing sections + missing = required - set(sections.keys()) + # Check empty sections + empty: list[str] = [] + mechanism_gap: list[str] = [] + for heading in required: + if heading in sections: + body_text = sections[heading] + if not body_text or self._is_placeholder(body_text): + if concept == "Component" and heading == SECTION_MECHANISM: + mechanism_gap.append(heading) + else: + empty.append(heading) + elif ( + concept == "Component" + and heading == SECTION_MECHANISM + and self._is_bare_mechanism_gap(body_text) + ): + mechanism_gap.append(heading) + + issues: list[str] = [] + if missing: + issues.append( + f"Missing {len(missing)} required section(s): {', '.join(sorted(missing))}", + ) + if empty: + issues.append( + f"Empty or placeholder-only section(s): " + f"{', '.join(sorted(empty))}. " + f"Do not delete required sections; keep them and write a source-honest gap note " + f"when the source does not provide the content.", + ) + if mechanism_gap: + message = ( + f"Component `{SECTION_MECHANISM}` 不得为空或裸占位" + "(来源未说明/无/待补充):按总成的功能/结构/常见动作推断," + "或从原文归纳;确实无从推断时写 open_gap 说明原因并挂 gap OPA。" + ) + issues.append(message) + + if issues: + severity = "error" if missing else "warning" + return HookResult( + hook_name=self.name, + passed=False, + message=" | ".join(issues), + severity=severity, + ) + + return HookResult( + hook_name=self.name, + passed=True, + message=( + f"All {len(required)} required sections present and non-empty{' (Profile)' if is_profile else ''}." + ), + ) + + def _is_placeholder(self, text: str) -> bool: + """Check if section content is just a placeholder.""" + stripped = text.strip() + if not stripped: + return True + if self._PLACEHOLDER_PATTERNS.match(stripped): + return True + return len(stripped) < 15 and ("待" in stripped and "补充" in stripped) + + def _is_bare_mechanism_gap(self, text: str) -> bool: + """Whether ``工作机理`` is only a bare gap note (not a cited open_gap).""" + return bool(self._BARE_MECHANISM_GAP_RE.match(text.strip())) + + +def _parse_frontmatter_for_source(content: str) -> dict[str, object]: + """Read only the source field needed by the case-schema decision. + + This hook deliberately avoids importing the broader quality parser to + keep validation hooks dependency-light and usable by the MCP boundary. + """ + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return {} + end = next((index for index, line in enumerate(lines[1:], 1) if line.strip() == "---"), None) + if end is None: + return {} + try: + loaded = yaml.safe_load("\n".join(lines[1:end])) + except yaml.YAMLError: + return {} + return loaded if isinstance(loaded, dict) else {} diff --git a/src/wolfharness/capabilities/wiki/hooks/component_taxonomy.py b/src/wolfharness/capabilities/wiki/hooks/component_taxonomy.py new file mode 100644 index 000000000..e2dd0964a --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/component_taxonomy.py @@ -0,0 +1,92 @@ +"""Validate BOM-driven Component identity and logical classification paths.""" + +from __future__ import annotations + +from wolfharness.capabilities.wiki.quality import ( + is_bom_source_uri, + is_external_source_uri, + is_raw_chapter_uri, + parse_frontmatter, + wiki_uri_prefix, +) +from wolfharness.capabilities.wiki.section_constants import MODEL_TOKEN_RE + +from .base import BaseHook, HookResult + + +_FORBIDDEN_PREFIXES = ("关重件/", "普通件/") + + +def _string_list(value: object) -> list[str]: + """Normalize YAML scalar/list values without accepting arbitrary mappings.""" + if isinstance(value, str): + return [value.strip()] if value.strip() else [] + if isinstance(value, list): + return [item.strip() for item in value if isinstance(item, str) and item.strip()] + return [] + + +class ComponentTaxonomyHook(BaseHook): + """Keep Component folders as evidence-backed BOM paths, not model buckets.""" + + @property + def name(self) -> str: + return "component_taxonomy" + + def check( + self, + content: str, + concept: str = "", + class_name: str = "", + object_name: str = "", + ) -> HookResult: + if concept != "Component": + return HookResult(self.name, True, "BOM taxonomy applies only to Component entities.") + + frontmatter = parse_frontmatter(content) + declared_class = frontmatter.get("class_name") + logical_class = ( + declared_class.strip() + if isinstance(declared_class, str) and declared_class.strip() + else class_name + ) + failures: list[str] = [] + if any(logical_class.startswith(prefix) for prefix in _FORBIDDEN_PREFIXES): + failures.append("physical tier prefix is forbidden; use the logical BOM path") + + segments = [segment for segment in logical_class.split("/") if segment] + applicable_models = _string_list(frontmatter.get("applicable_models")) + if segments and any( + segments[0].casefold() == model.casefold() for model in applicable_models + ): + failures.append("Component class_name must not use a machine model as its root") + if segments and MODEL_TOKEN_RE.fullmatch(segments[0]) is not None: + failures.append("Component class_name must not use a machine-model token as its root") + + bom_path = frontmatter.get("bom_path") + if isinstance(bom_path, str) and bom_path.strip(): + if logical_class != bom_path.strip(): + failures.append("class_name does not match the declared BOM path") + evidence = _string_list(frontmatter.get("bom_evidence")) + if not any( + is_raw_chapter_uri(uri) or is_external_source_uri(uri) or is_bom_source_uri(uri) + for uri in evidence + ): + failures.append("declared BOM path has no raw BOM evidence") + parent_uri = frontmatter.get("bom_parent_uri") + if parent_uri is not None and ( + not isinstance(parent_uri, str) + or not parent_uri.startswith(wiki_uri_prefix() + "/Component/") + ): + failures.append("bom_parent_uri must reference a Component") + + if failures: + return HookResult( + self.name, + False, + f"Component taxonomy validation failed for {object_name or 'unnamed'}: {'; '.join(failures)}.", + severity="error", + ) + return HookResult( + self.name, True, "Component uses a logical, BOM-compatible classification path." + ) diff --git a/src/wolfharness/capabilities/wiki/hooks/diagnostic_closure.py b/src/wolfharness/capabilities/wiki/hooks/diagnostic_closure.py new file mode 100644 index 000000000..5ec19ec39 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/diagnostic_closure.py @@ -0,0 +1,53 @@ +"""Confirmation-time diagnostic graph closure hook.""" + +from __future__ import annotations + +from wolfharness.capabilities.wiki.quality import ( + confirmation_requirements, + entity_status, + has_unresolved_placeholder, +) + +from .base import BaseHook, HookResult + + +class DiagnosticClosureHook(BaseHook): + """Block confirmation while a diagnostic traversal edge is missing.""" + + @property + def name(self) -> str: + return "diagnostic_closure" + + def check( + self, + content: str, + concept: str = "", + class_name: str = "", + object_name: str = "", + ) -> HookResult: + if entity_status(content) != "confirmed": + return HookResult( + hook_name=self.name, + passed=True, + message="Diagnostic closure is enforced when status is confirmed.", + ) + + missing = [ + check.code + for check in confirmation_requirements(content, concept, class_name) + if not check.complete + ] + if has_unresolved_placeholder(content): + missing.append("content.unresolved_placeholder") + if missing: + return HookResult( + hook_name=self.name, + passed=False, + message=f"Diagnostic graph is incomplete: {', '.join(missing)}.", + severity="error", + ) + return HookResult( + hook_name=self.name, + passed=True, + message="Diagnostic graph requirements are complete.", + ) diff --git a/src/wolfharness/capabilities/wiki/hooks/directory_structure.py b/src/wolfharness/capabilities/wiki/hooks/directory_structure.py new file mode 100644 index 000000000..bde1e04c0 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/directory_structure.py @@ -0,0 +1,300 @@ +"""Directory structure validation hook. + +Validates that entity directory paths conform to design_729.md §2 KB +目录结构一览. Checks concept/class_name against expected patterns: + +- Fault: 7 English class names (HydraulicFailure, MechanicalFailure, ...) +- Procedure: 10 English operation types (diagnosis, inspection, ...) +- DTC: 系列代号_控制器功能角色 (e.g. SY215_主控制器, SY365_发动机控制器) +- Component: logical type/variant path, NO 关重件/普通件 storage prefix +- Symptom: functional domain (not series name) +- Prohibited concepts: Series, System +""" + +from __future__ import annotations + +from wolfharness.capabilities.wiki.section_constants import ( + CONFIG_SPECIFIC_PATTERNS, + MODEL_PREFIXES, + MODEL_TOKEN_RE, +) + +from .base import BaseHook, HookResult + + +# ── Design_717.md directory rules ───────────────────────────────────────── + +_FAULT_CLASSES: frozenset[str] = frozenset({ + "HydraulicFailure", + "MechanicalFailure", + "ElectricalFailure", + "ControlFailure", + "FluidFailure", + "ThermalFailure", + "StructuralFailure", +}) + +_PROCEDURE_CLASSES: frozenset[str] = frozenset({ + "diagnosis", + "inspection", + "measurement", + "maintenance", + "removal", + "installation", + "replacement", + "adjustment", + "repair", + "test", +}) + +_PROHIBITED_CONCEPTS: frozenset[str] = frozenset({ + "Series", + "System", +}) + +_VALID_CONCEPTS: frozenset[str] = frozenset({ + "Device", + "Component", + "DTC", + "Symptom", + "Fault", + "Procedure", + "OP", +}) + +# Chinese class names that should be English (Fault) +_CHINESE_FAULT_INDICATORS: frozenset[str] = frozenset({ + "液压故障", + "机械故障", + "电气故障", + "控制故障", + "流体故障", + "热故障", + "结构故障", + "传感器故障", + "电气系统", + "液压系统", +}) + +# Chinese class names that should be English (Procedure) +_CHINESE_PROCEDURE_INDICATORS: frozenset[str] = frozenset({ + "发动机", + "操作规程", + "拆装", + "测试", + "检查", + "测量", + "保养", + "调整", + "诊断", + "维修", + "测试与调整", + "标定", + "calibration", + "disassembly", +}) + +# DTC class names that use the old MCU-SYC215 / ECU-QSB6.7 pattern. +# These should be 系列代号_控制器功能角色 (e.g. SY215_主控制器). +_DTC_LEGACY_PATTERNS: tuple[str, ...] = ( + "MCU-", + "ECU-", + "HCU-", + "VCU-", +) + +# Device model prefixes that must NOT appear in Component object_name. +# Component objects should be named by brand+model or mechanical type+specs, +# never by "机型+部件名" (e.g. "SY215C前泵压力传感器" is wrong). +# Centralized in wolfharness.capabilities.wiki.section_constants.MODEL_PREFIXES. + + +class DirectoryStructureHook(BaseHook): + """Check that entity directory structure conforms to design_729.md. + + Validates: + - Concept is a valid type (not Series/System) + - Fault class_name is one of 7 English types + - Procedure class_name is one of 10 English operation types + - DTC class_name looks like a controller model + - Component class_name must NOT contain a 关重件/ or 普通件/ prefix + - Component object_name has no device model prefix (SY215C, SY365H, ...) + """ + + @property + def name(self) -> str: + return "directory_structure" + + def check( + self, + content: str, + concept: str = "", + class_name: str = "", + object_name: str = "", + ) -> HookResult: + issues: list[str] = [] + + # ── Prohibited concepts ─────────────────────────────────────────── + if concept in _PROHIBITED_CONCEPTS: + return HookResult( + hook_name=self.name, + passed=False, + message=( + f"Concept '{concept}' is prohibited — Series and System " + f"are not valid Concept types (design_729.md §2). " + f"Series aggregation is via Device/<系列>/<系列>.md, " + f"System overview is via raw chapters." + ), + severity="error", + ) + + if concept and concept not in _VALID_CONCEPTS: + issues.append( + f"Concept '{concept}' is not a recognized type. " + f"Valid types: {', '.join(sorted(_VALID_CONCEPTS))}.", + ) + + # ── Fault: must use 7 English class names ───────────────────────── + if concept == "Fault": + if not class_name: + issues.append("Fault entity has no class_name.") + elif class_name in _CHINESE_FAULT_INDICATORS: + issues.append( + f"Fault class_name '{class_name}' is Chinese — must use " + f"one of 7 English types: " + f"{', '.join(sorted(_FAULT_CLASSES))}.", + ) + elif class_name not in _FAULT_CLASSES: + issues.append( + f"Fault class_name '{class_name}' is not one of the 7 " + f"valid English failure types: " + f"{', '.join(sorted(_FAULT_CLASSES))}.", + ) + + # ── Procedure: must use 10 English operation types ──────────────── + if concept == "Procedure": + if not class_name: + issues.append("Procedure entity has no class_name.") + elif class_name in _CHINESE_PROCEDURE_INDICATORS: + issues.append( + f"Procedure class_name '{class_name}' is Chinese — must use " + f"one of 10 English operation types: " + f"{', '.join(sorted(_PROCEDURE_CLASSES))}.", + ) + elif class_name not in _PROCEDURE_CLASSES: + issues.append( + f"Procedure class_name '{class_name}' is not one of the 10 " + f"valid English operation types: " + f"{', '.join(sorted(_PROCEDURE_CLASSES))}.", + ) + + # ── DTC: class_name must be 系列代号_控制器功能角色 ─────────────── + if concept == "DTC": + if not class_name: + issues.append("DTC entity has no class_name.") + elif MODEL_TOKEN_RE.match(class_name) is not None: + issues.append( + f"DTC class_name '{class_name}' is a device series/model, " + f"not a controller. Use format: 系列代号_控制器功能角色 " + f"(e.g. SY215_主控制器, SY365_发动机控制器).", + ) + elif any(class_name.startswith(p) for p in _DTC_LEGACY_PATTERNS): + issues.append( + f"DTC class_name '{class_name}' uses legacy MCU/ECU/HCU " + f"naming — must use format: 系列代号_控制器功能角色 " + f"(e.g. SY215_主控制器, NOT MCU-SYC215). " + f"Controllers are identified by functional role from the " + f"manual, not by inventing model names with device codes.", + ) + elif "_" not in class_name: + issues.append( + f"DTC class_name '{class_name}' missing series prefix — " + f"must use format: 系列代号_控制器功能角色 " + f"(e.g. SY215_主控制器, SY365_主控制器).", + ) + + # ── Component: class_name must be pure type path, NO legacy prefix ─ + if concept == "Component": + if not class_name: + issues.append("Component entity has no class_name.") + elif class_name.startswith(("关重件/", "普通件/")): + issues.append( + f"Component class_name '{class_name}' contains a legacy " + f"storage prefix. Pass the logical assembly-path such as " + f"'主泵' or '主泵/双联柱塞式', not '关重件/主泵'. " + f"The 关重件/普通件 tier routing was removed; a Component " + f"folder is its logical class_name path.", + ) + if object_name: + bad_prefix = next( + (p for p in MODEL_PREFIXES if object_name.upper().startswith(p)), + None, + ) + if bad_prefix: + issues.append( + f"Component object_name '{object_name}' starts with " + f"device model prefix '{bad_prefix}' — must use " + f"brand+model (e.g. '川崎K3V112') or mechanical type" + f"+specs (e.g. '往复柱塞式液压油缸Φ135×95×1490'), " + f"not '机型+部件名'.", + ) + + # ── Symptom: class_name should be a functional domain ───────────── + if concept == "Symptom": + if not class_name: + issues.append("Symptom entity has no class_name.") + elif MODEL_TOKEN_RE.match(class_name) is not None: + issues.append( + f"Symptom class_name '{class_name}' looks like a device " + f"model — should be a functional domain like " + f"动力与发动机, 液压系统, 电气系统.", + ) + + # ── Symptom index.md: 产品配置差异 must be a redirect, not inline ─ + # design_729.md §3.6: index.md's 产品配置差异 section should be + # a one-liner redirect to profile/. Config-specific content + # (model names, pressure values, component types) belongs in + # profile/<profile_id>.md. + if "## 产品配置差异" in content: + # Extract the section content between ## 产品配置差异 and next ## + lines = content.splitlines() + in_section = False + section_lines: list[str] = [] + for line in lines: + if line.strip() == "## 产品配置差异": + in_section = True + continue + if in_section and line.startswith("## "): + break + if in_section: + section_lines.append(line) + + section_text = "\n".join(section_lines).strip() + # The canonical redirect is ~1-2 sentences. If it contains + # specific model codes, component names, or numeric specs, + # it's inline config content that belongs in a Profile. + # Patterns are centralized in section_constants.CONFIG_SPECIFIC_PATTERNS. + found_patterns = [p for p in CONFIG_SPECIFIC_PATTERNS if p in section_text] + if found_patterns: + issues.append( + f"Symptom index.md '## 产品配置差异' contains " + f"config-specific content ({', '.join(found_patterns[:3])}). " + f"This belongs in profile/<profile_id>.md, not index.md. " + f"index.md should only have a redirect sentence like: " + f"'不同配置下的失效机理和诊断路径存在差异,详见对应 Symptom Profile。' " + f"(design_729.md §3.6).", + ) + + if issues: + return HookResult( + hook_name=self.name, + passed=False, + message=" | ".join(issues), + severity="error", + ) + + return HookResult( + hook_name=self.name, + passed=True, + message=f"Directory structure OK for {concept}/{class_name}/{object_name}.", + ) diff --git a/src/wolfharness/capabilities/wiki/hooks/frontmatter_schema.py b/src/wolfharness/capabilities/wiki/hooks/frontmatter_schema.py new file mode 100644 index 000000000..db3f9fcec --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/frontmatter_schema.py @@ -0,0 +1,187 @@ +"""Frontmatter schema compliance hook. + +Validates that entity frontmatter only contains fields allowed by the +concept's schema definition (design_729.md §3). Non-schema fields are +rejected at write time with severity ``"error"``, blocking the write +so the worker must fix field names before the draft lands on disk. + +Allowed field sets are loaded dynamically from +``xeno_adp_agentic/wiki/templates/default_schema.yaml`` via +:mod:`wolfharness.capabilities.wiki.schema_loader`, eliminating hardcoded drift. +""" + +from __future__ import annotations + +import re + +from wolfharness.capabilities.wiki.schema_loader import ( + get_all_concept_frontmatter_fields, + get_profile_frontmatter_fields, +) + +from .base import BaseHook, HookResult + + +# ── Allowed frontmatter fields ─────────────────────────────────────────── +# Loaded once from the YAML schema (cached via lru_cache in schema_loader). +# ``_COMMON_FIELDS`` and ``_OP_FIELDS`` are fallbacks for concepts not +# present in the YAML (OP is a synthetic concept for OPA/OPS records). + +_ALLOWED_FIELDS: dict[str, frozenset[str]] = get_all_concept_frontmatter_fields() + +_PROFILE_FIELDS: frozenset[str] = get_profile_frontmatter_fields() + +# Fallback for concepts absent from the YAML (e.g. OP). +_COMMON_FIELDS: frozenset[str] = frozenset( + { + "id", + "title", + "description", + "status", + "sources", + "aliases", + "class_name", + "object_name", + }, +) + +_OP_FIELDS: frozenset[str] = frozenset( + { + "id", + "title", + "description", + "status", + "sources", + }, +) + +# Internal pipeline markers injected by merge-conflict detection; not part +# of the YAML schema but must not block rewrites of conflict-marked pages. +_INTERNAL_PIPELINE_FIELDS: frozenset[str] = frozenset( + { + "conflict_pending", + "conflict_refs", + }, +) + +# Fields that are universally optional across entity concepts (not OP). +_SHARED_OPTIONAL_FIELDS: frozenset[str] = frozenset( + { + "applicable_models", + }, +) + +# Frontmatter delimiter pattern +_FM_DELIMITER_RE = re.compile(r"^---\s*$", re.MULTILINE) + + +def _extract_frontmatter_fields(content: str) -> tuple[set[str], bool]: + """Extract field names from YAML frontmatter. + + Returns ``(field_names, is_profile)`` where ``is_profile`` indicates + whether this looks like a Symptom Profile file (has ``parent_symptom`` + or ``profile_id`` field). + """ + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return set(), False + + fields: set[str] = set() + is_profile = False + in_fm = True + + for line in lines[1:]: + if line.strip() == "---": + break + if not in_fm: + continue + # Match top-level field: "key:" or "key: value" + # Skip nested items (indented under lists/dicts) + if line and not line[0].isspace() and not line.startswith("#"): + m = re.match(r"^(\w[\w_]*)\s*:", line) + if m: + field = m.group(1) + fields.add(field) + if field in ("profile_id", "parent_symptom"): + is_profile = True + + return fields, is_profile + + +class FrontmatterSchemaHook(BaseHook): + """Check that entity frontmatter only contains schema-allowed fields. + + Non-schema fields are rejected with severity ``"error"`` at write time, + so the worker must fix the field name (e.g. ``profile:`` → ``profile_resource:``) + before the draft is written. + """ + + @property + def name(self) -> str: + return "frontmatter_schema" + + def check( + self, + content: str, + concept: str = "", + class_name: str = "", + object_name: str = "", + ) -> HookResult: + fields, is_profile = _extract_frontmatter_fields(content) + + if not fields: + return HookResult( + hook_name=self.name, + passed=True, + message="No frontmatter found; schema check skipped.", + ) + + # Profile files use a different field set. + # Detect via concept parameter (authoritative) OR frontmatter fields + # (fallback for files missing profile_id/parent_symptom). + if concept == "SymptomProfile" or is_profile: + allowed = _PROFILE_FIELDS | _COMMON_FIELDS | _INTERNAL_PIPELINE_FIELDS + extra = fields - allowed + if extra: + return HookResult( + hook_name=self.name, + passed=False, + message=( + f"Profile frontmatter has {len(extra)} non-schema field(s): {', '.join(sorted(extra))}. Fix the field names to match the schema; write is blocked." + ), + severity="error", + ) + return HookResult( + hook_name=self.name, + passed=True, + message="Profile frontmatter fields are schema-compliant.", + ) + + # Regular entity: check against concept-specific allowed fields. + # OP is not in the YAML schema; fall back to its explicit field set, + # then to _COMMON_FIELDS for any other unknown concept. + if concept == "OP": + allowed = _OP_FIELDS + else: + allowed = _ALLOWED_FIELDS.get(concept, _COMMON_FIELDS) | _SHARED_OPTIONAL_FIELDS + allowed = allowed | _INTERNAL_PIPELINE_FIELDS + extra = fields - allowed + + if extra: + return HookResult( + hook_name=self.name, + passed=False, + message=( + f"Entity frontmatter has {len(extra)} non-schema " + f"field(s) for concept '{concept}': " + f"{', '.join(sorted(extra))}. " + f"Fix the field names to match the schema; write is blocked." + ), + severity="error", + ) + + return HookResult( + hook_name=self.name, + passed=True, + message=f"Frontmatter fields are schema-compliant for '{concept}'.", + ) diff --git a/src/wolfharness/capabilities/wiki/hooks/lightweight_materialization.py b/src/wolfharness/capabilities/wiki/hooks/lightweight_materialization.py new file mode 100644 index 000000000..187fc1404 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/lightweight_materialization.py @@ -0,0 +1,148 @@ +"""Enforce lightweight, connected entity materialization. + +The general wiki is a navigation and procedure layer, not a second copy of +model-specific parameter tables or wiring diagrams. This hook runs for both +candidate and published writes so parameter-heavy pages are rejected before +they enter the store. +""" + +from __future__ import annotations + +import re + +from wolfharness.capabilities.wiki.quality import ( + extract_sections, + parse_frontmatter, + wiki_uri_prefix, +) +from wolfharness.capabilities.wiki.section_constants import ( + SECTION_JUDGMENT_CRITERIA, + SECTION_OPERATION_STEPS, + SECTION_PREREQUISITES, + SECTION_REQUIRED_TOOLS, + SECTION_SOURCE, +) + +from .base import BaseHook, HookResult + + +_MEASURED_VALUE_RE = re.compile( + r"(?<![A-Za-z])[-+]?\d+(?:\.\d+)?\s*" + r"(?:mV|kV|V|mA|A|Ω|ohm|MPa|kPa|bar|rpm|r/min|℃|°C|mm|cm|kg|" + r"L/min|mL/min|N[·.]?m|Hz|ms|s|%)\b", + re.IGNORECASE, +) +_WIRING_DETAIL_RE = re.compile( + r"(?:针脚|针位|端子|Pin|线号|线色|导线).{0,24}" + r"(?:\d+\s*(?:号|#)?|红|黑|白|蓝|黄|绿|棕|灰|紫|橙)", + re.IGNORECASE, +) +_RAW_DELEGATION_RE = re.compile( + r"(?:不在.{0,12}(?:复制|记录)|不(?:复制|记录)|以.{0,16}原始章节为准|" + r"按.{0,16}原始章节|查阅.{0,16}原始章节)", + re.IGNORECASE, +) + + +def _as_uri_list(value: object) -> list[str]: + if isinstance(value, str): + return [value] if value.strip() else [] + if isinstance(value, list): + return [item for item in value if isinstance(item, str) and item.strip()] + if isinstance(value, dict): + return [item for item in value.values() if isinstance(item, str) and item.strip()] + return [] + + +def _has_concept_uri(values: list[str], concept: str) -> bool: + prefix = f"{wiki_uri_prefix()}/{concept}/" + return any(value.startswith(prefix) for value in values) + + +def _unstable_detail_lines(text: str) -> list[str]: + """Return copied model-specific values or wiring-map details.""" + offending: list[str] = [] + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or _RAW_DELEGATION_RE.search(line): + continue + if _MEASURED_VALUE_RE.search(line) or _WIRING_DETAIL_RE.search(line): + offending.append(line) + return offending + + +class LightweightMaterializationHook(BaseHook): + """Block parameter-heavy Procedure nodes and general pages carrying + model-specific measured values or wiring maps. + """ + + @property + def name(self) -> str: + return "lightweight_materialization" + + def check( + self, + content: str, + concept: str = "", + class_name: str = "", + object_name: str = "", + ) -> HookResult: + frontmatter = parse_frontmatter(content) + sections = extract_sections(content) + issues: list[str] = [] + + if concept == "Procedure": + target_components = _as_uri_list(frontmatter.get("target_components")) + if not _has_concept_uri(target_components, "Component"): + issues.append("Procedure must reference at least one real target Component URI") + + steps = sections.get(SECTION_OPERATION_STEPS, "").strip() + if not steps or not re.search(r"^\s*(?:\d+[.)]|[-*])\s+\S", steps, re.MULTILINE): + issues.append("Procedure must contain reusable ordered actions") + + checked_text = "\n".join( + sections.get(name, "") + for name in ( + SECTION_PREREQUISITES, + SECTION_REQUIRED_TOOLS, + SECTION_OPERATION_STEPS, + SECTION_JUDGMENT_CRITERIA, + ) + ) + unstable = _unstable_detail_lines(checked_text) + if unstable: + issues.append( + "Procedure contains unstable numeric/wiring detail; keep the reusable action " + "and delegate model-specific values, wire numbers, colors and pin maps to the " + "cited raw chapter: " + "; ".join(unstable[:3]), + ) + + elif concept in {"Component", "Device", "DTC", "Symptom"}: + # General pages may retain identity codes and DTC codes, but not + # model-specific measured values or wiring maps copied into body + # sections. + body_without_sources = "\n".join( + body for name, body in sections.items() if name != SECTION_SOURCE + ) + unstable = _unstable_detail_lines(body_without_sources) + if unstable: + issues.append( + f"{concept} contains unstable numeric/wiring detail; retain the general fact " + "and raw source link instead: " + "; ".join(unstable[:3]), + ) + + if issues: + return HookResult( + hook_name=self.name, + passed=False, + message=" | ".join(issues), + severity="error", + ) + + # Keep this hook focused on materialization policy; graph closure for + # other concepts remains in relationship/audit checks. + return HookResult( + hook_name=self.name, + passed=True, + message="Entity satisfies lightweight materialization policy.", + ) diff --git a/src/wolfharness/capabilities/wiki/hooks/reference_integrity.py b/src/wolfharness/capabilities/wiki/hooks/reference_integrity.py new file mode 100644 index 000000000..39d7ae92a --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/reference_integrity.py @@ -0,0 +1,217 @@ +"""Reference integrity validation hook. + +Validates that wiki URI references in entity body content are well-formed +and flags placeholder text that indicates incomplete references. Wiki +URIs use the configured wiki root (``<root_uri>/<Concept>/...``, e.g. +``viking://resources/<namespace>/...``). + +Two types of checks: + +1. **Placeholder detection** — flags body text like "待 URI 关联补充" or + "待 Fault 关联补充" that indicates a relationship gap. These should be + resolved by relation_worker when the target entity exists, not deleted by + file_operator. + +2. **URI format validation** — checks that wiki URIs in the body + use 24-char hex hash format (not Chinese descriptive paths). This + overlaps with URIIntegrityHook but focuses on body references rather + than the entity heading. + +3. **Dangling reference detection** — flags wiki URIs that appear + in frontmatter relation fields but are empty lists or contain + placeholder values. + +Note: Actual existence checking (does the URI point to a real file?) +requires access to the WikiStore and is not performed by this hook. +That check should be done by the conductor in the post-processing phase. +""" + +from __future__ import annotations + +import re + +from wolfharness.capabilities.wiki.quality import ( + extract_malformed_wiki_uris, + is_wiki_uri, + parse_frontmatter, +) + +from .base import BaseHook, HookResult + + +# Body placeholder patterns indicating incomplete URI references +_PLACEHOLDER_PATTERNS: list[re.Pattern[str]] = [ + re.compile(r"待\s*URI\s*关联补充"), + re.compile(r"待\s*Fault\s*关联补充"), + re.compile(r"待\s*Procedure\s*关联补充"), + re.compile(r"待\s*Component\s*关联补充"), + re.compile(r"待\s*Symptom\s*关联补充"), + re.compile(r"待\s*DTC\s*关联补充"), +] + +# Frontmatter relation fields that should contain URIs +_RELATION_FIELDS: frozenset[str] = frozenset( + { + "parent_symptom", + "device_refs", + "symptom_refs", + "direct_component_uri", + "critical_components", + "affected_components", + "verification_procedures", + "repair_procedures", + "assembly_parts", + "target_components", + "specification_refs", + "related_faults", + "controller_component", + "possible_faults", + }, +) + + +def _uri_values(value: object) -> list[str]: + """Collect scalar values from nested relation fields.""" + if isinstance(value, str): + return [value.strip()] if value.strip() else [] + if isinstance(value, dict): + values: list[str] = [] + for nested in value.values(): + values.extend(_uri_values(nested)) + return values + if isinstance(value, list): + values = [] + for nested in value: + values.extend(_uri_values(nested)) + return values + return [] + + +def _extract_body(content: str) -> str: + """Strip YAML frontmatter, return body only.""" + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return content + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + return "\n".join(lines[i + 1 :]) + return content + + +def _extract_frontmatter(content: str) -> str: + """Extract YAML frontmatter text.""" + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return "" + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + return "\n".join(lines[1:i]) + return "" + + +class ReferenceIntegrityHook(BaseHook): + """Check that URI references in entity content are complete and well-formed. + + Flags: + - Placeholder text ("待 URI 关联补充" etc.) in body sections + - Chinese descriptive paths in wiki URIs (should be hex hashes) + - Empty relation fields in frontmatter that should contain URIs + """ + + @property + def name(self) -> str: + return "reference_integrity" + + def check( + self, + content: str, + concept: str = "", + class_name: str = "", + object_name: str = "", + ) -> HookResult: + body = _extract_body(content) + frontmatter = _extract_frontmatter(content) + + issues: list[str] = [] + invalid_relations: list[str] = [] + + # ── 1. Placeholder detection in body ───────────────────────────── + placeholders: list[str] = [] + for pattern in _PLACEHOLDER_PATTERNS: + matches = pattern.findall(body) + if matches: + placeholders.extend(matches) + + if placeholders: + issues.append( + f"Found {len(placeholders)} placeholder reference(s) in body: " + f"{', '.join(set(placeholders))[:200]}. " + f"These are relationship gaps for relation_worker to resolve when evidence exists; " + f"do not delete source-honest gap text just to silence the warning.", + ) + + # ── 2. URI format check in body ────────────────────────────────── + bad_format_uris = [u for u in extract_malformed_wiki_uris(body) if u != ""] + + if bad_format_uris: + issues.append( + f"Found {len(bad_format_uris)} malformed wiki URI(s) with empty/object-less/placeholder tails: {', '.join(bad_format_uris[:3])}", + ) + + # ── 3. Empty relation fields in frontmatter ────────────────────── + empty_relations: list[str] = [] + fm_lines = frontmatter.splitlines() + for i, line in enumerate(fm_lines): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + # Match "field: value" (inline value) + m = re.match(r"^(\w[\w_]*)\s*:\s*(.*)", stripped) + if m: + field = m.group(1) + value = m.group(2).strip() + if field in _RELATION_FIELDS and value in ("[]", "''", '""', "", "~", "null"): + # Check if next line is a list item (YAML multiline list) + # If so, the field is NOT empty — it has items on following lines + has_list_items = False + for next_line in fm_lines[i + 1 :]: + next_stripped = next_line.strip() + if next_stripped == "---" or (next_line and not next_line[0].isspace()): + break + if next_stripped.startswith("- "): + has_list_items = True + break + if not has_list_items: + empty_relations.append(field) + + if empty_relations: + issues.append( + f"Empty relation field(s) in frontmatter: {', '.join(empty_relations)}. These should be filled by relation_worker.", + ) + + frontmatter_values = parse_frontmatter(content) + for field in _RELATION_FIELDS: + for value in _uri_values(frontmatter_values.get(field)): + if is_wiki_uri(value): + continue + invalid_relations.append(f"{field}={value}") + if invalid_relations: + issues.append( + "Relation field(s) contain non-resolvable wiki identifiers: " + + ", ".join(invalid_relations[:5]) + + ". Use a real wiki URI or leave an explicit open_gap.", + ) + + if issues: + return HookResult( + hook_name=self.name, + passed=False, + message=" | ".join(issues), + severity="error" if invalid_relations else "warning", + ) + + return HookResult( + hook_name=self.name, + passed=True, + message="All URI references are complete and well-formed.", + ) diff --git a/src/wolfharness/capabilities/wiki/hooks/relationship_completeness.py b/src/wolfharness/capabilities/wiki/hooks/relationship_completeness.py new file mode 100644 index 000000000..f711566d4 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/relationship_completeness.py @@ -0,0 +1,129 @@ +"""Confirmation-time relationship completeness validation.""" + +from __future__ import annotations + +import re + +from wolfharness.capabilities.wiki.section_constants import SECTION_CONTROLLER_IDENTITY + +from .base import BaseHook, HookResult + + +_PLACEHOLDER_RE = re.compile( + r"待\s*(?:URI|Fault|Procedure|Component|Symptom|DTC)?\s*(?:关联)?补充|待后续补充", + re.IGNORECASE, +) + + +def _extract_frontmatter(content: str) -> str: + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return "" + for index, line in enumerate(lines[1:], 1): + if line.strip() == "---": + return "\n".join(lines[1:index]) + return "" + + +def _field_value(frontmatter: str, field: str) -> str: + lines = frontmatter.splitlines() + for index, line in enumerate(lines): + match = re.match(rf"^{re.escape(field)}\s*:\s*(.*)$", line) + if match is None: + continue + inline = match.group(1).strip() + if inline and inline not in {"[]", "null", "~", "''", '""'}: + return inline + items: list[str] = [] + for following in lines[index + 1 :]: + if following and not following[0].isspace(): + break + stripped = following.strip() + if stripped.startswith("- "): + items.append(stripped[2:].strip()) + return "\n".join(items) + return "" + + +class RelationshipCompletenessHook(BaseHook): + """Block ``confirmed`` entities whose required graph edges are missing.""" + + @property + def name(self) -> str: + return "relationship_completeness" + + def check( + self, + content: str, + concept: str = "", + class_name: str = "", + object_name: str = "", + ) -> HookResult: + frontmatter = _extract_frontmatter(content) + if _field_value(frontmatter, "status").strip("\"'") != "confirmed": + return HookResult( + hook_name=self.name, + passed=True, + message="Relationship completeness is enforced when status is confirmed.", + ) + + missing: list[str] = [] + if _field_value(frontmatter, "profile_id"): + if not _field_value(frontmatter, "device_refs"): + missing.append("device_refs") + if not _field_value(frontmatter, "direct_component_uri"): + missing.append("direct_component_uri") + if not _field_value(frontmatter, "possible_faults"): + missing.append("possible_faults") + elif concept == "Device": + if not _field_value(frontmatter, "critical_components"): + missing.append("critical_components") + if not _field_value(frontmatter, "symptom_refs"): + missing.append("symptom_refs") + elif concept == "DTC": + controller_section = ( + content.split(f"## {SECTION_CONTROLLER_IDENTITY}", 1)[-1] + if f"## {SECTION_CONTROLLER_IDENTITY}" in content + else "" + ) + if not _field_value(frontmatter, "controller_role"): + missing.append("controller_role") + if not _field_value(frontmatter, "controller_component") and not re.search( + r"open_gap|未提供|未确认|未说明|未知", + controller_section, + re.IGNORECASE, + ): + missing.append("controller_component or explicit controller identity gap") + if not _field_value(frontmatter, "related_faults"): + missing.append("related_faults") + elif concept == "Fault": + if not _field_value(frontmatter, "affected_components"): + missing.append("affected_components") + if not ( + _field_value(frontmatter, "verification_procedures") + or _field_value(frontmatter, "repair_procedures") + ): + missing.append("verification_procedures|repair_procedures") + elif concept == "Procedure": + if not _field_value(frontmatter, "target_components"): + missing.append("target_components") + if class_name == "diagnosis" and not _field_value(frontmatter, "procedure_scope"): + missing.append("procedure_scope") + + if _PLACEHOLDER_RE.search(content): + missing.append("unresolved_placeholders") + + if missing: + return HookResult( + hook_name=self.name, + passed=False, + message=( + f"Entity cannot become confirmed; missing or unresolved: {', '.join(missing)}." + ), + severity="error", + ) + return HookResult( + hook_name=self.name, + passed=True, + message="Confirmed entity has the required relationship fields.", + ) diff --git a/src/wolfharness/capabilities/wiki/hooks/source_ref.py b/src/wolfharness/capabilities/wiki/hooks/source_ref.py new file mode 100644 index 000000000..40749e549 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/source_ref.py @@ -0,0 +1,90 @@ +"""Source reference format hook. + +Validates that source reference tags (``[Menu]``, ``[Case]``) in entity +body text are accompanied by a ``viking://`` or ``file://`` URI. Bare tags +without URIs should be stripped during post-processing (step 16), per +design_717.md. + +Example of valid reference: + ``[Menu][viking://resources/<namespace>/.../chapters/.../chapter.md]`` + +Example of bare reference (flagged): + ``[Menu]`` + ``[Case] some text`` +""" + +from __future__ import annotations + +import re + +from .base import BaseHook, HookResult + + +# Bare source tags: [Menu] or [Case] NOT followed by a [scheme://...] URI +# Matches [Menu] or [Case] that are not immediately followed by a recognised +# source URI scheme. +_BARE_TAG_RE = re.compile(r"\[(Menu|Case)\](?!\[[a-z][a-z0-9+.\-]*://)") + +# Valid source tags: [Menu][viking://...] / [Case][file://...] +_VALID_TAG_RE = re.compile(r"\[(Menu|Case)\]\[[a-z][a-z0-9+.\-]*://[^\]]+\]") + + +class SourceReferenceHook(BaseHook): + """Check that source reference tags have accompanying URIs. + + Per design_717.md post-processing step 16: + "裸引用标签清洗(剥离无 URI 的 [Menu]/[Case])" + + This hook flags bare ``[Menu]`` and ``[Case]`` tags that lack a + following ``[viking://...]`` or ``[file://...]`` source URI link, so + they can be cleaned up. + """ + + @property + def name(self) -> str: + return "source_ref" + + def check( + self, + content: str, + concept: str = "", + class_name: str = "", + object_name: str = "", + ) -> HookResult: + # Extract frontmatter to skip it (sources field in frontmatter + # uses a different format — list of URIs, not [Menu][...] tags) + lines = content.splitlines() + body_start = 0 + if lines and lines[0].strip() == "---": + for i, line in enumerate(lines[1:], 1): + if line.strip() == "---": + body_start = i + 1 + break + + body = "\n".join(lines[body_start:]) + + bare_matches = list(_BARE_TAG_RE.finditer(body)) + valid_count = len(_VALID_TAG_RE.findall(body)) + + if bare_matches: + bare_tags = [m.group(0) for m in bare_matches] + return HookResult( + hook_name=self.name, + passed=False, + message=( + f"Found {len(bare_tags)} bare source tag(s) without URI: " + f"{', '.join(bare_tags[:5])}" + f"{'...' if len(bare_tags) > 5 else ''}. " + f"These will be stripped in post-processing. " + f"({valid_count} valid tagged reference(s) found.)" + ), + severity="warning", + ) + + return HookResult( + hook_name=self.name, + passed=True, + message=( + f"All source reference tags have URIs. ({valid_count} valid reference(s) found.)" + ), + ) diff --git a/src/wolfharness/capabilities/wiki/hooks/uri_integrity.py b/src/wolfharness/capabilities/wiki/hooks/uri_integrity.py new file mode 100644 index 000000000..3fdb7e303 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/hooks/uri_integrity.py @@ -0,0 +1,84 @@ +"""URI integrity check hook. + +Validates that wiki entity URIs in entity content follow the canonical +readable format ``<root_uri>/<Concept>/<Class>/<Object>`` (where +``<root_uri>`` is the configured wiki root, e.g. +``viking://resources/<namespace>``), or the legacy 24-hex hash form +``<root_uri>/<Concept>/<hash>``. A readable URI is a file path, so it +cannot be a "phantom hash"; only empty / placeholder / object-less tails +are flagged. +""" + +from __future__ import annotations + +from wolfharness.capabilities.wiki.quality import ( + extract_malformed_wiki_uris, + extract_source_uris, + extract_wiki_uris, + is_wiki_uri, +) + +from .base import BaseHook, HookResult + + +class URIIntegrityHook(BaseHook): + """Check that wiki URIs in entity content are well-formed. + + Validates: + - Entity heading URI matches readable or legacy-hash format. + - Body references to ``<root_uri>/...`` are well-formed (no empty, + object-less, or placeholder tails). + """ + + @property + def name(self) -> str: + return "uri_integrity" + + def check( + self, + content: str, + concept: str = "", + class_name: str = "", + object_name: str = "", + ) -> HookResult: + lines = content.splitlines() + + heading_uri = "" + for line in lines: + stripped = line.strip() + if stripped.startswith("# ") and not stripped.startswith("# ---"): + heading_uris = sorted(extract_source_uris(stripped)) + if heading_uris and is_wiki_uri(heading_uris[0]): + heading_uri = heading_uris[0] + break + + if heading_uri and heading_uri not in extract_wiki_uris(heading_uri): + return HookResult( + hook_name=self.name, + passed=False, + message=( + f"Entity heading URI '{heading_uri}' does not match " + f"expected format <root_uri>/<Concept>/<Class>/<Object>." + ), + severity="warning", + ) + + bad_uris = extract_malformed_wiki_uris(content) + if bad_uris: + return HookResult( + hook_name=self.name, + passed=False, + message=( + f"Found {len(bad_uris)} malformed wiki URI(s) with empty/" + f"object-less/placeholder tails: " + f"{', '.join(bad_uris[:3])}" + f"{'...' if len(bad_uris) > 3 else ''}" + ), + severity="warning", + ) + + return HookResult( + hook_name=self.name, + passed=True, + message="All wiki URIs are well-formed.", + ) diff --git a/src/wolfharness/capabilities/wiki/io/__init__.py b/src/wolfharness/capabilities/wiki/io/__init__.py new file mode 100644 index 000000000..6cd2d1039 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/io/__init__.py @@ -0,0 +1 @@ +"""wiki capability subpackage: io.""" diff --git a/src/wolfharness/capabilities/wiki/io/audit.py b/src/wolfharness/capabilities/wiki/io/audit.py new file mode 100644 index 000000000..41c082df7 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/io/audit.py @@ -0,0 +1,990 @@ +"""Audit mixin for WikiBuildTools — full-library quality audit + paginated reporting.""" + +from __future__ import annotations + +from collections import Counter +from hashlib import sha256 +from typing import TypedDict + +from wolfharness.capabilities.wiki.quality import ( + BUILD_PROFILES, + BuildProfile, + CoverageSummary, + IssueDisposition, + QualityIssue, + SourceReadResult, + SourceReadStatus, + WikiAuditReport, + audit_issue_policy, + classify_raw_source_uri, + confirmation_requirements, + entity_status, + extract_malformed_wiki_uris, + extract_source_uris, + extract_wiki_uris, + force_confirmed_status, + has_unresolved_placeholder, + parse_frontmatter, + wiki_uri_prefix, +) +from wolfharness.capabilities.wiki.section_constants import SECTION_COMMON_FAULTS +from wolfharness.capabilities.wiki.tickets.opa import opa_section_names +from wolfharness.capabilities.wiki.validation import ENTITY_VALIDATION_HOOKS, run_entity_validation +from wolfharness.capabilities.wiki.wiki_build_deps import WikiBuildDeps + + +_CASE_SOFT_REQUIREMENT_CODES = { + "Component.assembly_parts", + "Component.procedure.body_link", + "Component.fault.body_link", + "DTC.diagnostic_procedure.body_link", + "Device.diagnostic_chain.body_link", + "Device.dtc.body_link", + "Fault.verification_procedures", + "Fault.repair_procedures", + "Fault.verification_procedure.body_link", + "Fault.repair_procedure.body_link", + "Procedure.target_components", + "SymptomProfile.diagnostic_procedure.body_link", +} + +_GAP_CATEGORY_MAP: dict[str, str] = { + "Fault.symptom.body_link": "dependency_gap", + "DTC.diagnostic_procedure.body_link": "dependency_gap", + "Fault.procedures": "dependency_gap", + "Fault.verification_procedures": "dependency_gap", + "Fault.repair_procedures": "dependency_gap", + "Profile.possible_faults": "dependency_gap", + "dangling_reference": "dependency_gap", + "source_unresolvable": "source_unresolvable", +} + + +_CONTENT_HOOK_NAMES = frozenset({ + "body_sections", +}) + + +def _classify_gap_category( + code: str, + opa_reason_code: str = "", + disposition: str = "", +) -> str: + """Map an audit issue code to a gap triage category. + + Content-completeness hook findings (``hook.body_sections``, etc.) and + ``repair_only`` issues on already-materialized entities are content + limitations, not structural dependency gaps — classify them as + ``data_limitation`` so the finalize gate does not self-lock on its own + promotion artifacts. + """ + if code in _GAP_CATEGORY_MAP: + return _GAP_CATEGORY_MAP[code] + if opa_reason_code and opa_reason_code in _GAP_CATEGORY_MAP: + return _GAP_CATEGORY_MAP[opa_reason_code] + if code.startswith("hook.") and code.removeprefix("hook.") in _CONTENT_HOOK_NAMES: + return "data_limitation" + if disposition == "repair_only": + return "data_limitation" + if "物化身份" in code or "model" in code.lower(): + return "data_limitation" + return "dependency_gap" + + +_TYPED_RELATION_FIELDS: dict[str, dict[str, str]] = { + "Device": {"symptom_refs": "Symptom", "critical_components": "Component"}, + "Component": {"assembly_parts": "Part"}, + "Part": {"parent_components": "Component", "procedure_refs": "Procedure"}, + "DTC": {"controller_component": "Component", "related_faults": "Fault"}, + "SymptomProfile": { + "parent_symptom": "Symptom", + "device_refs": "Device", + "direct_component_uri": "Component", + "possible_faults": "Fault", + }, + "Fault": { + "affected_components": "Component", + "verification_procedures": "Procedure", + "repair_procedures": "Procedure", + }, + "Procedure": {"target_components": "Component"}, +} + +_REPAIR_ACTION_BY_CODE: dict[str, str] = { + "Fault.affected_components": "在 frontmatter affected_components 中链接受影响的部件 URI。", + "Fault.procedures": "为该故障构建验证或修复流程,并在 verification_procedures/repair_procedures 中引用。", + "Fault.verification_procedures": "物化该故障所需的验证流程实体并建立链接。", + "Fault.repair_procedures": "物化该故障所需的修复/更换流程实体并建立链接。", + "Fault.verification_procedure.body_link": "将「验证方法」章节链接到已有的验证流程 URI。", + "Fault.repair_procedure.body_link": "将「修复方式」章节链接到已有的修复/更换流程 URI。", + "Fault.component.body_link": "将「影响范围」章节链接受影响的部件 URI。", + "Fault.symptom.body_link": "将「关联故障现象」章节链接到可引发的故障现象 URI。", + "DTC.controller_role": "在 frontmatter controller_role 中保留源文档确认的控制器功能角色。", + "DTC.related_faults": "物化或链接该 DTC 引发的故障实体。", + "DTC.related_faults.body_link": "将「可能失效机理」章节链接到关联的故障 URI。", + "DTC.diagnostic_procedure.body_link": "物化或链接该 DTC 的诊断流程。", + "SymptomProfile.device_refs": "在 device_refs 中链接该故障现象适用的具体设备实体。", + "SymptomProfile.direct_component_uri": "在 direct_component_uri 中链接该故障现象的直接部件 URI。", + "SymptomProfile.possible_faults": "链接该故障现象可能的故障实体。", + "SymptomProfile.possible_faults.body_link": "将「可能失效机理」章节链接到可能的故障 URI。", + "SymptomProfile.diagnostic_procedure.body_link": "将「推荐诊断流程」章节链接到诊断流程 URI。", + "Device.critical_components": "引用该设备的关重件清单。", + "Device.symptom_refs": "索引该设备可用的故障现象实体。", + "Device.diagnostic_chain.body_link": f"在「{SECTION_COMMON_FAULTS}」表中链接故障现象/故障/部件 URI。", + "Procedure.target_components": "引用该流程的目标部件。", +} + + +def _repair_action_for(code: str, target_concepts: tuple[str, ...]) -> str: + """Return a concrete remediation instruction for a REPAIR_ONLY issue.""" + return _REPAIR_ACTION_BY_CODE.get( + code, + f"物化或链接缺失的{'、'.join(target_concepts) or '目标'}实体,并在本页引用其 URI。", + ) + + +def _resource_uris(value: object, *, raw_root_uri: str = "") -> list[str]: + """Collect resource URIs from YAML lists/maps without using labels. + + Recognises wiki entity URIs (under the active wiki namespace) and + cross-namespace case/fault-annotation evidence URIs (matching by shape, + consistent with opa.py and record_source_packet). + """ + wiki_prefix = wiki_uri_prefix() + "/" + if isinstance(value, str): + stripped = value.strip() + if ( + stripped.startswith(wiki_prefix) + or classify_raw_source_uri( + stripped, + raw_root_uri=raw_root_uri, + ) + is not None + ): + return [stripped] + return [] + if isinstance(value, dict): + result: list[str] = [] + for nested in value.values(): + result.extend(_resource_uris(nested, raw_root_uri=raw_root_uri)) + return result + if isinstance(value, (list, tuple, set)): + result: list[str] = [] + for nested in value: + result.extend(_resource_uris(nested, raw_root_uri=raw_root_uri)) + return result + return [] + + +def _raw_source_uris(value: object, *, raw_root_uri: str) -> list[str]: + """Collect only provenance URIs from a structured field.""" + return [ + uri + for uri in _resource_uris(value, raw_root_uri=raw_root_uri) + if classify_raw_source_uri(uri, raw_root_uri=raw_root_uri) is not None + ] + + +class AuditCache(TypedDict): + """Snapshot of a full-library audit, keyed for fast re-filtering.""" + + profile: BuildProfile + issues: list[QualityIssue] + coverage_counts: dict[str, list[int]] + confirmed_candidates: list[str] + entity_count: int + confirmed_count: int + draft_count: int + deprecated_count: int + snapshot_id: str + source_snapshot_id: str + + +class EntitySnapshotRecord(TypedDict): + """One readable formal entity or Symptom Profile in the build snapshot.""" + + concept: str + class_name: str + object_name: str + uri: str + kind: str + path: str + parent_uri: str + publication_state: str + validation_state: str + review_state: str + content_hash: str + content: str + + +class AuditMixin(WikiBuildDeps): + """Full-library audit and paginated issue reporting.""" + + def _formal_entity_snapshot_records( + self, concept_filter: str = "" + ) -> list[EntitySnapshotRecord]: + """Enumerate the same formal pages used by audit and finalize. + + Symptom Profiles are physical child pages, not top-level entities. + They must nevertheless be part of the publication snapshot with their + real path; otherwise audit and finalize count different populations. + """ + records: list[EntitySnapshotRecord] = [] + for concept, class_name, object_name, uri in self.store.list_entities(): + if concept_filter and concept != concept_filter: + continue + content = self.store.read_entity_by_uri(uri) + if content is None: + continue + path = self.store.entity_path( + concept, + class_name or None, + object_name, + ).relative_to(self.store.root) + records.append( + { + "concept": concept, + "class_name": class_name or "", + "object_name": object_name, + "uri": uri, + "kind": "entity", + "path": str(path), + "parent_uri": "", + "publication_state": str( + parse_frontmatter(content).get("publication_state", "") + ), + "validation_state": str(parse_frontmatter(content).get("validation_state", "")), + "review_state": str(parse_frontmatter(content).get("review_state", "")), + "content_hash": sha256(content.encode("utf-8")).hexdigest(), + "content": content, + }, + ) + if concept != "Symptom": + continue + for profile_id, profile_uri in self.store.list_symptom_profiles(uri): + profile_path = self.store.symptom_profile_path(uri, profile_id) + if not profile_path.is_file(): + continue + profile_content = profile_path.read_text(encoding="utf-8") + records.append( + { + "concept": "Symptom", + "class_name": class_name or "", + "object_name": profile_id, + "uri": profile_uri, + "kind": "profile", + "path": str(profile_path.relative_to(self.store.root)), + "parent_uri": uri, + "publication_state": str( + parse_frontmatter(profile_content).get("publication_state", "") + ), + "validation_state": str( + parse_frontmatter(profile_content).get("validation_state", "") + ), + "review_state": str( + parse_frontmatter(profile_content).get("review_state", "") + ), + "content_hash": sha256(profile_content.encode("utf-8")).hexdigest(), + "content": profile_content, + }, + ) + return sorted(records, key=lambda record: record["uri"]) + + def _tracked_gap_covers_issue( + self, + uri: str, + message: str, + pending_records: list[dict] | None = None, + tracked_cache: dict[str, bool] | None = None, + ) -> bool: + """Downgrade only an explicitly marked, OPA-backed source gap.""" + records = pending_records + if records is None: + records = [ + *self.get_opas(target_uri=uri, category="gap", status="pending"), + *self.get_opas(target_uri=uri, category="conflict", status="pending"), + ] + records = [record for record in records if str(record.get("target_uri", "")) == uri] + if not records: + return False + lowered_message = message.lower() + matching = [ + record + for record in records + if ( + tracked_cache.setdefault( + str(record.get("opa_id", record.get("uri", ""))), + self._is_explicit_tracked_record(record), + ) + if tracked_cache is not None + else self._is_explicit_tracked_record(record) + ) + and any( + name.lower() in lowered_message + for name in opa_section_names( + str(record.get("target_section", "")), + " ".join( + str(record.get(field, "")) + for field in ("description", "finding", "missing", "recommendation") + ), + ) + ) + ] + return bool(matching) + + def audit_wiki( + self, + *, + concept: str = "", + code: str = "", + offset: int = 0, + limit: int = 100, + profile: BuildProfile = "manual", + entity_uris: list[str] | None = None, + force_refresh: bool = False, + ) -> WikiAuditReport: + """Audit formal entities with a paginated actionable issue list. + + When ``entity_uris`` is provided, only those entities are audited + (incremental mode). Otherwise all entities (optionally filtered by + ``concept``) are scanned. + + ``force_refresh`` bypasses the in-memory audit cache. Use it when + workers may have modified entities since the last full audit on this + instance (e.g. before finalize). + """ + if offset < 0: + raise ValueError("audit_wiki offset must be non-negative") + if limit < 1 or limit > 500: + raise ValueError("audit_wiki limit must be between 1 and 500") + audit_profile = profile + if audit_profile not in BUILD_PROFILES: + raise ValueError( + f"audit_wiki profile must be one of: {', '.join(sorted(BUILD_PROFILES))}" + ) + concept_filter = concept + code_filter = code + + # ponytail: stale-cache guard — the cache is per-instance and only + # invalidated by writes on THIS instance. A conductor that never + # writes will serve a frozen snapshot forever. Cheap entity-count + # probe (directory listing with its own TTL cache) catches adds/deletes; + # force_refresh covers content-only patches. + if force_refresh and self._audit_cache is not None: + self._audit_cache = None + if ( + entity_uris is None + and self._audit_cache is not None + and self._audit_cache["profile"] == audit_profile + ): + live_count = len(self.store.list_entities()) + if live_count != self._audit_cache["entity_count"]: + self._audit_cache = None + return self._finalize_audit_report( + issues=self._audit_cache["issues"], + coverage_counts=self._audit_cache["coverage_counts"], + confirmed_candidates=self._audit_cache["confirmed_candidates"], + entity_count=self._audit_cache["entity_count"], + confirmed_count=self._audit_cache["confirmed_count"], + draft_count=self._audit_cache["draft_count"], + deprecated_count=self._audit_cache["deprecated_count"], + snapshot_id=self._audit_cache["snapshot_id"], + source_snapshot_id=self._audit_cache["source_snapshot_id"], + concept_filter=concept_filter, + code_filter=code_filter, + offset=offset, + limit=limit, + audit_profile=audit_profile, + ) + issues: list[QualityIssue] = [] + coverage_counts: dict[str, list[int]] = {} + confirmed_candidates: list[str] = [] + entity_count = 0 + confirmed_count = 0 + draft_count = 0 + deprecated_count = 0 + + snapshot_records = self._formal_entity_snapshot_records(concept_filter) + snapshot_by_uri = {record["uri"]: record for record in snapshot_records} + # A full audit can derive both directions from the in-memory snapshot. + # A concept shard must not read every unrelated page: relation_worker + # rebuilds the persisted backlink index before audit, so filtered + # audits combine the selected pages' forward links with that index. + graph_records = snapshot_records + graph_uris = { + uri for _concept, _class_name, _object_name, uri in self.store.list_entities() + } + graph_uris.update(record["uri"] for record in snapshot_records) + # Concepts already materialized in this build. A relationship check + # whose target concept exists must be repaired (build the link), not + # recorded as a permanent content gap (open_gap / OPA). + root_prefix = self.store.root_uri.rstrip("/") + "/" + materialized_concepts = { + uri.removeprefix(root_prefix).split("/", 1)[0] + for uri in graph_uris + if uri.startswith(root_prefix) + } + incoming_links: Counter[str] = Counter() + outgoing_links: dict[str, set[str]] = {} + for graph_record in graph_records: + source_uri = graph_record["uri"] + targets = { + target.partition("#")[0] + for target in extract_wiki_uris(graph_record["content"]) + if target.partition("#")[0] in graph_uris and target.partition("#")[0] != source_uri + } + outgoing_links[source_uri] = targets + incoming_links.update(targets) + if concept_filter: + for record in snapshot_records: + target_uri = record["uri"] + incoming_links[target_uri] = sum( + 1 + for source_uri in self.store.get_backlinks(target_uri) + if source_uri != target_uri and source_uri in graph_uris + ) + pending_opa_records = [ + *self.get_opas(category="gap", status="pending", limit=10000), + *self.get_opas(category="conflict", status="pending", limit=10000), + ] + pending_opa_by_target: dict[str, list[dict]] = {} + for record in pending_opa_records: + pending_opa_by_target.setdefault(str(record.get("target_uri", "")), []).append(record) + tracked_opa_cache: dict[str, bool] = {} + resource_cache: dict[str, str | None] = {} + raw_source_cache: dict[str, SourceReadResult] = {} + source_hashes: dict[str, str] = {} + + def read_resource_cached(resource_uri: str) -> str | None: + """Avoid rereading the same raw/wiki resource during one audit.""" + if resource_uri not in resource_cache: + resource_cache[resource_uri] = self.read_resource(resource_uri) + return resource_cache[resource_uri] + + def read_raw_source_cached(resource_uri: str) -> SourceReadResult: + """Resolve provenance once and retain hashes for the audit snapshot.""" + if resource_uri not in raw_source_cache: + raw_source_cache[resource_uri] = self.read_raw_source(resource_uri) + result = raw_source_cache[resource_uri] + if result.status is SourceReadStatus.OK and result.content_hash is not None: + source_hashes[resource_uri] = result.content_hash + return result + + entities: list[tuple[str, str, str, str, str]] = [] + if entity_uris is not None and entity_uris: + for target_uri in entity_uris: + record = snapshot_by_uri.get(target_uri) + if record is None: + issues.append( + { + "uri": target_uri, + "concept": "", + "code": "entity_target_missing", + "severity": "error", + "message": "请求的实体 URI 不在存储快照中。", + }, + ) + continue + entities.append( + ( + record["concept"], + record["class_name"], + record["object_name"], + record["uri"], + record["content"], + ), + ) + else: + for record in snapshot_records: + if concept_filter and record["concept"] != concept_filter: + continue + entities.append( + ( + record["concept"], + record["class_name"], + record["object_name"], + record["uri"], + record["content"], + ), + ) + + audit_hooks = tuple( + hook + for hook in ENTITY_VALIDATION_HOOKS + if hook.name not in {"diagnostic_closure", "relationship_completeness", "body_sections"} + ) + snapshot_id = sha256( + "\n".join( + f"{uri}\x1f{sha256(content.encode()).hexdigest()}" + for _concept, _class_name, _object_name, uri, content in sorted( + entities, key=lambda item: item[3] + ) + ).encode(), + ).hexdigest() + for concept, class_name, object_name, uri, content in entities: + entity_count += 1 + _issue_start = len(issues) + status = entity_status(content) + if status == "confirmed": + confirmed_count += 1 + elif status == "deprecated": + deprecated_count += 1 + continue + else: + draft_count += 1 + issues.append( + { + "uri": uri, + "concept": concept, + "code": "unconfirmed_entity", + "severity": "warning", + "message": ("实体在所有确定性确认检查通过前仍为草稿状态。"), + }, + ) + + entity_has_error = False + frontmatter = parse_frontmatter(content) + relation_concept = "SymptomProfile" if "profile_id" in frontmatter else concept + for field, expected_concept in _TYPED_RELATION_FIELDS.get(relation_concept, {}).items(): + for target_uri in _resource_uris(frontmatter.get(field)): + target_base = target_uri.partition("#")[0] + target_identity = self.store.lookup_by_uri(target_base) + if target_identity is None or read_resource_cached(target_uri) is None: + entity_has_error = True + issues.append( + { + "uri": uri, + "concept": concept, + "code": "dangling_relation_target", + "severity": "error", + "message": f"关系字段 {field} 指向无法解析的 URI:{target_uri}", + }, + ) + continue + if target_identity[0] == expected_concept: + continue + entity_has_error = True + issues.append( + { + "uri": uri, + "concept": concept, + "code": "typed_relation_wrong_concept", + "severity": "error", + "message": ( + f"关系字段 {field} 应指向 {expected_concept},但实际指向 {target_identity[0]}:{target_uri}" + ), + }, + ) + + # Build-time writes may contain forward references, but a full + # audit must not promote a page whose body still points at a + # missing entity. Validate profile links and heading fragments + # through read_resource so Symptom Profile children are handled + # as well as ordinary entities. + for target_uri in extract_wiki_uris(content): + target_base = target_uri.partition("#")[0] + if target_base == uri: + continue + if read_resource_cached(target_uri) is not None: + continue + entity_has_error = True + issues.append( + { + "uri": uri, + "concept": concept, + "code": "dangling_wiki_reference", + "severity": "error", + "message": f"正文引用无法解析:{target_uri}", + }, + ) + + if concept == "Procedure": + for specification_uri in _resource_uris(frontmatter.get("specification_refs")): + component_uri, separator, fragment = specification_uri.partition("#") + target_identity = self.store.lookup_by_uri(component_uri) + specification_issue = "" + if target_identity is None or target_identity[0] != "Component": + specification_issue = "流程 specification_ref 必须指向已存在的部件。" + elif not separator or not fragment.strip(): + specification_issue = ( + "流程 specification_ref 必须包含 Component#spec 片段。" + ) + elif read_resource_cached(specification_uri) is None: + specification_issue = ( + f"流程 specification 片段无法解析:{specification_uri}" + ) + if specification_issue: + entity_has_error = True + issues.append( + { + "uri": uri, + "concept": concept, + "code": "Procedure.specification_ref_unresolvable", + "severity": "error", + "message": specification_issue, + }, + ) + + if relation_concept == "SymptomProfile": + parent_symptom = frontmatter.get("parent_symptom") + direct_component = _resource_uris(frontmatter.get("direct_component_uri")) + for device_uri in _resource_uris(frontmatter.get("device_refs")): + device_content = read_resource_cached(device_uri) + if device_content is None: + continue + device_frontmatter = parse_frontmatter(device_content) + critical_components = _resource_uris( + device_frontmatter.get("critical_components") + ) + if ( + critical_components + and direct_component + and direct_component[0] not in critical_components + ): + entity_has_error = True + issues.append( + { + "uri": uri, + "concept": concept, + "code": "Profile.direct_component_not_in_device_bom", + "severity": "error", + "message": ( + f"画像直接部件 {direct_component[0]} 未列入设备 {device_uri} 的关重件清单。" + ), + }, + ) + if isinstance(parent_symptom, str): + symptom_refs = _resource_uris(device_frontmatter.get("symptom_refs")) + if symptom_refs and parent_symptom not in symptom_refs: + entity_has_error = True + issues.append( + { + "uri": uri, + "concept": concept, + "code": "Profile.parent_symptom_not_indexed_by_device", + "severity": "error", + "message": f"设备 {device_uri} 未索引父级故障现象 {parent_symptom}。", + }, + ) + declared_sources = list( + dict.fromkeys( + _raw_source_uris( + frontmatter.get("sources"), + raw_root_uri=self._raw_fs.root_uri, + ), + ), + ) + body_sources = [ + source_uri + for source_uri in extract_source_uris(content) + if classify_raw_source_uri( + source_uri, + raw_root_uri=self._raw_fs.root_uri, + ) + is not None + ] + source_uris = list(dict.fromkeys([*declared_sources, *body_sources])) + + for source_uri in source_uris: + source_result = read_raw_source_cached(source_uri) + if source_result.status is SourceReadStatus.OK: + continue + entity_has_error = True + issues.append( + { + "uri": uri, + "concept": concept, + "code": "source_unresolvable", + "severity": "error", + "message": f"原始引用无法解析:{source_uri}", + }, + ) + + for requirement in confirmation_requirements( + content, + concept, + class_name, + ): + counts = coverage_counts.setdefault(requirement.code, [0, 0]) + counts[0] += 1 + if requirement.complete: + counts[1] += 1 + continue + disposition = requirement.disposition.value + if requirement.target_concepts and materialized_concepts.intersection( + requirement.target_concepts + ): + disposition = IssueDisposition.REPAIR_ONLY.value + severity = ( + "warning" + if audit_profile == "case" and requirement.code in _CASE_SOFT_REQUIREMENT_CODES + else "error" + ) + # REPAIR_ONLY means the target concept is already materialized: + # the missing link/entity can and must be built. A pending OPA + # must never downgrade that into a warning, or finalize would + # publish the page with an unfixed relationship. + if ( + severity == "error" + and disposition != IssueDisposition.REPAIR_ONLY.value + and self._tracked_gap_covers_issue( + uri, + f"{requirement.code}: {requirement.message}", + pending_opa_by_target.get(uri, []), + tracked_opa_cache, + ) + ): + severity = "warning" + if severity == "error": + entity_has_error = True + issue: QualityIssue = { + "uri": uri, + "concept": concept, + "code": requirement.code, + "severity": severity, + "message": requirement.message, + "disposition": disposition, + "opa_reason_code": requirement.opa_reason_code, + } + if disposition == IssueDisposition.REPAIR_ONLY.value: + issue["repair_action"] = _repair_action_for( + requirement.code, requirement.target_concepts + ) + issues.append(issue) + + if has_unresolved_placeholder(content): + entity_has_error = True + issues.append( + { + "uri": uri, + "concept": concept, + "code": "unresolved_placeholder", + "severity": "error", + "message": ("正式内容仍包含未解决的 agent TODO 或关系占位符。"), + }, + ) + + validation_results = run_entity_validation( + content=force_confirmed_status(content), + concept=concept, + class_name=class_name, + object_name=object_name, + hooks=audit_hooks, + ) + for result in validation_results: + if result.passed: + continue + # ponytail: content-grade hooks (body evidence, source refs, + # lightweight materialization) are structural gates only at + # finalize; on draft entities demote to warning so mid-build + # audits surface them as backlog instead of blocking the loop. + # Structural hooks (reference integrity, directory structure, + # taxonomy, schema, URI) stay errors at every stage. + severity = result.severity + message = result.message + if ( + status != "confirmed" + and result.severity == "error" + and result.hook_name + in ( + "body_evidence", + "body_sections", + "source_ref", + "lightweight_materialization", + ) + ): + severity = "warning" + message = f"[draft] {message}" + if severity == "error": + entity_has_error = True + issues.append( + { + "uri": uri, + "concept": concept, + "code": f"hook.{result.hook_name}", + "severity": severity, + "message": message, + }, + ) + + for target_uri in extract_wiki_uris(content): + if read_resource_cached(target_uri) is not None: + continue + entity_has_error = True + issues.append( + { + "uri": uri, + "concept": concept, + "code": "dangling_reference", + "severity": "error", + "message": f"引用资源无法解析:{target_uri}", + }, + ) + + for bad_uri in extract_malformed_wiki_uris(content): + entity_has_error = True + issues.append( + { + "uri": uri, + "concept": concept, + "code": "malformed_uri", + "severity": "error", + "message": f"wiki URI 格式错误(空/占位符/无对象尾段):{self.store.root_uri}/.../{bad_uri or '(空)'}", + }, + ) + + # ponytail: draft entities with structural errors (hook.*, + # dangling refs, malformed URIs, etc.) classify as + # data_limitation so finalize proceeds — the entity stays + # draft (entity_has_error=True) and issues remain visible. + # Confirmed entities keep dependency_gap → finalize blocks. + if status != "confirmed": + for _issue in issues[_issue_start:]: + if _issue.get("severity") == "error" and "gap_category" not in _issue: + _issue["gap_category"] = "data_limitation" + + if not entity_has_error and status != "confirmed": + confirmed_candidates.append(uri) + + if entity_count == 0: + issues.append( + { + "uri": self.store.root_uri + "/", + "concept": "", + "code": "empty_wiki", + "severity": "error", + "message": "空 wiki 无法完成定稿。", + }, + ) + + for issue in issues: + if "disposition" in issue: + continue + policy = audit_issue_policy(issue["code"]) + if policy is None: + continue + issue["disposition"] = policy.disposition.value + if policy.opa_reason_code: + issue["opa_reason_code"] = policy.opa_reason_code + + source_snapshot_id = sha256( + "\n".join( + f"{source_uri}\x1f{content_hash}" + for source_uri, content_hash in sorted(source_hashes.items()) + ).encode(), + ).hexdigest() + + relation_coverage: dict[str, CoverageSummary] = {} + for relation_code, counts in sorted(coverage_counts.items()): + eligible, complete = counts + relation_coverage[relation_code] = { + "eligible": eligible, + "complete": complete, + "percent": round((complete / eligible) * 100, 2), + } + + # Only an unfiltered scan is a valid reusable snapshot. A + # concept-filtered audit is intentionally a shard and must never + # replace the cache: doing so makes the next concept appear to have + # zero issues and can create a false finalize decision. + if entity_uris is None and not concept_filter and self._audit_cache is None: + snapshot: AuditCache = { + "profile": audit_profile, + "issues": issues, + "coverage_counts": coverage_counts, + "confirmed_candidates": confirmed_candidates, + "entity_count": entity_count, + "confirmed_count": confirmed_count, + "draft_count": draft_count, + "deprecated_count": deprecated_count, + "snapshot_id": snapshot_id, + "source_snapshot_id": source_snapshot_id, + } + self._audit_cache = snapshot + return self._finalize_audit_report( + issues=issues, + coverage_counts=coverage_counts, + confirmed_candidates=confirmed_candidates, + entity_count=entity_count, + confirmed_count=confirmed_count, + draft_count=draft_count, + deprecated_count=deprecated_count, + snapshot_id=snapshot_id, + source_snapshot_id=source_snapshot_id, + concept_filter=concept_filter, + code_filter=code_filter, + offset=offset, + limit=limit, + audit_profile=audit_profile, + ) + + def _finalize_audit_report( + self, + *, + issues: list[QualityIssue], + coverage_counts: dict[str, list[int]], + confirmed_candidates: list[str], + entity_count: int, + confirmed_count: int, + draft_count: int, + deprecated_count: int, + snapshot_id: str = "", + source_snapshot_id: str = "", + concept_filter: str, + code_filter: str, + offset: int, + limit: int, + audit_profile: str, + ) -> WikiAuditReport: + """Build the paginated audit report from full issue/coverage data.""" + relation_coverage: dict[str, CoverageSummary] = {} + for relation_code, counts in sorted(coverage_counts.items()): + eligible, complete = counts + relation_coverage[relation_code] = { + "eligible": eligible, + "complete": complete, + "percent": round((complete / eligible) * 100, 2), + } + error_count = sum(issue["severity"] == "error" for issue in issues) + warning_count = len(issues) - error_count + issue_counts = dict(Counter(issue["code"] for issue in issues).most_common()) + filtered_issues = [ + issue + for issue in issues + if (not concept_filter or issue["concept"] == concept_filter) + and (not code_filter or issue["code"] == code_filter) + ] + returned_issues = filtered_issues[offset : offset + limit] + for issue in returned_issues: + if "target_uri" not in issue: + issue["target_uri"] = issue.get("uri", "") + if "gap_category" not in issue: + issue["gap_category"] = _classify_gap_category( + issue.get("code", ""), + issue.get("opa_reason_code", ""), + issue.get("disposition", ""), + ) + next_offset = offset + limit if offset + limit < len(filtered_issues) else -1 + return { + # ``status=draft`` is a legacy build marker, not a human-review + # gate. Finalize admits only citation/structure-clean pages (or + # explicitly OPA-backed open gaps) and stamps the independent + # publication/review fields after that gate. + "passed": error_count == 0 and entity_count > 0, + "profile": audit_profile, + "entity_count": entity_count, + "confirmed_count": confirmed_count, + "draft_count": draft_count, + "deprecated_count": deprecated_count, + "snapshot_id": snapshot_id, + "source_snapshot_id": source_snapshot_id, + "error_count": error_count, + "warning_count": warning_count, + "issue_counts": issue_counts, + "filtered_issue_count": len(filtered_issues), + "returned_issue_count": len(returned_issues), + "next_offset": next_offset, + "relation_coverage": relation_coverage, + "confirmed_candidates": confirmed_candidates, + "issues": returned_issues, + } diff --git a/src/wolfharness/capabilities/wiki/io/chapter_scoring.py b/src/wolfharness/capabilities/wiki/io/chapter_scoring.py new file mode 100644 index 000000000..958fb9426 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/io/chapter_scoring.py @@ -0,0 +1,175 @@ +"""Language-agnostic chapter importance scoring via character n-grams. + +Ranks a manual's chapters by how much distinctive technical content they +carry, using only character bigrams learned from the manual itself — no +per-language vocabulary and no entity keyword lists. A chapter that repeats +the manual's technical vocabulary (part names, procedures, fault steps, +measured values) scores high; boilerplate (prefaces, safety notes, bare +schematics) shares few distinctive bigrams and scores near zero. + +Two passes over the manual: + +1. :func:`build_fingerprint` — TF-IDF-lite over all chapters produces the + small set of bigrams that best characterize the manual's technical + content ("the fingerprint"). Ubiquitous grams self-exclude via inverse + document frequency, so the vocabulary adapts to whatever language the + manual is written in. +2. :func:`score_chapter_record` — a chapter's score is the share of + fingerprint bigrams it contains, mapped to a 0-100 scale so scores stay + comparable across manuals of different size/language. + +Pure statistics (Counter/math/re), no model calls, milliseconds across a +whole manual, and fully unit-testable with synthetic corpora. The only +assumptions are the byte-level ``_KEEP_RE`` character whitelist and the HTML +strip — plain text without markup works unchanged. +""" + +from __future__ import annotations + +from collections import Counter +import math +import re + +from wolfharness.capabilities.wiki.section_constants import ADMIN_SECTION_KEYWORDS + + +_HTML_STRIP_RE = re.compile(r"<[^>]+>") +_KEEP_RE = re.compile(r"[^A-Za-z0-9\u4e00-\u9fff]") # letters + digits only +_LETTER_RE = re.compile(r"[A-Za-z\u4e00-\u9fff]") + + +def _clean(content: str) -> str: + """Normalize a chapter to a letter/digit character stream. + + HTML/OCR scaffolding, entities and spaces are dropped (CJK and Latin both + reduce to continuous streams), so character bigrams are language-agnostic. + """ + return _KEEP_RE.sub("", _HTML_STRIP_RE.sub("", content).lower()) + + +def _bigrams(text: str) -> Counter[str]: + if len(text) < 2: + return Counter() + return Counter(text[i : i + 2] for i in range(len(text) - 1)) + + +def build_fingerprint( + chapters: list[str], + *, + size: int = 1000, + min_df: int = 1, +) -> frozenset[str]: + """Select the bigrams that best characterize a manual's technical content. + + Weights each bigram by corpus TF scaled by inverse document frequency + (TF-IDF-lite over chapters as documents); ubiquitous grams (e.g. ``的了`` + or ``th``) self-exclude and distinctive terms surface regardless of + language. Pure-digit or pure-noise grams are dropped. + + Args: + chapters: Raw chapter texts (HTML is stripped internally). + size: Target fingerprint cardinality (top-N by weight). + min_df: Drop grams seen in fewer chapters than this. Default ``1`` + keeps rare-but-technical bigrams in the running for small + corpora; raise it only to suppress OCR junk in very noisy corps. + + Returns: + Immutable set of selected bigrams. + """ + grams_by_doc: list[Counter[str]] = [_bigrams(_clean(c)) for c in chapters] + doc_freq: Counter[str] = Counter() + for grams in grams_by_doc: + doc_freq.update(grams.keys()) + + n_docs = len(grams_by_doc) + idf = { + gram: math.log(n_docs / (1 + count)) + for gram, count in doc_freq.items() + if count >= min_df and _LETTER_RE.search(gram) + } + weight: dict[str, float] = {} + for grams in grams_by_doc: + total = sum(grams.values()) or 1 + for gram, count in grams.items(): + inverse = idf.get(gram) + if inverse is not None: + weight[gram] = weight.get(gram, 0.0) + (count / total) * inverse + return frozenset( + gram for gram, _ in sorted(weight.items(), key=lambda kv: kv[1], reverse=True)[:size] + ) + + +def classify_score(score: float, *, skip_below: float = 10.0, read_above: float = 25.0) -> str: + """Map a 0-100 importance score to a build action. + + Args: + score: Importance score from :func:`score_chapter_record`. + skip_below: Below this → low-value → register no_entity. + read_above: At/above this → high-value → priority read. + + Returns: + ``"skip"``, ``"read"``, or ``"priority"``. + """ + if score < skip_below: + return "skip" + if score >= read_above: + return "priority" + return "read" + + +def score_chapter_record( + content: str, + fingerprint: frozenset[str], + *, + skip_below: float = 10.0, + read_above: float = 25.0, +) -> dict[str, object]: + """Score one chapter against a corpus fingerprint. + + Score = 100 × (distinct fingerprint bigrams present) ÷ fingerprint + size — the share of the manual's technical vocabulary this chapter + carries, on a 0-100 scale comparable across manuals of any size and + language. Chapters that fail to substantively reuse the manual's + vocabulary (prefaces/safety/bare schematics) score near zero and + classify as ``"skip"`` so the model never reads them. + + Args: + content: Raw chapter markdown text. + fingerprint: Fingerprint from :func:`build_fingerprint`. + skip_below: Threshold for ``"skip"`` classification. + read_above: Threshold for ``"priority"`` classification. + + Returns: + ``{"score": float, "action": str, "signal_breakdown": {name: count}}``. + """ + grams = _bigrams(_clean(content)) + hits = 0 + for gram in grams: + if gram in fingerprint: + hits += 1 + score = round(hits * 100 / max(len(fingerprint), 1), 2) + return { + "score": score, + "action": classify_score(score, skip_below=skip_below, read_above=read_above), + "signal_breakdown": {"fingerprint_hits": hits}, + } + + +def should_auto_register_no_entity_from_toc(*, rootsection: str, section: str, title: str) -> bool: + """True when a chapter is administrative boilerplate by its TOC position.""" + probe = f"{rootsection} {section} {title}".lower() + return any(keyword in probe for keyword in ADMIN_SECTION_KEYWORDS) + + +def should_auto_register_no_entity( + content: str, + score_record: dict[str, object] | None, + *, + directory_administrative: bool, +) -> bool: + """True when a chapter is administrative or scores below the skip threshold.""" + if directory_administrative: + return True + if not isinstance(score_record, dict): + return False + return str(score_record.get("action", "")) == "skip" diff --git a/src/wolfharness/capabilities/wiki/io/children.py b/src/wolfharness/capabilities/wiki/io/children.py new file mode 100644 index 000000000..c5829694d --- /dev/null +++ b/src/wolfharness/capabilities/wiki/io/children.py @@ -0,0 +1,972 @@ +"""Children/parent navigation mixin for WikiBuildTools — hierarchical URI drill-down.""" + +from __future__ import annotations + +import re + +from wolfharness.capabilities.wiki.quality import ( + extract_sections, + extract_source_uris, + extract_wiki_uris, + has_usable_procedure_criteria, + is_external_source_uri, + is_raw_chapter_uri as is_registered_raw_chapter_uri, + is_source_uri_scheme, + parse_frontmatter, +) +from wolfharness.capabilities.wiki.storage import viking_list_children +from wolfharness.capabilities.wiki.wiki_build_deps import WikiBuildDeps + + +_RELATION_FIELDS: dict[str, tuple[str, ...]] = { + "Device": ("critical_components", "symptom_refs"), + "Component": ("assembly_parts",), + "SymptomProfile": ("device_refs", "direct_component_uri", "possible_faults"), + "Fault": ( + "affected_components", + "verification_procedures", + "repair_procedures", + ), + "DTC": ("controller_component", "related_faults"), + "Procedure": ("target_components", "specification_refs"), +} + +_TOP_LEVEL_STEP_RE = re.compile(r"^\s*(?:\d+[.)]|[-*])\s+(.+?)\s*$") +_TRACE_GAP_RE = re.compile(r"(?:open_gap|待补充|来源未说明|未物化|来源缺失)", re.IGNORECASE) + + +def _uri_values(root_uri: str, value: object) -> list[str]: + """Collect wiki URIs from a YAML scalar/list/map without trusting names.""" + prefix = root_uri + "/" + if isinstance(value, str): + return [value.strip()] if value.strip().startswith(prefix) else [] + if isinstance(value, dict): + result: list[str] = [] + for nested in value.values(): + result.extend(_uri_values(root_uri, nested)) + return result + if isinstance(value, (list, tuple, set)): + result = [] + for nested in value: + result.extend(_uri_values(root_uri, nested)) + return result + return [] + + +def _source_values(value: object) -> list[str]: + """Collect source/resource URIs from frontmatter values.""" + if isinstance(value, str): + return [value.strip()] if is_source_uri_scheme(value.strip()) else [] + if isinstance(value, dict): + result: list[str] = [] + for nested in value.values(): + result.extend(_source_values(nested)) + return result + if isinstance(value, (list, tuple, set)): + result = [] + for nested in value: + result.extend(_source_values(nested)) + return result + return [] + + +def _section_at(content: str, position: int) -> tuple[str, str]: + """Return the nearest ``##`` heading and its state for a body position.""" + body_start = content.find("\n---\n") + body = content[body_start + 5 :] if body_start >= 0 else content + body_offset = body_start + 5 if body_start >= 0 else 0 + local_position = max(0, position - body_offset) + heading = "" + heading_position = -1 + for match in re.finditer(r"^##\s+(.+?)\s*$", body, re.MULTILINE): + if match.start() > local_position: + break + heading = match.group(1).strip() + heading_position = match.end() + next_heading = re.search(r"^##\s+.+?$", body[local_position:], re.MULTILINE) + section_text = body[ + heading_position : local_position + (next_heading.start() if next_heading else len(body)) + ] + lowered = section_text.lower() + if any(marker in lowered for marker in ("open_gap", "待补充", "缺失", "未提供")): + state = "open_gap" + elif "冲突" in section_text: + state = "conflict_pending" + else: + state = "complete" + return heading, state + + +def _is_raw_chapter_uri(uri: str) -> bool: + """Return whether ``uri`` is a resolvable source citation. + + Recognizes real chapter paths (``{root}/<doc>/chapters/<subdir>/chapter.md``) + and external MCP source URIs (any non-viking/non-file scheme). + """ + return is_registered_raw_chapter_uri(uri) or is_external_source_uri(uri) + + +def _raw_source_values(values: list[str]) -> list[str]: + """Keep chapter citations that this trace can resolve through ``read_resource``.""" + return [uri for uri in dict.fromkeys(values) if _is_raw_chapter_uri(uri)] + + +def _content_source_values(content: str, frontmatter: dict[str, object]) -> list[str]: + """Collect page and body raw citations without inventing provenance.""" + return list( + dict.fromkeys( + [ + *_source_values(frontmatter.get("sources")), + *(uri for uri in extract_source_uris(content) if _is_raw_chapter_uri(uri)), + ], + ), + ) + + +def _diagnostic_step_items(section: str) -> list[str]: + """Return top-level diagnostic steps without interpreting their prose.""" + return [ + match.group(1).strip() + for line in section.splitlines() + if (match := _TOP_LEVEL_STEP_RE.match(line)) is not None + ] + + +def _step_evidence(section: str) -> list[dict[str, object]]: + """Return raw evidence attached to each numbered/list procedure step.""" + evidence: list[dict[str, object]] = [] + for step in _diagnostic_step_items(section): + direct_sources = [uri for uri in extract_source_uris(step) if _is_raw_chapter_uri(uri)] + evidence.append( + { + "step": step, + "raw_source_uris": direct_sources, + "effective_source_uris": direct_sources, + "has_local_evidence": bool(direct_sources), + }, + ) + return evidence + + +class ChildrenMixin(WikiBuildDeps): + """Unified hierarchical browsing by URI (wiki entities, case files, fault-annotated docs).""" + + def _discover_devices_for_symptom(self, symptom_uri: str) -> list[dict[str, object]]: + """Find model pages that explicitly index a Symptom or its Profiles.""" + discovered: dict[str, dict[str, object]] = {} + for concept, class_name, object_name, device_uri in self.store.list_entities("Device"): + if concept != "Device": + continue + content = self.read_resource(device_uri) + if content is None: + continue + frontmatter = parse_frontmatter(content) + if symptom_uri not in _uri_values(self.store.root_uri, frontmatter.get("symptom_refs")): + continue + discovered[device_uri] = { + "device_uri": device_uri, + "indexed_by_device": True, + "indexed_by_profile": False, + "class_name": class_name, + "object_name": object_name, + } + + for profile in self.list_symptom_profiles(symptom_uri): + profile_content = self.read_resource(profile["uri"]) + if profile_content is None: + continue + for device_uri in _uri_values( + self.store.root_uri, parse_frontmatter(profile_content).get("device_refs") + ): + record = discovered.setdefault( + device_uri, + { + "device_uri": device_uri, + "indexed_by_device": False, + "indexed_by_profile": False, + }, + ) + record["indexed_by_profile"] = True + + return [discovered[uri] for uri in sorted(discovered)] + + def trace_diagnostic_path( + self, + device_uri: str = "", + symptom_uri: str = "", + limit: int = 100, + ) -> dict[str, object]: + """Trace Device → Symptom/Profile → Fault → Component → Procedure. + + The trace only follows typed frontmatter relations. Narrative names + and ``applicable_models`` strings are deliberately not used as + substitutes, so missing bindings remain visible to the caller. + """ + if limit < 1 or limit > 500: + raise ValueError("limit must be between 1 and 500") + if not device_uri: + if not symptom_uri: + raise ValueError("device_uri or symptom_uri must be provided") + discovery = self._discover_devices_for_symptom(symptom_uri) + if not discovery: + return { + "symptom_uri": symptom_uri, + "device_uris": [], + "device_discovery": [], + "paths": [], + "complete": False, + "error": "device_not_discovered_for_symptom", + } + truncated = {"devices": len(discovery)} if len(discovery) > limit else {} + paths = [ + self.trace_diagnostic_path( + str(record["device_uri"]), + symptom_uri=symptom_uri, + limit=limit, + ) + for record in discovery[:limit] + ] + return { + "symptom_uri": symptom_uri, + "device_uris": [str(record["device_uri"]) for record in discovery[:limit]], + "device_discovery": discovery[:limit], + "paths": paths, + "truncated": truncated, + "complete": ( + bool(paths) + and not truncated + and all(bool(path.get("complete")) for path in paths) + ), + } + device_content = self.read_resource(device_uri) + if device_content is None: + return {"device_uri": device_uri, "complete": False, "error": "device_not_found"} + device_frontmatter = parse_frontmatter(device_content) + symptom_uris = _uri_values(self.store.root_uri, device_frontmatter.get("symptom_refs")) + device_components = set( + _uri_values(self.store.root_uri, device_frontmatter.get("critical_components")) + ) + device_sections = extract_sections(device_content) + device_body_link_uris = sorted(extract_wiki_uris(device_content)) + device_typed_relations = { + field: _uri_values(self.store.root_uri, device_frontmatter.get(field)) + for field in _RELATION_FIELDS["Device"] + if _uri_values(self.store.root_uri, device_frontmatter.get(field)) + } + device_related_uris = sorted( + set(device_body_link_uris).union( + uri for values in device_typed_relations.values() for uri in values + ), + ) + truncated: dict[str, int] = {} + if len(device_components) > limit: + truncated["critical_components"] = len(device_components) + if len(device_body_link_uris) > limit: + truncated["device_body_links"] = len(device_body_link_uris) + if len(symptom_uris) > limit: + truncated["symptoms"] = len(symptom_uris) + device_typed_uri_set = {uri for values in device_typed_relations.values() for uri in values} + device_extra_resources: list[dict[str, object]] = [] + device_extra_unresolved: list[str] = [] + device_extra_source_uris: list[str] = [] + for linked_uri in device_body_link_uris[:limit]: + if linked_uri == device_uri or linked_uri in device_typed_uri_set: + continue + linked_content = self.read_resource(linked_uri) + if linked_content is None: + device_extra_unresolved.append(linked_uri) + device_extra_resources.append( + {"uri": linked_uri, "complete": False, "error": "resource_not_found"}, + ) + continue + linked_frontmatter = parse_frontmatter(linked_content) + linked_sources = _content_source_values(linked_content, linked_frontmatter) + linked_unresolved_sources = [ + uri for uri in _raw_source_values(linked_sources) if self.read_resource(uri) is None + ] + device_extra_source_uris.extend(linked_unresolved_sources) + device_extra_resources.append( + { + "uri": linked_uri, + "content": linked_content, + "source_uris": linked_sources, + "unresolved_source_uris": linked_unresolved_sources, + "complete": not linked_unresolved_sources, + }, + ) + device_source_uris = _content_source_values(device_content, device_frontmatter) + unresolved_source_uris = [ + uri for uri in _raw_source_values(device_source_uris) if self.read_resource(uri) is None + ] + unresolved_source_uris.extend(device_extra_source_uris) + complete = bool( + symptom_uris + and device_components + and _raw_source_values(device_source_uris) + and not unresolved_source_uris, + ) + if device_extra_unresolved: + complete = False + component_resources: list[dict[str, object]] = [] + for component_uri in sorted(device_components)[:limit]: + component_content = self.read_resource(component_uri) + if component_content is None: + complete = False + component_resources.append( + {"uri": component_uri, "complete": False, "error": "component_not_found"}, + ) + continue + component_frontmatter = parse_frontmatter(component_content) + component_source_uris = _content_source_values(component_content, component_frontmatter) + component_unresolved = [ + uri + for uri in _raw_source_values(component_source_uris) + if self.read_resource(uri) is None + ] + unresolved_source_uris.extend(component_unresolved) + if not _raw_source_values(component_source_uris) or component_unresolved: + complete = False + component_resources.append( + { + "uri": component_uri, + "title": component_frontmatter.get("title", ""), + "content": component_content, + "source_uris": component_source_uris, + "unresolved_source_uris": component_unresolved, + "complete": bool(component_source_uris and not component_unresolved), + }, + ) + if symptom_uri: + if symptom_uri not in symptom_uris: + return { + "device_uri": device_uri, + "symptom_uri": symptom_uri, + "complete": False, + "error": "symptom_not_indexed_by_device", + "symptom_uris": symptom_uris, + } + symptom_uris = [symptom_uri] + + symptoms: list[dict[str, object]] = [] + procedure_uris: list[str] = [] + expected_procedure_components: dict[str, set[str]] = {} + for current_symptom_uri in symptom_uris[:limit]: + symptom_content = self.read_resource(current_symptom_uri) + if symptom_content is None: + complete = False + symptoms.append({ + "uri": current_symptom_uri, + "complete": False, + "error": "symptom_not_found", + }) + continue + symptom_frontmatter = parse_frontmatter(symptom_content) + symptom_source_uris = _content_source_values(symptom_content, symptom_frontmatter) + symptom_unresolved = [ + uri + for uri in _raw_source_values(symptom_source_uris) + if self.read_resource(uri) is None + ] + unresolved_source_uris.extend(symptom_unresolved) + if not _raw_source_values(symptom_source_uris) or symptom_unresolved: + complete = False + profiles: list[dict[str, object]] = [] + for profile in self.list_symptom_profiles(current_symptom_uri): + profile_uri = profile["uri"] + profile_content = self.read_resource(profile_uri) + if profile_content is None: + complete = False + profiles.append({ + "uri": profile_uri, + "complete": False, + "error": "profile_not_found", + }) + continue + profile_frontmatter = parse_frontmatter(profile_content) + profile_source_uris = _content_source_values(profile_content, profile_frontmatter) + profile_unresolved = [ + uri + for uri in _raw_source_values(profile_source_uris) + if self.read_resource(uri) is None + ] + unresolved_source_uris.extend(profile_unresolved) + if not _raw_source_values(profile_source_uris) or profile_unresolved: + complete = False + profile_devices = _uri_values( + self.store.root_uri, profile_frontmatter.get("device_refs") + ) + if device_uri not in profile_devices: + continue + component_uri = profile_frontmatter.get("direct_component_uri") + component_uris = _uri_values(self.store.root_uri, component_uri) + fault_uris = _uri_values( + self.store.root_uri, profile_frontmatter.get("possible_faults") + ) + if len(fault_uris) > limit: + truncated[f"faults:{profile_uri}"] = len(fault_uris) + if component_uris and not set(component_uris) <= device_components: + complete = False + profile_sections = extract_sections(profile_content) + diagnostic_section = profile_sections.get("推荐诊断流程", "") + diagnostic_steps = _diagnostic_step_items(diagnostic_section) + missing_procedure_steps = [ + step + for step in diagnostic_steps + if not _TRACE_GAP_RE.search(step) + and not any( + uri.startswith(self.store.root_uri + "/Procedure/") + for uri in extract_wiki_uris(step) + ) + ] + open_gap_steps = [step for step in diagnostic_steps if _TRACE_GAP_RE.search(step)] + diagnostic_flow_checked = bool(diagnostic_section.strip()) + diagnostic_flow_complete = ( + None + if not diagnostic_flow_checked + else bool(diagnostic_steps) + and not missing_procedure_steps + and not open_gap_steps + ) + for procedure_uri in extract_wiki_uris(diagnostic_section): + if ( + procedure_uri.startswith(self.store.root_uri + "/Procedure/") + and procedure_uri not in procedure_uris + ): + procedure_uris.append(procedure_uri) + if procedure_uri.startswith(self.store.root_uri + "/Procedure/"): + expected_procedure_components.setdefault(procedure_uri, set()).update( + component_uris + ) + faults: list[dict[str, object]] = [] + if not component_uris or not fault_uris: + complete = False + for fault_uri in fault_uris[:limit]: + fault_content = self.read_resource(fault_uri) + if fault_content is None: + complete = False + faults.append({ + "uri": fault_uri, + "complete": False, + "error": "fault_not_found", + }) + continue + fault_frontmatter = parse_frontmatter(fault_content) + fault_source_uris = _content_source_values(fault_content, fault_frontmatter) + fault_unresolved = [ + uri + for uri in _raw_source_values(fault_source_uris) + if self.read_resource(uri) is None + ] + unresolved_source_uris.extend(fault_unresolved) + if not _raw_source_values(fault_source_uris) or fault_unresolved: + complete = False + affected_components = _uri_values( + self.store.root_uri, fault_frontmatter.get("affected_components") + ) + verification = _uri_values( + self.store.root_uri, fault_frontmatter.get("verification_procedures") + ) + repair = _uri_values( + self.store.root_uri, fault_frontmatter.get("repair_procedures") + ) + for procedure_uri in [*verification, *repair]: + if procedure_uri not in procedure_uris: + procedure_uris.append(procedure_uri) + expected_procedure_components.setdefault(procedure_uri, set()).update( + affected_components, + ) + component_covered = bool(set(component_uris) & set(affected_components)) + affected_component_in_bom = bool(set(affected_components) & device_components) + if ( + not affected_components + or not component_covered + or not affected_component_in_bom + or (not verification and not repair) + ): + complete = False + faults.append( + { + "uri": fault_uri, + "component_uris": affected_components, + "verification_procedures": verification, + "repair_procedures": repair, + "direct_component_covered": component_covered, + "affected_component_in_bom": affected_component_in_bom, + "source_uris": fault_source_uris, + "unresolved_source_uris": fault_unresolved, + "content": fault_content, + "complete": bool(affected_components and (verification or repair)), + }, + ) + profiles.append( + { + "uri": profile_uri, + "device_uris": profile_devices, + "direct_component_uri": component_uris, + "faults": faults, + "diagnostic_step_count": len(diagnostic_steps), + "linked_procedure_step_count": len(diagnostic_steps) + - len(missing_procedure_steps), + "missing_procedure_steps": missing_procedure_steps, + "open_gap_steps": open_gap_steps, + "diagnostic_flow_complete": diagnostic_flow_complete, + "source_uris": profile_source_uris, + "unresolved_source_uris": profile_unresolved, + "content": profile_content, + "complete": bool( + component_uris + and fault_uris + and faults + and all(bool(fault.get("complete")) for fault in faults) + and diagnostic_flow_complete is not False, + ), + }, + ) + if not profiles: + complete = False + symptoms.append({ + "uri": current_symptom_uri, + "profiles": profiles, + "complete": bool(profiles), + }) + + procedures: list[dict[str, object]] = [] + if len(procedure_uris) > limit: + truncated["procedures"] = len(procedure_uris) + for procedure_uri in procedure_uris[:limit]: + procedure_content = self.read_resource(procedure_uri) + if procedure_content is None: + complete = False + procedures.append({ + "uri": procedure_uri, + "complete": False, + "error": "procedure_not_found", + }) + continue + procedure_frontmatter = parse_frontmatter(procedure_content) + procedure_sections = extract_sections(procedure_content) + operation_steps = procedure_sections.get("操作步骤", "") + criteria = procedure_sections.get("判定标准", "") + operation_sources = sorted( + uri for uri in extract_source_uris(operation_steps) if _is_raw_chapter_uri(uri) + ) + criteria_sources = sorted( + uri for uri in extract_source_uris(criteria) if _is_raw_chapter_uri(uri) + ) + procedure_source_uris = _content_source_values(procedure_content, procedure_frontmatter) + procedure_unresolved = [ + uri + for uri in _raw_source_values([ + *procedure_source_uris, + *operation_sources, + *criteria_sources, + ]) + if self.read_resource(uri) is None + ] + unresolved_source_uris.extend(procedure_unresolved) + target_components = _uri_values( + self.store.root_uri, procedure_frontmatter.get("target_components") + ) + expected_components = expected_procedure_components.get(procedure_uri, set()) + target_is_in_device_bom = bool(set(target_components) & device_components) + target_covers_chain = not expected_components or bool( + set(target_components) & expected_components, + ) + operation_evidence_sources = list( + dict.fromkeys(operation_sources), + ) + criteria_evidence_sources = list( + dict.fromkeys(criteria_sources), + ) + operation_step_evidence = _step_evidence(operation_steps) + missing_operation_step_evidence = [ + str(item["step"]) + for item in operation_step_evidence + if not bool(item["has_local_evidence"]) + ] + procedure_complete = bool( + target_components + and target_is_in_device_bom + and target_covers_chain + and operation_steps.strip() + and has_usable_procedure_criteria(criteria) + and _raw_source_values(operation_evidence_sources) + and _raw_source_values(criteria_evidence_sources) + and not procedure_unresolved, + ) + procedures.append( + { + "uri": procedure_uri, + "target_components": target_components, + "target_is_in_device_bom": target_is_in_device_bom, + "target_covers_chain": target_covers_chain, + "source_uris": list( + dict.fromkeys( + [ + *procedure_source_uris, + *operation_sources, + *criteria_sources, + ], + ), + ), + "operation_steps": operation_steps, + "operation_step_evidence": operation_step_evidence, + "missing_operation_step_evidence": missing_operation_step_evidence, + "operation_source_uris": operation_evidence_sources, + "criteria": criteria, + "criteria_usable": has_usable_procedure_criteria(criteria), + "criteria_source_uris": criteria_evidence_sources, + "unresolved_source_uris": procedure_unresolved, + "complete": procedure_complete, + }, + ) + if not procedure_complete: + complete = False + + if truncated: + complete = False + + return { + "device_uri": device_uri, + "device": { + "uri": device_uri, + "content": device_content, + "frontmatter": device_frontmatter, + "sections": device_sections, + "typed_relations": device_typed_relations, + "body_link_uris": device_body_link_uris, + "related_uris": device_related_uris, + "extra_resources": device_extra_resources, + "unresolved_related_uris": sorted(device_extra_unresolved), + "source_uris": device_source_uris, + "unresolved_source_uris": sorted( + set(_raw_source_values(device_source_uris)).union(device_extra_unresolved) + & set(unresolved_source_uris), + ), + }, + "symptom_uri": symptom_uri, + "device_source_uris": device_source_uris, + "critical_component_uris": sorted(device_components), + "component_resources": component_resources, + "symptoms": symptoms, + "procedures": procedures, + "truncated": truncated, + "unresolved_source_uris": sorted(set(unresolved_source_uris)), + "complete": complete, + "indexed_symptom_count": len(symptom_uris), + } + + def list_children(self, uri: str) -> dict: + """Browse by URI — unified hierarchical drill-down. + + Supports three URI schemes: + - ``{root_uri}/`` → wiki entities (concepts → classes → entities) + - ``viking://resources/`` → arbitrary remote trees (raw manuals, cases) + - ``{bom_root}/`` → the configured global BOM tree + + Wiki entities and classes use readable, self-describing paths. + For BOM URIs, the configured global BOM tree is browsed directly. + For viking URIs, the remote namespace is traversed via the SDK. + """ + if self._bom_fs is not None: + bom_root = self._bom_fs.root_uri.rstrip("/") + if uri == bom_root or uri.startswith(bom_root + "/"): + return self._list_bom_children(uri) + if uri.startswith("viking://resources/") and not ( + uri == self.store.root_uri or uri.startswith(self.store.root_uri + "/") + ): + return viking_list_children(uri) + + path = uri.removeprefix(self.store.root_uri).strip("/") + parts = [p for p in path.split("/") if p] + + if not parts: + entities = self.store.list_entities() + counts: dict[str, int] = {} + for c, _cls, _obj, _u in entities: + counts[c] = counts.get(c, 0) + 1 + return { + "uri": uri, + "type": "root", + "children": [ + {"concept": k, "count": v, "uri": f"{self.store.root_uri}/{k}"} + for k, v in sorted(counts.items()) + ], + } + + concept = parts[0] + + if len(parts) == 1: + entities = self.store.list_entities(concept) + class_counts: dict[str, int] = {} + for _c, cls, _obj, _u in entities: + key = self.store.logical_class_name(concept, cls) or "(flat)" + class_counts[key] = class_counts.get(key, 0) + 1 + return { + "uri": uri, + "type": "concept", + "concept": concept, + "children": [ + { + "class_name": cls, + "count": cnt, + "uri": self.store.class_uri(concept, cls), + } + for cls, cnt in sorted(class_counts.items()) + ], + } + + content = self.store.read_entity_by_uri(uri) + if content is not None: + info = self.store.lookup_by_uri(uri) + c, cls, obj = info if info else (concept, None, parts[-1]) + result: dict[str, object] = { + "uri": uri, + "type": "entity", + "concept": c, + "class_name": self.store.logical_class_name(c, cls) or "", + "object_name": obj, + "path": str( + self.store.entity_path(c, cls, obj).relative_to(self.store.root), + ), + "content": content, + } + if c == "Symptom": + result["children"] = self.list_symptom_profiles(uri) + return result + + class_info = self.store.lookup_class_by_uri(uri) + if class_info is not None: + c, class_name = class_info + logical_class_name = self.store.logical_class_name(c, class_name) or class_name + entities = self.store.list_entities(c) + if class_name == "(flat)": + matched = [(cc, cls, obj, u) for cc, cls, obj, u in entities if not cls] + else: + matched = [ + (cc, cls, obj, u) + for cc, cls, obj, u in entities + if (cls or "(flat)") == class_name or (cls or "").startswith(class_name + "/") + ] + return { + "uri": uri, + "type": "class", + "concept": c, + "class_name": logical_class_name, + "children": [ + { + "object_name": obj, + "uri": u, + } + for _cc, cls, obj, u in matched + ], + } + + return {"uri": uri, "type": "not_found", "error": f"No entities at {uri}"} + + def get_related_resources( + self, + uri: str, + relation: str = "", + cursor: int = 0, + limit: int = 50, + ) -> dict: + """Return deterministic typed edges for one entity or Profile. + + This is the read side of the reference-maintenance worker. It keeps + structured frontmatter relations and narrative links separate, while + carrying the source URIs that explain where each edge came from. + ``cursor`` is an item offset, so callers can safely page a large + component tree without changing the ordering between calls. + """ + if cursor < 0: + raise ValueError("cursor must be non-negative") + if limit < 1 or limit > 200: + raise ValueError("limit must be between 1 and 200") + + content = self.read_resource(uri) + if content is None: + return { + "uri": uri, + "relation": relation, + "cursor": cursor, + "next_cursor": -1, + "links": [], + "error": "resource_not_found", + } + + identity = self.store.lookup_by_uri(uri) + concept = identity[0] if identity is not None else "" + if "/profile/" in uri: + concept = "SymptomProfile" + frontmatter = parse_frontmatter(content) + all_sources = list(dict.fromkeys(_source_values(frontmatter.get("sources")))) + + links: list[dict[str, object]] = [] + relation_fields = _RELATION_FIELDS.get(concept, ()) + for field in relation_fields: + for target_uri in _uri_values(self.store.root_uri, frontmatter.get(field)): + if target_uri == uri: + continue + target_base = target_uri.partition("#")[0] + target_concept = target_base.removeprefix(self.store.root_uri + "/").split("/", 1)[ + 0 + ] + links.append( + { + "from_uri": uri, + "relation": field, + "to_uri": target_uri, + "to_concept": target_concept, + "source_refs": all_sources, + "target_section": "frontmatter", + "section_state": "complete", + }, + ) + + for target_uri in sorted(extract_wiki_uris(content)): + if target_uri == uri: + continue + section, state = _section_at(content, content.find(target_uri)) + target_base = target_uri.partition("#")[0] + target_concept = target_base.removeprefix(self.store.root_uri + "/").split("/", 1)[0] + links.append( + { + "from_uri": uri, + "relation": "body_link", + "to_uri": target_uri, + "to_concept": target_concept, + "source_refs": all_sources, + "target_section": section, + "section_state": state, + }, + ) + + # OpenViking is the graph authority when the remote backend exposes + # native relation edges. Keep the Markdown-derived edges as the + # explainable/source-bearing view, then add only native edges that are + # not already represented. This lets retrieval use the SDK without + # losing section-level evidence required by the Wiki harness. + native_relations = self.store._fs.relations(uri) + known_native = { + (str(link.get("relation", "")), str(link.get("to_uri", ""))) for link in links + } + for native in native_relations: + to_uri = str( + native.get("to_uri") + or native.get("target_uri") + or native.get("uri") + or native.get("to", ""), + ).strip() + if not to_uri or to_uri == uri: + continue + native_relation = str(native.get("relation") or native.get("type") or "native_link") + if (native_relation, to_uri) in known_native: + continue + known_native.add((native_relation, to_uri)) + links.append( + { + "from_uri": uri, + "relation": native_relation, + "to_uri": to_uri, + "to_concept": to_uri.removeprefix(self.store.root_uri + "/").split("/", 1)[0], + "source_refs": [], + "target_section": "openviking_relation", + "section_state": "native", + }, + ) + + # A compatibility guard for legacy URI tokenizers: only retain links + # that the canonical quality extractor can also see. This prevents a + # truncated ``/()`` token from becoming a graph edge. + canonical_links = extract_wiki_uris(content) + links = [ + link + for link in links + if link.get("section_state") == "native" or link["to_uri"] in canonical_links + ] + links.sort(key=lambda link: (str(link["relation"]), str(link["to_uri"]))) + if relation: + links = [link for link in links if link["relation"] == relation] + page = links[cursor : cursor + limit] + next_cursor = cursor + limit if cursor + limit < len(links) else -1 + return { + "uri": uri, + "relation": relation, + "cursor": cursor, + "next_cursor": next_cursor, + "links": page, + } + + def _list_bom_children(self, uri: str) -> dict: + """Browse the global BOM tree (directories + markdown leaves). + + The configured BOM backend is a read-only namespace covering every + machine class (e.g. ``viking://resources/<bom_namespace>/bom/component/挖掘机部件_BOM_清单``). + Directories are returned as ``directory`` entries for drill-down; + markdown files as ``bom`` entries whose per-machine model/tables can + be read through ``read_resource``. + """ + if self._bom_fs is None: + return {"uri": uri, "type": "error", "error": "bom_root not configured"} + prefix = self._bom_fs.root_uri.rstrip("/") + "/" + key = uri.removeprefix(self._bom_fs.root_uri.rstrip("/")).strip("/") + if not self._bom_fs.is_dir(key): + return { + "uri": uri, + "type": "bom_file", + "path": key, + } + entries: dict[str, str] = {} + for rel in self._bom_fs.list_dir(key, recursive=True): + tail = rel[len(key) :].lstrip("/") if key else rel + if not tail: + continue + head, separator, _rest = tail.partition("/") + entries[head] = "directory" if separator else "bom" + return { + "uri": uri, + "type": "bom_dir", + "children": [ + { + "name": head, + "type": kind, + "uri": f"{prefix}{key}/{head}" if key else f"{prefix}{head}", + } + for head, kind in sorted(entries.items()) + ], + } + + def parent_of(self, uri: str) -> str | None: + """Return the parent URI of a ``{root_uri}/`` URI, or ``None`` at root. + + Hierarchy: + - ``{root_uri}/`` → ``None`` (already root) + - ``{root_uri}/<Concept>`` → ``{root_uri}/`` + - ``{root_uri}/<Concept>/<class_hash>`` → ``{root_uri}/<Concept>`` + - ``{root_uri}/<Concept>/<entity_hash>`` → ``{root_uri}/<Concept>/<class_hash>`` + + For entity URIs the class-hash parent is resolved via + ``lookup_by_uri`` + ``class_uri``. Non-wiki URIs return ``None``. + """ + if not self.store.is_wiki_uri(uri): + return None + path = uri.removeprefix(self.store.root_uri).strip("/") + parts = [p for p in path.split("/") if p] + if not parts: + return None + if len(parts) == 1: + return self.store.root_uri + "/" + info = self.store.lookup_by_uri(uri) + if info is not None: + concept, class_name, _obj = info + return ( + self.store.class_uri(concept, class_name) + if class_name + else f"{self.store.root_uri}/{concept}" + ) + class_info = self.store.lookup_class_by_uri(uri) + if class_info is not None: + concept, _class_name = class_info + return f"{self.store.root_uri}/{concept}" + return None diff --git a/src/wolfharness/capabilities/wiki/io/migration.py b/src/wolfharness/capabilities/wiki/io/migration.py new file mode 100644 index 000000000..161df6591 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/io/migration.py @@ -0,0 +1,517 @@ +"""URI migration, source-URI sync, and read paths.""" + +from __future__ import annotations + +from hashlib import sha256 +import json +import logging +from pathlib import Path +import re + +from wolfharness.capabilities.wiki.io.text_parsers import ( + _dir_to_clean_title, +) +from wolfharness.capabilities.wiki.quality import ( + RawSourceKind, + classify_raw_source_uri, +) +from wolfharness.capabilities.wiki.storage import ( + viking_read, +) + + +logger = logging.getLogger(__name__) + +from wolfharness.capabilities.wiki._helpers import _CHAPTER_COMPONENT_RE + + +class MigrationMixin: + """URI migration, source-URI sync, and read paths.""" + + def create_subdir(self, concept: str, subdir_path: str) -> bool: + """Create a subdirectory under a concept directory. + + ``subdir_path`` is relative to the concept root, e.g. ``"发动机/SC9D"`` + creates ``Component/发动机/SC9D/``. + """ + if concept not in self.store.CONCEPT_DIRS or concept in {"OPA", "OPS", "OPL"}: + raise ValueError(f"Cannot create entity subdirectories for concept: {concept}") + relative = Path(subdir_path) + if relative.is_absolute() or not subdir_path.strip(): + raise ValueError("subdir_path must be a non-empty relative path") + base = self.store.CONCEPT_DIRS[concept] + target = str(Path(base) / relative) + if not target.startswith(base + "/") and target != base: + raise ValueError("subdir_path escapes the concept directory") + self.store.mkdir_p(target) + logger.info("Subdir created: %s", target) + return True + + def _rewrite_persisted_uri(self, old_uri: str, new_uri: str) -> list[str]: + """Rewrite a moved/deleted URI in pages and published indexes. + + Returns the URIs of the pages whose content was rewritten (the + ``from`` side of the moved link) so the caller can re-sync native + graph edges for exactly those pages. + """ + affected: list[str] = [] + # ponytail: rewrite only pages that link old_uri, resolved via the + # persisted backlink index (one index read). When no index exists + # (unbuilt library) fall back to a full scan so referrers are not lost. + referrers = self.store.get_backlinks(old_uri) + if not referrers: + referrers = [ + record["uri"] + for record in self._formal_entity_snapshot_records() + if old_uri in record["content"] + ] + for uri in referrers: + content = self.read_resource(uri) + if content is None or old_uri not in content: + continue + updated = content.replace(old_uri, new_uri) + expected_sha256 = sha256(content.encode("utf-8")).hexdigest() + profile_identity = self.store.split_symptom_profile_uri(uri) + if profile_identity is not None: + parent_uri, profile_id = profile_identity + self.write_symptom_profile( + parent_uri, + profile_id, + updated, + expected_sha256=expected_sha256, + skip_materialization=True, + ) + affected.append(uri) + continue + identity = self.store.lookup_by_uri(uri) + if identity is None: + continue + concept, class_name, object_name = identity + self.merge_entity( + concept, + class_name or "", + object_name, + updated, + expected_sha256=expected_sha256, + skip_materialization=True, + ) + affected.append(uri) + for relative_path in ("resource.json", "index/concepts_index.json"): + raw = self.store.read_text(relative_path) + if raw is None: + continue + updated = raw.replace(old_uri, new_uri) + if updated != raw: + self.store.write_text(relative_path, updated) + return affected + + def migrate_source_uri_references( + self, + old_uri: str, + new_uri: str, + *, + entity_uris: list[str] | None = None, + dry_run: bool = False, + ) -> dict[str, object]: + """Patch Device references when a manual chapter receives a new URI. + + Only exact references to ``old_uri`` are replaced. Each affected Device + is read and patched independently with its current SHA-256, so a + concurrent or expert-owned page cannot be overwritten. Repeating the + same migration is idempotent: pages no longer containing ``old_uri`` + are reported as skipped. + + Args: + old_uri: Previous readable raw manual URI. + new_uri: New readable raw manual URI. + entity_uris: Optional explicit Device URIs. Defaults to all Devices. + dry_run: Return the planned pages without writing them. + + Returns: + Counts and per-page results. Expert-authority or optimistic-lock + conflicts are isolated in ``blocked`` and do not stop other pages. + """ + previous_uri = old_uri.strip() + replacement_uri = new_uri.strip() + if not previous_uri or not replacement_uri: + raise ValueError("old_uri and new_uri are required") + if previous_uri == replacement_uri: + raise ValueError("old_uri and new_uri must be different") + if classify_raw_source_uri(previous_uri, raw_root_uri=self._raw_fs.root_uri) is None: + raise ValueError(f"old_uri is not a supported raw source URI: {previous_uri}") + if classify_raw_source_uri(replacement_uri, raw_root_uri=self._raw_fs.root_uri) is None: + raise ValueError(f"new_uri is not a supported raw source URI: {replacement_uri}") + if self.read_raw_resource(replacement_uri) is None: + raise ValueError(f"new_uri is not readable: {replacement_uri}") + + selected: list[tuple[str, str, str]] = [] + if entity_uris is None: + selected = [ + (class_name or "", object_name, uri) + for _concept, class_name, object_name, uri in self.store.list_entities("Device") + ] + else: + for requested_uri in dict.fromkeys(uri.strip() for uri in entity_uris if uri.strip()): + identity = self.store.lookup_by_uri(requested_uri) + if identity is None or identity[0] != "Device": + raise ValueError(f"entity_uris must identify Device pages: {requested_uri}") + selected.append((identity[1] or "", identity[2], identity[3])) + + planned: list[dict[str, object]] = [] + skipped: list[dict[str, object]] = [] + updated: list[dict[str, object]] = [] + blocked: list[dict[str, object]] = [] + for class_name, object_name, entity_uri in selected: + content = self.store.read_entity_by_uri(entity_uri) + if content is None: + blocked.append({"uri": entity_uri, "reason": "entity_not_readable"}) + continue + if previous_uri not in content: + skipped.append({"uri": entity_uri, "reason": "old_uri_not_present"}) + continue + current_sha256 = sha256(content.encode("utf-8")).hexdigest() + lines = content.splitlines(keepends=True) + operations = [ + { + "op": "line_replace", + "start": line_number, + "end": line_number, + "content": line.replace(previous_uri, replacement_uri), + } + for line_number, line in enumerate(lines, 1) + if previous_uri in line + ] + item: dict[str, object] = { + "uri": entity_uri, + "class_name": class_name, + "object_name": object_name, + "expected_sha256": current_sha256, + "replacement_count": sum(line.count(previous_uri) for line in lines), + } + if dry_run: + planned.append(item) + continue + try: + self.patch_entity( + "Device", + class_name, + object_name, + operations, + expected_sha256=current_sha256, + reference_replacements=[(previous_uri, replacement_uri)], + ) + except (FileNotFoundError, OSError, ValueError) as error: + blocked.append({**item, "reason": str(error)}) + else: + updated.append(item) + + return { + "old_uri": previous_uri, + "new_uri": replacement_uri, + "dry_run": dry_run, + "scanned_count": len(selected), + "planned_count": len(planned), + "updated_count": len(updated), + "skipped_count": len(skipped), + "blocked_count": len(blocked), + "planned": planned, + "updated": updated, + "skipped": skipped, + "blocked": blocked, + "idempotent": True, + } + + def sync_device_system_chapters( + self, + doc_id: str, + device_id: str, + ) -> dict[str, object]: + """Synchronize a Device's raw chapter navigation from the source tree. + + Legacy/incremental builds can create the Device skeleton before the + chapter planner has run. The later chapter packets then provide real + source evidence, but nothing used to backfill the existing Device + page. This operation updates only the ``system_chapters`` frontmatter + field; all other Device content, including expert-owned sections, is + left untouched. + + Each entry carries the chapter number, readable title and exact raw + URI. Re-running the operation is a no-op when the chapter inventory has + not changed. An empty local chapter inventory is not treated as a + successful sync because external-MCP builds must supply their own + citations from the source service. + """ + normalized_doc_id = doc_id.strip() + normalized_device_id = device_id.strip() + if not normalized_doc_id or not normalized_device_id: + raise ValueError("doc_id and device_id must not be empty") + + current = self.store.read_entity("Device", None, normalized_device_id) + if current is None: + # ponytail: fuzzy fallback — BOM may register Device under a different object_name + for _concept, _cls, obj_name, _uri in self.store.list_entities("Device"): + if obj_name.startswith(normalized_device_id) or normalized_device_id.startswith( + obj_name + ): + current = self.store.read_entity("Device", None, obj_name) + if current is not None: + normalized_device_id = obj_name + break + if current is None: + return { + "status": "skipped", + "reason": "device_not_found", + "doc_id": normalized_doc_id, + "device_id": normalized_device_id, + "chapter_count": 0, + "updated": False, + } + + chapters = self.list_chapters(normalized_doc_id, refresh=True) + if not chapters: + return { + "status": "skipped", + "reason": "no_local_chapters", + "doc_id": normalized_doc_id, + "device_id": normalized_device_id, + "chapter_count": 0, + "updated": False, + } + + chapter_rows: list[tuple[str, str]] = [] + for chapter in chapters: + subdir = str(chapter.get("subdir", "")).strip() + if not subdir: + continue + uri = self.make_source_uri(normalized_doc_id, subdir) + parts = [part for part in subdir.split("/") if part] + leaf = parts[-1] if parts else "" + if leaf.endswith(".md"): + leaf = leaf[:-3] + number_match = _CHAPTER_COMPONENT_RE.match(leaf) + chapter_number = number_match.group(0).replace("_", ".") if number_match else "" + title = str(chapter.get("title", "")).strip() or _dir_to_clean_title(leaf) + title = re.sub(r"_\d+more_[0-9a-f]+(?:_\d+)?$", "", title).replace("_", " ").strip() + label = ( + " ".join(value for value in (chapter_number, title) if value).strip() + or uri.rsplit("/", 1)[-1] + ) + chapter_rows.append((label, uri)) + + if not chapter_rows: + return { + "status": "skipped", + "reason": "chapters_have_no_readable_uri", + "doc_id": normalized_doc_id, + "device_id": normalized_device_id, + "chapter_count": 0, + "updated": False, + } + + chapter_rows = list(dict.fromkeys(chapter_rows)) + chapter_uris = [uri for _label, uri in chapter_rows] + current_hash = sha256(current.encode("utf-8")).hexdigest() + operations: list[dict[str, object]] = [ + {"op": "fm_set_list", "field": "system_chapters", "values": chapter_uris}, + ] + before = current + self.patch_entity( + "Device", + "", + normalized_device_id, + operations, + expected_sha256=current_hash, + ) + after = self.store.read_entity("Device", None, normalized_device_id) or before + return { + "status": "updated" if after != before else "unchanged", + "reason": "chapter_inventory_synced", + "doc_id": normalized_doc_id, + "device_id": normalized_device_id, + "chapter_count": len(chapter_rows), + "updated": after != before, + "idempotent": True, + "source_uris": chapter_uris, + } + + def read_resource(self, uri: str, line_numbers: bool = False) -> str | None: + """Resolve a ``{root_uri}/<Concept>/<hash>`` URI to file content. + + When *line_numbers* is True, each line is prefixed with its 1-indexed + line number formatted as ``NN: <content>`` for precise patch + positioning. + + Delegates to ``read_entity_by_uri`` (hash-based lookup); falls back + to ``_uri_to_path`` for backward compatibility. + """ + resource_uri, separator, fragment = uri.partition("#") + content: str | None = None + if self.store.is_wiki_uri(resource_uri): + # Wiki namespace: OP records keep their real backend URI; entity + # canonical URIs (no `.md`) must go through the hash-entity mapping + # layer, otherwise the canonical→physical-file resolution is + # bypassed and every read returns None (dangling audit). + relative = resource_uri.removeprefix(self.store.root_uri + "/") + if relative.startswith("OP/"): + content = self.store.read_text(relative) + elif relative.startswith("source_packets/") and relative.endswith(".json"): + raw = self.store.read_json(relative) + content = json.dumps(raw, ensure_ascii=False, indent=2) if raw is not None else None + else: + content = self.store.read_entity_by_uri(resource_uri) + if content is None and resource_uri.endswith(".md"): + content = self.store.read_entity_by_uri(resource_uri.removesuffix(".md")) + if content is None: + path = self._uri_to_path(resource_uri) + if path is not None: + content = self.store.read_text(self.store._key_of(path)) + elif resource_uri.startswith("viking://resources/"): + # Cross-namespace OpenViking read — arbitrary remote URI (raw + # manuals, cases, repairmenus) regardless of which namespace it + # lives in. Only reached for URIs outside the active wiki store. + content = viking_read(resource_uri) + elif self._bom_fs is not None and resource_uri.startswith(self._bom_fs.root_uri + "/"): + content = self._bom_fs.read_text( + resource_uri.removeprefix(self._bom_fs.root_uri + "/"), + ) + elif resource_uri.startswith(self._raw_fs.root_uri + "/"): + content = self.read_raw_resource(resource_uri) + elif ( + classify_raw_source_uri(resource_uri, raw_root_uri=self._raw_fs.root_uri) + is RawSourceKind.EXTERNAL + ): + content = "" + else: + result = self.store.read_entity_by_uri(resource_uri) + if result is not None: + content = result + else: + path = self._uri_to_path(resource_uri) + if path is not None: + content = self.store.read_text(self.store._key_of(path)) + + if content is not None and separator: + content = self._read_markdown_fragment(content, fragment) + + if content is not None and line_numbers: + lines = content.splitlines() + max_width = len(str(len(lines))) + content = "\n".join(f"{i + 1:>{max_width}}: {line}" for i, line in enumerate(lines)) + return content + + def read_raw_resource(self, uri: str) -> str | None: + """Resolve a real-path raw chapter URI to chapter markdown content. + + Accepts URIs shaped ``{root_uri}/{doc_id}/chapters/<subdir>/chapter.md`` + where ``root_uri`` is the active raw backend root. Also supports + title-based lookup via ``read_chapter_map`` when the URI's tail + matches a chapter title. + + Returns ``None`` for invalid or unresolvable URIs. + """ + cached = self._raw_resource_cache.get(uri) + if cached is not None or uri in self._raw_resource_cache: + return cached + root_prefix = self._raw_fs.root_uri + "/" + if not uri.startswith(root_prefix): + logger.warning("Not a raw chapter URI: %s", uri) + return None + remainder = uri.removeprefix(root_prefix) + content = self._raw_fs.read_uri(uri) + if content is not None: + self._raw_resource_cache[uri] = content + return content + parts = remainder.split("/chapters/", 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + logger.warning("Malformed raw chapter URI: %s", uri) + self._raw_resource_cache[uri] = None + return None + doc_id, subdir_tail = parts + if not subdir_tail.endswith("/chapter.md"): + logger.warning("Raw chapter URI does not point to chapter.md: %s", uri) + self._raw_resource_cache[uri] = None + return None + subdir = subdir_tail[: -len("/chapter.md")] + if not subdir: + logger.warning("Empty chapter subdir in URI: %s", uri) + self._raw_resource_cache[uri] = None + return None + try: + content = self.read_chapter(doc_id, subdir) + except (FileNotFoundError, ValueError): + pass + else: + self._raw_resource_cache[uri] = content + return content + try: + mapping = self.read_chapter_map(doc_id) + if subdir in mapping: + target_uri = mapping[subdir] + target_subdir = target_uri.split("/chapters/", 1)[1].removesuffix("/chapter.md") + content = self.read_chapter(doc_id, target_subdir) + self._raw_resource_cache[uri] = content + return content + subdir_lower = subdir.lower().strip() + for key, key_uri in mapping.items(): + if key.lower().strip() == subdir_lower: + target_subdir = key_uri.split("/chapters/", 1)[1].removesuffix("/chapter.md") + content = self.read_chapter(doc_id, target_subdir) + self._raw_resource_cache[uri] = content + return content + except (FileNotFoundError, ValueError, IndexError): + pass + logger.warning("Cannot resolve raw resource %s", uri) + self._raw_resource_cache[uri] = None + return None + + def _uri_to_path(self, uri: str) -> Path | None: + """Convert a ``{root_uri}/...`` URI to a filesystem path. + + Note: with flat hash URIs (``{root_uri}/Concept/<hash>``), this + method only works when ``<hash>`` looks like a filename. New code + should prefer ``read_entity_by_uri`` instead. + """ + if self.store.is_wiki_uri(uri): + parts = uri[len(self.store.root_uri) + 1 :].split("/") + else: + return None + if not parts: + return None + concept = parts[0] + dir_name = self.store.CONCEPT_DIRS.get(concept, concept) + base = self.store.root / dir_name + if len(parts) == 1: + return None # No object name + # Last part is object_name, middle parts are class path. + object_name = parts[-1] + class_parts = parts[1:-1] + for cp in class_parts: + base = base / cp + if concept in self.store.DIRECTORY_CONCEPTS: + return base / object_name / "index.md" + return base / f"{object_name}.md" + + # ── Semantic retrieval ───────────────────────────────────────────────── + + def find_wiki( + self, + query: str, + *, + target_uri: str = "", + limit: int = 10, + deep: bool = False, + ) -> list[dict]: + """Use OpenViking's native bounded retrieval over wiki or raw scope. + + The caller may scope retrieval to the configured Wiki root or raw + manual root. No local path scan is used for semantic discovery. + """ + target = target_uri.rstrip("/") + allowed = (self.store.root_uri, self._raw_fs.root_uri) + if target and not any(target == root or target.startswith(root + "/") for root in allowed): + raise ValueError("target_uri must belong to the configured wiki or raw OpenViking root") + return self.store._fs.find(query, target_uri=target, limit=limit, deep=deep) + + def search_wiki(self, query: str, *, limit: int = 10) -> list[dict]: + """Compatibility alias for native OpenViking ``find`` retrieval.""" + return self.find_wiki(query, limit=limit) diff --git a/src/wolfharness/capabilities/wiki/io/model_mapping.py b/src/wolfharness/capabilities/wiki/io/model_mapping.py new file mode 100644 index 000000000..35e2604e4 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/io/model_mapping.py @@ -0,0 +1,184 @@ +"""Auditable engineering/sales model mapping for Wiki builds.""" + +from __future__ import annotations + +from datetime import UTC, datetime +import re + +from wolfharness.capabilities.wiki.quality import ( + RawSourceKind, + SourceReadStatus, + classify_raw_source_uri, +) +from wolfharness.capabilities.wiki.wiki_build_deps import WikiBuildDeps + + +class ModelMappingMixin(WikiBuildDeps): + """Persist model aliases without turning unsupported guesses into facts.""" + + _MODEL_MAPPING_KEY = "index/model_mapping.json" + + @staticmethod + def _model_label(value: str) -> str: + """Normalize a model label for matching while preserving display text.""" + return re.sub(r"[^A-Za-z0-9]+", "", value).casefold() + + def _read_model_mapping_records(self) -> list[dict[str, object]]: + raw = self.store.read_json(self._MODEL_MAPPING_KEY) + if raw is None: + return [] + records = raw.get("mappings") + if not isinstance(records, list): + raise TypeError("Invalid model mapping registry: mappings must be a list") + return [record for record in records if isinstance(record, dict)] + + @staticmethod + def _unique_labels(values: list[str] | None) -> list[str]: + return list(dict.fromkeys(value.strip() for value in (values or []) if value.strip())) + + def _validate_mapping_sources(self, source_uris: list[str]) -> list[str]: + valid: list[str] = [] + for uri in self._unique_labels(source_uris): + kind = classify_raw_source_uri(uri, raw_root_uri=self._raw_fs.root_uri) + if kind is None or kind is RawSourceKind.EXTERNAL: + if self.read_resource(uri) is None: + raise ValueError(f"Model mapping evidence cannot be resolved: {uri}") + else: + result = self.read_raw_source(uri) + if result.status is not SourceReadStatus.OK: + raise ValueError(f"Model mapping evidence cannot be resolved: {uri}") + valid.append(uri) + return valid + + def _find_device_uri(self, labels: list[str]) -> str: + normalized = {self._model_label(label) for label in labels if label.strip()} + for _concept, class_name, object_name, uri in self.store.list_entities("Device"): + candidates = {self._model_label(object_name)} + if class_name: + candidates.add(self._model_label(class_name)) + if normalized.intersection(candidates): + return uri + return "" + + def register_model_mapping( + self, + engineering_model: str, + sales_models: list[str], + *, + parent_model: str = "", + device_uri: str = "", + source_uris: list[str] | None = None, + provenance: str = "manual", + approved_by: str = "", + notes: str = "", + ) -> dict[str, object]: + """Register one engineering/sales/series mapping. + + A mapping without readable source evidence is persisted as ``pending``. + It can guide candidate lookup, but it must not silently promote + model-specific facts or publication status. Manual mappings become + ``confirmed`` only when an explicit ``approved_by`` is supplied. + """ + engineering = engineering_model.strip() + sales = self._unique_labels(sales_models) + parent = parent_model.strip() + if not engineering: + raise ValueError("engineering_model must not be empty") + if not sales: + raise ValueError("sales_models must contain at least one label") + if provenance not in {"manual", "source"}: + raise ValueError("provenance must be 'manual' or 'source'") + evidence = self._validate_mapping_sources(source_uris or []) + labels = [engineering, *sales, parent] + resolved_device_uri = device_uri.strip() or self._find_device_uri(labels) + if resolved_device_uri: + identity = self.store.lookup_by_uri(resolved_device_uri) + if identity is None or identity[0] != "Device": + raise ValueError(f"device_uri must resolve to a Device page: {resolved_device_uri}") + if self.read_resource(resolved_device_uri) is None: + raise ValueError( + f"device_uri does not point to a readable Device page: {resolved_device_uri}" + ) + status = "confirmed" if evidence and provenance == "source" else "pending" + if approved_by.strip(): + status = "confirmed" + now = datetime.now(UTC).isoformat() + key = self._model_label(engineering) + records = self._read_model_mapping_records() + record = { + "mapping_id": f"model-map-{key}", + "engineering_model": engineering, + "sales_models": sales, + "parent_model": parent, + "device_uri": resolved_device_uri, + "source_uris": evidence, + "provenance": provenance, + "status": status, + "approved_by": approved_by.strip(), + "notes": notes.strip(), + "updated_at": now, + } + replaced = False + for index, existing in enumerate(records): + if self._model_label(str(existing.get("engineering_model", ""))) == key: + records[index] = record + replaced = True + break + if not replaced: + records.append(record) + self.store.write_json( + self._MODEL_MAPPING_KEY, + { + "version": 1, + "updated_at": now, + "mappings": sorted(records, key=lambda item: str(item.get("mapping_id", ""))), + }, + ) + return {**record, "requires_review": status != "confirmed", "replaced": replaced} + + def get_model_mappings( + self, + model_id: str = "", + *, + include_pending: bool = True, + ) -> list[dict[str, object]]: + """Return mappings matching an engineering, sales, or parent label.""" + query = self._model_label(model_id) + result: list[dict[str, object]] = [] + for record in self._read_model_mapping_records(): + if not include_pending and str(record.get("status", "")) != "confirmed": + continue + if query: + labels = [ + str(record.get("engineering_model", "")), + str(record.get("parent_model", "")), + *[ + str(value) + for value in record.get("sales_models", []) + if isinstance(value, str) + ], + ] + if query not in {self._model_label(label) for label in labels}: + continue + result.append(record) + return result + + def model_mapping_report(self) -> dict[str, object]: + """Return counts and unresolved mapping records for build reporting.""" + records = self._read_model_mapping_records() + source_backed = [record for record in records if record.get("source_uris")] + pending = [record for record in records if record.get("status") != "confirmed"] + unresolved_device = [ + record for record in records if not str(record.get("device_uri", "")).strip() + ] + return { + "mapping_count": len(records), + "confirmed_count": len(records) - len(pending), + "pending_count": len(pending), + "source_backed_count": len(source_backed), + "unresolved_device_count": len(unresolved_device), + "manual_pending_count": sum( + 1 for record in pending if record.get("provenance") == "manual" + ), + "records": records, + } diff --git a/src/wolfharness/capabilities/wiki/io/template_materializer.py b/src/wolfharness/capabilities/wiki/io/template_materializer.py new file mode 100644 index 000000000..6b60c5aa0 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/io/template_materializer.py @@ -0,0 +1,336 @@ +"""Template-based entity materialization from source packet fields. + +Assembles entity page content directly from structured packet body fields +(``source_subject``, ``explicit_facts``, ``parts_and_specs``, +``evidence_map``, ``ordered_actions``) without invoking an LLM. + +This is the fast path for single-packet candidates where the packet already +carries all source-backed content needed for a draft entity page. The output +is a full markdown page with YAML frontmatter, ready for ``write_entities_batch``. + +Multi-packet merge candidates (where identity reconciliation is needed) stay on +the LLM worker path — ``plan_materialization_work`` marks them ``strategy=llm``. +""" + +from __future__ import annotations + +from typing import Any + + +__all__ = [ + "assemble_template_entity", + "strip_device_prefix", +] + + +def strip_device_prefix(object_name: str, device_id: str, series_id: str) -> str: + """Strip a device model prefix from *object_name* if present. + + Uses the build's actual ``device_id`` / ``series_id`` (e.g. ``SY75C`` / + ``SY75``) so the check is generic across manuals — no hardcoded prefix list. + Returns the original *object_name* when no prefix matches or the stripped + result would be empty. + """ + for prefix in (device_id, series_id): + if not prefix: + continue + if object_name.startswith(prefix): + stripped = object_name[len(prefix) :].lstrip("-_/ :") + if stripped: + return stripped + return object_name + + +def _yaml_escape(value: str) -> str: + """Escape a string for safe YAML scalar usage.""" + # Wrap in quotes if it contains characters that would confuse YAML parsing. + if any( + ch in value + for ch in ( + ":", + "#", + "'", + '"', + "\n", + "{", + "}", + "[", + "]", + ",", + "&", + "*", + "!", + "|", + ">", + "%", + "@", + "`", + ) + ): + escaped = value.replace("'", "''") + return f"'{escaped}'" + return value + + +def _concept_id_prefix(concept: str) -> str: + """Return the frontmatter ``id`` prefix for a concept (e.g. Component → COMP).""" + mapping = { + "Component": "COMP", + "DTC": "DTC", + "Device": "DEV", + "Symptom": "SYM", + "Fault": "FLT", + "Procedure": "PROC", + "OP": "OP", + } + return mapping.get(concept, concept.upper()[:4]) + + +def _format_parts_and_specs(parts_and_specs: Any) -> list[str]: + """Render ``parts_and_specs`` dict as markdown bullet lines.""" + lines: list[str] = [] + if not isinstance(parts_and_specs, dict): + return lines + for key, value in parts_and_specs.items(): + if not str(key).strip(): + continue + lines.append(f"- **{key}**: {value}") + return lines + + +def _format_explicit_facts(explicit_facts: Any, limit: int = 5) -> list[str]: + """Render ``explicit_facts`` list as markdown bullet lines (capped).""" + lines: list[str] = [] + if not isinstance(explicit_facts, list): + return lines + for fact in explicit_facts[:limit]: + if isinstance(fact, str) and fact.strip(): + lines.append(f"- {fact.strip()}") + return lines + + +def _format_ordered_actions(ordered_actions: Any) -> list[str]: + """Render ``ordered_actions`` list as numbered steps.""" + lines: list[str] = [] + if not isinstance(ordered_actions, list): + return lines + for idx, action in enumerate(ordered_actions, 1): + if not isinstance(action, dict): + continue + action_name = str(action.get("action", action.get("step", ""))).strip() + if not action_name: + continue + criterion = str(action.get("decision_criterion", "")).strip() + if criterion: + lines.append(f"{idx}. **{action_name}** — 判定标准: {criterion}") + else: + lines.append(f"{idx}. **{action_name}**") + return lines + + +def _format_sources(evidence_map: Any, source_uris: list[str]) -> list[str]: + """Deduce source URIs from evidence_map or fallback to source_uris.""" + uris: list[str] = [] + seen: set[str] = set() + if isinstance(evidence_map, list): + for entry in evidence_map: + if not isinstance(entry, dict): + continue + uri = str(entry.get("source_uri", "")).strip() + if uri and uri not in seen: + uris.append(uri) + seen.add(uri) + for uri in source_uris: + uri = str(uri).strip() + if uri and uri not in seen: + uris.append(uri) + seen.add(uri) + return uris + + +def _format_images(images: Any) -> list[str]: + """Render ``images`` list as markdown image references.""" + lines: list[str] = [] + if not isinstance(images, list): + return lines + for img in images: + if not isinstance(img, dict): + continue + uri = str(img.get("uri", "")).strip() + if not uri: + continue + caption = str(img.get("caption", "")).strip() + lines.append(f"![{caption}]({uri})") + lines.append("") + return lines + + +def _component_body_sections( + *, + source_subject: str, + working_mechanism: str, + parts_and_specs: Any, +) -> list[str]: + """Body sections for Component pages: identity + normal working principle. + + Component pages carry no parameter tables, thresholds, wiring info or + fault propagation. ``## 工作机理`` is always emitted so missing + ``working_mechanism`` gaps stay visible. + """ + lines: list[str] = [] + if source_subject: + lines.append("## 总成概览") + lines.append("") + lines.append(source_subject) + lines.append("") + + lines.append("## 工作机理") + lines.append("") + lines.append( + working_mechanism.strip() or "> 工作机理待补充: 当前 packet 未提供 working_mechanism。" + ) + lines.append("") + + spec_lines = _format_parts_and_specs(parts_and_specs) + if spec_lines: + lines.append("## 组成零件") + lines.append("") + lines.extend(spec_lines) + lines.append("") + return lines + + +def _fault_body_sections( + *, + source_subject: str, + failure_mechanism: str, +) -> list[str]: + """Body sections for Fault pages: failure description + mechanism. + + ``## 失效机理`` is always emitted so missing ``failure_mechanism`` + gaps stay visible. Observable symptoms / 影响范围 are populated by + relation workers, not the template path. + """ + lines: list[str] = [] + if source_subject: + lines.append("## 失效描述") + lines.append("") + lines.append(source_subject) + lines.append("") + + lines.append("## 失效机理") + lines.append("") + lines.append( + failure_mechanism.strip() or "> 失效机理待补充: 当前 packet 未提供 failure_mechanism。" + ) + lines.append("") + return lines + + +def assemble_template_entity( + *, + packet_body: dict[str, Any], + concept: str, + class_name: str, + object_name: str, + device_model: str, + source_uris: list[str] | None = None, +) -> str: + """Assemble a full entity page (frontmatter + body) from packet body fields. + + Only sections with content are emitted — no empty headings. Component + pages are the exception: ``## 工作机理`` is always emitted (with a + placeholder when ``working_mechanism`` is missing) so content + gaps stay visible. Fault pages likewise always emit ``## 失效机理``. + The output is a draft entity page (``status: draft``) + ready for ``write_entities_batch``. + + Args: + packet_body: The ``packet`` field from a stored source packet. + concept: Entity concept (Component, DTC, Procedure, …). + class_name: Logical assembly path or controller role. + object_name: Brand+model or mechanical type name. + device_model: Build ``device_id`` or ``series_id`` for applicable_models. + source_uris: Fallback source URIs from the packet record. + + Returns: + Full markdown content string with YAML frontmatter. + """ + source_subject = str(packet_body.get("source_subject", "")).strip() + parts_and_specs = packet_body.get("parts_and_specs") + explicit_facts = packet_body.get("explicit_facts") + ordered_actions = packet_body.get("ordered_actions") + working_mechanism = str(packet_body.get("working_mechanism", "")) + failure_mechanism = str(packet_body.get("failure_mechanism", "")) + images = packet_body.get("images") + + # ── Frontmatter ────────────────────────────────────────────────────── + id_prefix = _concept_id_prefix(concept) + fm_lines = [ + "---", + f"id: {id_prefix}-{object_name}", + f"title: {object_name}", + f"description: {_yaml_escape(source_subject)}", + f"class_name: {class_name}", + f"object_name: {object_name}", + "status: draft", + ] + if device_model: + fm_lines.append("applicable_models:") + fm_lines.append(f" - {device_model}") + fm_lines.append("---") + + # ── Body sections (only non-empty) ─────────────────────────────────── + body_lines: list[str] = [] + + if concept == "Component": + body_lines.extend( + _component_body_sections( + source_subject=source_subject, + working_mechanism=working_mechanism, + parts_and_specs=parts_and_specs, + ) + ) + elif concept == "Fault": + body_lines.extend( + _fault_body_sections( + source_subject=source_subject, + failure_mechanism=failure_mechanism, + ) + ) + else: + if source_subject: + body_lines.append("## 总成概览") + body_lines.append("") + body_lines.append(source_subject) + body_lines.append("") + + spec_lines = _format_parts_and_specs(parts_and_specs) + if spec_lines: + body_lines.append("## 规格参数") + body_lines.append("") + body_lines.extend(spec_lines) + body_lines.append("") + + fact_lines = _format_explicit_facts(explicit_facts) + if fact_lines: + body_lines.append("## 关键事实") + body_lines.append("") + body_lines.extend(fact_lines) + body_lines.append("") + + action_lines = _format_ordered_actions(ordered_actions) + if action_lines: + body_lines.append("## 步骤") + body_lines.append("") + body_lines.extend(action_lines) + body_lines.append("") + + # ── Images (shared across all concepts) ────────────────────────────── + image_lines = _format_images(images) + if image_lines: + body_lines.append("## 附图") + body_lines.append("") + body_lines.extend(image_lines) + + return "\n".join(fm_lines) + "\n" + "\n".join(body_lines) diff --git a/src/wolfharness/capabilities/wiki/io/text_compact.py b/src/wolfharness/capabilities/wiki/io/text_compact.py new file mode 100644 index 000000000..5c458fc3d --- /dev/null +++ b/src/wolfharness/capabilities/wiki/io/text_compact.py @@ -0,0 +1,97 @@ +"""Compact raw chapter text before it reaches the model context. + +Wiki chapters are OCR-converted PDFs: they carry a lot of boilerplate that +carries zero entity-extraction signal — HTML residue (``<td>``/``<div>``), +image placeholders (``<image_token …>``), and verbose table rows. Repeating +template lines appear in 1 of every 4 chapters, so stripping them at the read +boundary cuts model input tokens with no loss of extractable facts. + +The compaction is applied only at the *read* boundary (``read_chapter`` / +``read_raw_resource``) — the raw files and their hashes are untouched, so +source/evidence references stay valid. It is intentionally lossy only in +layout: run values, units, part numbers, and DTC codes are all preserved. + +This module is pure and framework-free so it can be unit tested directly. +""" + +from __future__ import annotations + +import re + + +# Table rows and image placeholders contribute noise, not entities. +_TABLE_TAG_RE = re.compile(r"</?(?:td|th|tr|table)\b[^>]*>", flags=re.IGNORECASE) +_IMAGE_TOKEN_RE = re.compile(r"<image_token\b[^>]*/?>", flags=re.IGNORECASE) +_HTML_TAG_RE = re.compile(r"<[a-zA-Z/][^>]*>") +# Footnote divs carry part annotations (figure labels like "A1. 至行走马达PT油口"). +# Strip the wrapper tags but keep the inner text — the annotations are entity +# evidence, not boilerplate. +_FOOTNOTE_OPEN_RE = re.compile(r"<div\b[^>]*class=\"footnote\"[^>]*>\s*", flags=re.IGNORECASE) +_FOOTNOTE_CLOSE_RE = re.compile(r"</div>\s*", flags=re.IGNORECASE) +_EMPTY_CELL_RE = re.compile( + r"<\s*(?:td|th|tr|table)\s*([^>]*?)\s*>\s*</\s*(?:td|th|tr|table)\s*>", flags=re.IGNORECASE +) + +# Rows that repeat verbatim across every chapter (safety notices, torque +# reminders). Dropping them removes up to ~26% of duplicated input. +# Star-prefixed lines only drop when they carry NO measurement fingerprint +# (units/numbers), so "★ 使用 6 MPa 油压计" survives while fixed template +# noise like "★ 安装过程中螺栓扭矩…" is removed. +_HAS_NUMERIC_FINGERPRINT = re.compile( + r"\d+(?:\.\d+)?\s*(?:MPa|kPa|kgf|kg|N·m|V|Ω|A|rpm|mm|bar|℃|°C|L/min)", +) +_NOISE_LINE_RE = re.compile( + r"^\s*(?:" + r"[-*]\s*(?:安装|装配|拆卸|维修|保养)过程中螺栓扭矩[^\n]*|" + r"注意[::][^\n]*|" + r"警告[::][^\n]*" + r")\s*$", +) +_EMPTY_TABLE_ROW_RE = re.compile( + r"^\s*(?:<td[^>]*>\s*</td>|<td colspan=[\"']?\d+[\"']?\s*</td>\s*)+$" +) + + +def compact_chapter(content: str) -> str: + """Return a layout-compacted copy of a raw chapter for model reading. + + Keeps all extractable text: prose, tables (stripped of HTML tags but + cells intact), part numbers, DTC codes, run values. Only removes + HTML scaffolding, image placeholders, and cross-chapter boilerplate + lines. + + Args: + content: Full raw chapter markdown/HTML text. + + Returns: + Compacted text with the same newline structure where meaningful. + """ + text = content + text = _FOOTNOTE_OPEN_RE.sub("", text) + text = _FOOTNOTE_CLOSE_RE.sub("", text) + text = _EMPTY_CELL_RE.sub("", text) + text = _TABLE_TAG_RE.sub("", text) + text = _IMAGE_TOKEN_RE.sub("", text) + # Drop tag-only lines before collapsing the rest of the HTML tags. + text = _EMPTY_TABLE_ROW_RE.sub("", text) + text = _HTML_TAG_RE.sub("", text) + + out_lines: list[str] = [] + for original_line in text.splitlines(): + stripped_line = original_line.rstrip() + if not stripped_line.strip(): + continue + if stripped_line.lstrip().startswith("★") and not _HAS_NUMERIC_FINGERPRINT.search( + stripped_line + ): + # Template safety/torque reminder without any value → noise. + continue + if _NOISE_LINE_RE.match(stripped_line): + continue + # Deduplicate adjacent identical lines within a chapter as well, + # collapse >1 blank to one. + if out_lines and out_lines[-1] == stripped_line: + continue + out_lines.append(stripped_line) + + return "\n".join(out_lines) diff --git a/src/wolfharness/capabilities/wiki/io/text_parsers.py b/src/wolfharness/capabilities/wiki/io/text_parsers.py new file mode 100644 index 000000000..8380733ae --- /dev/null +++ b/src/wolfharness/capabilities/wiki/io/text_parsers.py @@ -0,0 +1,529 @@ +"""Pure-function helpers and the TextParsersMixin for WikiBuildTools. + +Extracted from ``mcp_server.py`` to keep the composition root small. All +methods here are ``@staticmethod`` / ``@classmethod`` and have zero ``self`` +dependencies, so the mixin can be reused without instantiating +``WikiBuildTools``. +""" + +from __future__ import annotations + +import logging +import re +from typing import TYPE_CHECKING + +from wolfharness.capabilities.wiki.quality import extract_source_uris + + +if TYPE_CHECKING: + from collections.abc import Callable + from pathlib import Path + + +logger = logging.getLogger(__name__) + + +# ── Regex helpers (moved from old builder) ────────────────────────────────── + +_CHAPTER_PREFIX_RE = re.compile(r"^\d+_\d+(?:\.\d+)*\s+") +_TITLE_SEGMENT_RE = re.compile(r"^\d+[_\s]*") +_TOC_TITLE_PREFIX_RE = re.compile(r"^\d+(?:\.\d+)*\s+") +_DIR_PREFIX_RE = re.compile(r"^\d+_") + +# Matches agent-generated entity headings still carrying a scheme URI (raw +# extraction may emit a path-shaped URI instead of the canonical hash URI); +# write paths normalize it to ``# <canonical_uri>``. +_ENTITY_URI_HEADING_RE = re.compile(r"^#\s+(?:viking://|file://)\S+(?:\r?\n|$)") + +# ── Directory-name ↔ TOC-title converters ─────────────────────────────────── + + +def _dir_to_toc_title(dir_name: str) -> str: + """Convert a chapter directory name to a TOC display title. + + ``01_1 前言`` → ``1 前言`` + ``01_1.1 阅读维修手册的方法`` → ``1.1 阅读维修手册的方法`` + ``02_5.2 液压子系统原理`` → ``5.2 液压子系统原理`` + """ + stripped = _DIR_PREFIX_RE.sub("", dir_name) + return stripped.strip() + + +def _dir_to_clean_title(dir_name: str) -> str: + """Extract a clean (number-free) title from a chapter directory name. + + ``01_1 前言`` → ``前言`` + ``01_1.1 阅读维修手册的方法`` → ``阅读维修手册的方法`` + """ + toc_title = _dir_to_toc_title(dir_name) + return _TOC_TITLE_PREFIX_RE.sub("", toc_title).strip() + + +def _parse_forward_links(content: str) -> list[str]: + """Extract all source URIs from content (forward links).""" + return sorted(extract_source_uris(content)) + + +class TextParsersMixin: + """Pure parsing helpers shared by :class:`WikiBuildTools`.""" + + _H2_SECTION_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) + + _FM_BOUNDARY_RE = re.compile(r"\n---\n") + + @staticmethod + def _build_chapter_map( + doc_id: str, + chapters: list[dict], + make_uri: Callable[[str, str], str], + ) -> dict[str, str]: + """Build title → raw chapter URI dict from chapter list. + + Primary keys (in order): + 1. **Clean TOC title** — number prefix stripped (``发动机冒黑烟``) + 2. **Raw TOC title** — as-is from ``toc.json`` (``9.9.6 发动机冒黑烟``) + 3. **Subdir-derived title** — last path segment, prefix stripped + + Dual indexing lets agents look up by either form: the clean title + extracted from ``toc.md`` or the raw line from the TOC markdown. + When multiple chapters share a key, the first wins. + + ``make_uri`` builds the real-path URI for a ``(doc_id, subdir)`` + pair; the mixin wires it to ``self.make_source_uri``. + """ + mapping: dict[str, str] = {} + for ch in chapters: + subdir = ch.get("subdir", "") + if not subdir: + continue + uri = make_uri(doc_id, subdir) + + title = ch.get("title", "") + if title: + raw = title.strip() + clean = _TOC_TITLE_PREFIX_RE.sub("", raw).strip() + if clean and clean not in mapping: + mapping[clean] = uri + if raw != clean and raw not in mapping: + mapping[raw] = uri + + last_seg = subdir.rsplit("/", 1)[-1] + derived = _TITLE_SEGMENT_RE.sub("", last_seg).strip() + if derived and derived not in mapping: + mapping[derived] = uri + if subdir not in mapping: + mapping[subdir] = uri + + return mapping + + @staticmethod + def _extract_model_from_doc(doc_dir: Path, pdf_path: str) -> str: + """Extract model ID from filename first, then from parsed content. + + Phase 1: regex on filename / doc_id (fastest). + Phase 2: regex on first 200 lines of ``full.md`` (parsed PDF content). + Falls back to ``"unknown"`` if both fail. + """ + # Phase 1: filename + for p in (pdf_path, doc_dir.name): + m = re.search(r"([A-Z]+[0-9]+[A-Z]?)", p) + if m: + return m.group(1).lower() + + # Phase 2: parsed content (full.md) + full_md = doc_dir / "full.md" + if full_md.is_file(): + try: + with full_md.open(encoding="utf-8") as f: + for _i, line in enumerate(f): + if _i >= 200: + break + m = re.search(r"\b([A-Z]{2,}[0-9]+[A-Z]?)\b", line) + if m: + candidate = m.group(1) + # Filter out non-model matches like "PDF", "ISBN" + if candidate not in { + "PDF", + "ISBN", + "URL", + "HTTP", + "HTML", + "XML", + "JSON", + }: + return candidate.lower() + except OSError: + pass + + logger.warning("Could not extract model from %s (pdf_path=%s)", doc_dir.name, pdf_path) + return "unknown" + + @classmethod + def _dedupe_h2_sections(cls, content: str) -> str: + """Merge repeated level-two sections without losing their evidence. + + Relation workers use section patches, and an interrupted/retried patch + can otherwise append a second copy of the same heading. Keeping the + first heading and merging only non-duplicate section bodies makes the + operation idempotent and preserves source text. + """ + if not content.startswith("---\n"): + body_start = 0 + else: + closing = content.find("\n---\n", 4) + if closing == -1: + return content + body_start = closing + 5 + prefix = content[:body_start] + body = content[body_start:] + matches = list(cls._H2_SECTION_RE.finditer(body)) + if len(matches) < 2: + return content + + preamble = body[: matches[0].start()] + sections: list[tuple[str, str]] = [] + positions: dict[str, int] = {} + for index, match in enumerate(matches): + heading = match.group(1).strip() + end = matches[index + 1].start() if index + 1 < len(matches) else len(body) + section_body = body[match.end() : end].strip() + existing_index = positions.get(heading) + if existing_index is None: + positions[heading] = len(sections) + sections.append((heading, section_body)) + continue + old_heading, old_body = sections[existing_index] + if not section_body: + continue + old_normalized = re.sub(r"\s+", " ", old_body).strip() + new_normalized = re.sub(r"\s+", " ", section_body).strip() + if new_normalized and new_normalized not in old_normalized: + merged = f"{old_body}\n\n{section_body}" if old_body else section_body + sections[existing_index] = (old_heading, merged) + + rendered = [preamble] + for heading, section_body in sections: + rendered.append(f"## {heading}\n") + if section_body: + rendered.append(f"{section_body}\n") + return prefix + "".join(rendered) + + @staticmethod + def _frontmatter_uri_values( + root_uri: str, frontmatter: dict[str, object], field: str + ) -> list[str]: + """Return URI-like values from a YAML relation field.""" + prefix = root_uri + "/" + value = frontmatter.get(field) + if isinstance(value, str): + return [value] if value.startswith(prefix) else [] + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, str) and item.startswith(prefix)] + + @classmethod + def _append_section_links( + cls, + content: str, + heading: str, + links: list[tuple[str, str]], + ) -> str: + """Append canonical Markdown links to one existing ``##`` section.""" + if not links: + return content + matches = list(cls._H2_SECTION_RE.finditer(content)) + target_index = next( + (index for index, match in enumerate(matches) if match.group(1).strip() == heading), + None, + ) + link_lines = [f"- [{label}]({uri})" for label, uri in links] + if target_index is None: + suffix = "\n" if content.endswith("\n") else "\n\n" + return f"{content}{suffix}## {heading}\n\n" + "\n".join(link_lines) + "\n" + match = matches[target_index] + end = matches[target_index + 1].start() if target_index + 1 < len(matches) else len(content) + section = content[match.end() : end] + missing = [line for line in link_lines if line not in section] + if not missing: + return content + insertion = section.rstrip() + "\n" + "\n".join(missing) + "\n" + return content[: match.end()] + insertion + content[end:] + + @staticmethod + def _apply_line_op(content: str, op: dict) -> str: + """Apply a line-based patch operation (1-indexed).""" + lines = content.splitlines(keepends=True) + + if op["op"] == "line_replace": + start, end = op["start"], op["end"] + if start < 1 or end > len(lines) or start > end: + raise ValueError( + f"line_replace range [{start}, {end}] out of bounds (1..{len(lines)})" + ) + replacement = op["content"] + if not replacement.endswith("\n"): + replacement += "\n" + lines[start - 1 : end] = replacement.splitlines(keepends=True) + + elif op["op"] == "line_insert": + at = op["at"] + if at < 1 or at > len(lines) + 1: + raise ValueError(f"line_insert position {at} out of bounds (1..{len(lines) + 1})") + insertion = op["content"] + if not insertion.endswith("\n"): + insertion += "\n" + lines[at - 1 : at - 1] = insertion.splitlines(keepends=True) + + elif op["op"] == "line_delete": + start, end = op["start"], op["end"] + if start < 1 or end > len(lines) or start > end: + raise ValueError( + f"line_delete range [{start}, {end}] out of bounds (1..{len(lines)})" + ) + del lines[start - 1 : end] + + return "".join(lines) + + @staticmethod + def _apply_section_op(content: str, op: dict) -> str: + """Apply a section-based patch operation. + + Sections are identified by ``## <heading>`` lines. A section spans + from its heading line to the next ``## `` heading or end of body. + """ + # Split into heading + frontmatter + body + heading_end = content.find("\n") + if heading_end == -1: + raise ValueError("Content has no body — cannot patch sections") + + rest = content[heading_end + 1 :] + + # Check for frontmatter + fm_text = "" + body_start = 0 + if rest.startswith("---\n"): + fm_close = rest.find("\n---\n", 4) + if fm_close != -1: + fm_text = rest[: fm_close + 5] + body_start = fm_close + 5 + + body = rest[body_start:] + body_lines = body.splitlines(keepends=True) + + # Find section boundaries: list of (heading_text, line_index) for ## headings + section_starts: list[tuple[str, int]] = [] + for i, line in enumerate(body_lines): + if line.startswith("## "): + heading_text = line[3:].strip() + section_starts.append((heading_text, i)) + + target_heading = op["heading"] + target_idx = None + for idx, (h, _line_index) in enumerate(section_starts): + if h == target_heading: + target_idx = idx + break + if target_idx is None: + raise ValueError(f"Section not found: {target_heading!r}") + + section_start_line = section_starts[target_idx][1] + if target_idx + 1 < len(section_starts): + section_end_line = section_starts[target_idx + 1][1] + else: + section_end_line = len(body_lines) + + if op["op"] == "section_replace": + new_content = op["content"] + # ponytail: auto-prepend heading to prevent accidental heading deletion + if not new_content.lstrip().startswith(f"## {target_heading}"): + new_content = f"## {target_heading}\n{new_content}" + if not new_content.endswith("\n"): + new_content += "\n" + new_lines = new_content.splitlines(keepends=True) + body_lines[section_start_line:section_end_line] = new_lines + + elif op["op"] == "section_insert_after": + heading_new = op["heading_new"] + new_content = op["content"] + block = f"## {heading_new}\n" + if not new_content.endswith("\n"): + new_content += "\n" + block += new_content + body_lines[section_end_line:section_end_line] = block.splitlines(keepends=True) + + new_body = "".join(body_lines) + return content[: heading_end + 1] + fm_text + new_body + + @staticmethod + def _apply_fm_op(content: str, op: dict) -> str: + """Apply a frontmatter field patch operation.""" + # Locate frontmatter boundaries + fm_start = content.find("---\n") + if fm_start == -1: + raise ValueError("No frontmatter found in entity content") + fm_end = content.find("\n---\n", fm_start + 4) + if fm_end == -1: + raise ValueError("Malformed frontmatter — no closing ---") + + fm_end += 4 # include the closing "---\n" + fm_text = content[:fm_end] + body = content[fm_end:] + fm_lines = fm_text.splitlines(keepends=True) + + field = op["field"] + + if op["op"] == "fm_set": + # Find existing field or append + found = False + for i, line in enumerate(fm_lines): + if line.strip().startswith(f"{field}:") and not line.startswith(" "): + fm_lines[i] = f"{field}: {op['value']}\n" + found = True + break + if not found: + # Insert before closing --- + fm_lines.insert(-1, f"{field}: {op['value']}\n") + + elif op["op"] == "fm_set_list": + values = op["values"] + field_line_idx = None + for i, line in enumerate(fm_lines): + if line.strip().startswith(f"{field}:") and not line.startswith(" "): + field_line_idx = i + break + if field_line_idx is not None: + item_end = field_line_idx + 1 + for j in range(field_line_idx + 1, len(fm_lines)): + stripped = fm_lines[j].rstrip() + if stripped.startswith(" - "): + item_end = j + 1 + else: + break + fm_lines[field_line_idx] = f"{field}:\n" + del fm_lines[field_line_idx + 1 : item_end] + new_lines = [f" - {v}\n" for v in values] + fm_lines[field_line_idx + 1 : field_line_idx + 1] = new_lines + else: + insert_block = [f"{field}:\n"] + insert_block.extend(f" - {v}\n" for v in values) + fm_lines.insert(-1, "".join(insert_block)) + + elif op["op"] == "fm_append": + values = op["values"] + # Find existing field + field_line_idx = None + for i, line in enumerate(fm_lines): + if line.strip() == f"{field}:" or line.strip().startswith(f"{field}: []"): + field_line_idx = i + break + if line.strip().startswith(f"{field}:") and not line.startswith(" "): + # Inline list field like "field: [a, b]" + field_line_idx = i + break + + if field_line_idx is not None: + # Find existing list items under this field + existing_items: list[str] = [] + item_start = field_line_idx + 1 + item_end = item_start + for j in range(item_start, len(fm_lines)): + stripped = fm_lines[j].rstrip() + if stripped.startswith(" - "): + existing_items.append(stripped[4:].strip()) + item_end = j + 1 + else: + break + + # Check if it's an inline list + field_line = fm_lines[field_line_idx].strip() + if field_line != f"{field}:" and not field_line.startswith(f"{field}: []"): + # Parse inline list + bracket_content = field_line[field_line.find("[") + 1 : field_line.rfind("]")] + if bracket_content.strip(): + existing_items.extend(v.strip() for v in bracket_content.split(",")) + # Convert to block list format, re-inserting existing items + fm_lines[field_line_idx] = f"{field}:\n" + existing_item_lines = [f" - {v}\n" for v in existing_items] + fm_lines[field_line_idx + 1 : field_line_idx + 1] = existing_item_lines + item_start = field_line_idx + 1 + item_end = item_start + len(existing_item_lines) + elif field_line.startswith(f"{field}: []"): + # Empty list `[]` — convert to block list format before appending + fm_lines[field_line_idx] = f"{field}:\n" + item_start = field_line_idx + 1 + item_end = item_start + + # Append new values (dedup) + existing_set = set(existing_items) + new_items: list[str] = [] + for v in values: + if v not in existing_set: + existing_set.add(v) + new_items.append(v) + + if new_items: + new_item_lines = [f" - {v}\n" for v in new_items] + fm_lines[item_end:item_end] = new_item_lines + else: + # Field doesn't exist — add it before closing --- + insert_block = [f"{field}:\n"] + insert_block.extend(f" - {v}\n" for v in values) + fm_lines.insert(-1, "".join(insert_block)) + + new_fm = "".join(fm_lines) + return new_fm + body + + @staticmethod + def _read_markdown_fragment(content: str, fragment: str) -> str | None: + """Return the Markdown section(s) addressed by a fragment. + + Generated references sometimes carry a display qualifier after the + heading (for example ``SPEC-输出电压(B19``) or combine adjacent + specification headings with ``/``. Resolve those forms against the + actual heading text while still returning ``None`` for an unrelated + fragment. + """ + if not fragment: + return content + heading_pattern = re.compile(r"^(?P<level>#{1,6})\s+(?P<title>.+?)\s*$", re.MULTILINE) + headings = list(heading_pattern.finditer(content)) + requested = [part.strip() for part in fragment.split("/") if part.strip()] + selected: list[re.Match[str]] = [] + for part in requested: + match = next( + ( + candidate + for candidate in headings + if candidate.group("title") == part + or any( + part.startswith(candidate.group("title") + prefix) + for prefix in ("(", " ", "—") + ) + or any( + candidate.group("title").startswith(part + prefix) + for prefix in ("(", " ", "—") + ) + ), + None, + ) + if match is not None and match not in selected: + selected.append(match) + if not selected: + return None + sections: list[str] = [] + for match in selected: + level = len(match.group("level")) + following_heading = re.compile(rf"^#{{1,{level}}}\s+", re.MULTILINE) + next_match = following_heading.search(content, match.end()) + end = next_match.start() if next_match is not None else len(content) + sections.append(content[match.start() : end].rstrip()) + return "\n\n".join(sections) + "\n" + + @staticmethod + def _read_legacy_text(root: Path, relative: str) -> str | None: + resolved_root = root.resolve() + requested = (resolved_root / relative).resolve() + for path in (requested, requested.with_suffix(".md")): + if path.is_relative_to(resolved_root) and path.is_file() and not path.is_symlink(): + return path.read_text(encoding="utf-8") + return None diff --git a/src/wolfharness/capabilities/wiki/models.py b/src/wolfharness/capabilities/wiki/models.py new file mode 100644 index 000000000..f6775942f --- /dev/null +++ b/src/wolfharness/capabilities/wiki/models.py @@ -0,0 +1,1465 @@ +"""models — 知识库核心数据模型. + +对应 design.md §三 概念层与实体层设计。 +包含统一的数据模型、抽取结果容器和自校验逻辑。 + +所有入库资源均使用 viking:// URI 协议(viking://resources/<namespace>/...), +通过 resource.json 维护 uri → 文件路径映射。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import hashlib +import json +import logging +import re +from typing import Any, ClassVar +from urllib.parse import quote + +from wolfharness.capabilities.wiki.namespaces import wiki_resources_root + + +logger = logging.getLogger(__name__) + +_CONCEPT_NAME_RE = re.compile(r"\[\[concepts/([^\]]+)\]\]") + +_NUM_RE = re.compile(r"-?\d+(?:\.\d+)?") + +OPA_REASON_CODES: tuple[str, ...] = ( + # Coarse reasons — set at OPA creation, before retrieval triage. + "content_missing", + "fact_conflict", + "expert_feedback", + # Fine reasons — OPS refines into these after retrieval; each maps to a + # distinct repair path, documented inline because the names alone don't + # reveal the fix direction. + "source_incomplete", # 源真没有(无品牌型号)→ 接外部 BOM/规格表 + "extraction_missed", # 源有内容但管线没物化实体 → 修管线/补跑 extraction + "relation_missed", # 目标实体存在但没链接 → 补 link / rebuild_backlinks + "param_unhosted", # 有标准值但无 Component 载体 → 等 Component 物化后回填 + "manual_error", # 手册内容错/跨章节矛盾 → 人工裁决 + "process_conflict", # hook/任务规范冲突 → 规范对齐(当前 fact_conflict 多属此类) + "hallucination", # 模型捏造 → 丢弃/修正 +) + +# Fine-grained reasons an OPS may refine a coarse OPA into after retrieval. +OPA_REASON_FINE_CODES: frozenset[str] = frozenset( + { + "source_incomplete", + "extraction_missed", + "relation_missed", + "param_unhosted", + "manual_error", + "process_conflict", + "hallucination", + }, +) + +OPA_CLOSURE_STATUSES: tuple[str, ...] = ("open", "deferred", "closed") + + +def infer_opa_reason_code(category: str) -> str: + """Map the legacy OPA category to a stable machine-readable reason.""" + if category.strip().lower() in {"feedback", "expert_feedback"}: + return "expert_feedback" + return "fact_conflict" if category.strip().lower() == "conflict" else "content_missing" + + +def _normalize_content(text: str) -> str: + """Normalize content for comparison: strip whitespace, lowercase first 200 chars.""" + return text.strip()[:200].casefold() + + +def _parse_number(value: object) -> float | None: + """Extract the first numeric value from a string or numeric input.""" + if isinstance(value, (int, float)): + return float(value) + if not isinstance(value, str): + return None + match = _NUM_RE.search(value) + return float(match.group()) if match else None + + +def _yaml_quote(value: str) -> str: + """Encode a user/LLM-controlled scalar safely for YAML frontmatter.""" + return json.dumps(value, ensure_ascii=False) + + +def _fm(value: object) -> str | None: + """Render a YAML frontmatter field, or ``None`` to omit empty fields. + + Empty strings and empty lists are omitted so expert-facing files are + not padded with ``field: ""`` noise. + """ + if isinstance(value, str): + return None if not value.strip() else _yaml_quote(value) + if isinstance(value, (list, dict)) and not value: + return None + if isinstance(value, bool) or value is None: + return str(value) + return str(value) + + +def _fm_lines(fields: list[tuple[str, object]]) -> list[str]: + """Render ``(key, value)`` pairs as ``key: value`` frontmatter lines.""" + return [f"{key}: {rendered}" for key, value in fields if (rendered := _fm(value)) is not None] + + +_ZH_STATUS: dict[str, str] = { + "pending": "待处理", + "resolved": "已处理", + "rejected": "已拒绝", + "superseded": "已取代", + "unconfirmed": "待确认", + "confirmed": "已确认", + "applied": "已应用", + "archived": "已归档", +} +_ZH_CLOSURE: dict[str, str] = { + "open": "未关闭", + "deferred": "延迟处理", + "closed": "已关闭", +} +_ZH_CATEGORY: dict[str, str] = { + "gap": "内容缺失", + "conflict": "知识冲突", + "feedback": "专家反馈", +} +_ZH_REASON: dict[str, str] = { + "content_missing": "内容缺失", + "fact_conflict": "事实冲突", + "expert_feedback": "专家反馈", + "source_incomplete": "来源不完整", + "extraction_missed": "抽取遗漏", + "relation_missed": "关系缺失", + "param_unhosted": "参数无载体", + "manual_error": "手册错误", + "process_conflict": "流程冲突", + "hallucination": "模型捏造", +} +_ZH_APPLY: dict[str, str] = { + "not_ready": "未就绪", + "not_applied": "未应用", + "not_applicable": "不适用", + "applied": "已应用", + "needs_review": "待人工确认", + "failed": "失败", +} + + +def zh_status(value: str) -> str: + return _ZH_STATUS.get(value.strip().lower(), value) + + +def zh_closure(value: str) -> str: + return _ZH_CLOSURE.get(value.strip().lower(), value) + + +def zh_category(value: str) -> str: + return _ZH_CATEGORY.get(value.strip().lower(), value) + + +def zh_reason(value: str) -> str: + return _ZH_REASON.get(value.strip().lower(), value) + + +def zh_apply(value: str) -> str: + return _ZH_APPLY.get(value.strip().lower(), value) + + +# ── OPA/OPS feedback models ────────────────────────────────────────────────── + + +@dataclass +class OPAModel: + """A single unresolved knowledge conflict or inconsistency.""" + + opa_id: str + title: str + description: str + human_key: str = "" + category: str = "conflict" + reason_code: str = "" + scope: str = "entity" + subtype: str = "wiki_error" # wiki_error | category_error | case_feedback | user_feedback + target_uri: str = "" + target_path: str = "" + target_section: str = "" + source_chapter: str = "" + evidence_uris: list[str] = field(default_factory=list) + status: str = "pending" + solution: str = "" + finding: str = "" + missing: str = "" + recommendation: str = "" + related_uris: list[str] = field(default_factory=list) + report_count: int = 1 + dedupe_key: str = "" + build_id: str = "" + # closure_status is the substantive gap state, independent of the + # administrative `status` (pending/resolved/...). `resolved` no longer + # implies the gap is closed — a record can be resolved (admin finalized) + # yet still closure_status=open (gap unfixed). The finalize gate keys off + # closure_status, not status, so status=resolved cannot bypass it. + closure_status: str = "open" # open | deferred | closed + closure_reason: str = "" + + def __post_init__(self) -> None: + """Keep records created by older callers readable and classifiable.""" + if not self.reason_code: + self.reason_code = infer_opa_reason_code(self.category) + if self.reason_code not in OPA_REASON_CODES: + raise ValueError(f"Unsupported OPA reason_code: {self.reason_code!r}") + if self.closure_status not in OPA_CLOSURE_STATUSES: + raise ValueError(f"Unsupported OPA closure_status: {self.closure_status!r}") + + def to_markdown(self, *, ops_uri: str = "") -> str: + """Render a durable OPA record with KB-addressable evidence.""" + evidence = ( + "\n".join(f"- [{uri}]({uri})" for uri in self.evidence_uris) or "- 尚无可解析证据 URI" + ) + frontmatter = ["---", f"id: {_yaml_quote(self.opa_id)}"] + frontmatter += _fm_lines( + [ + ("category", self.category), + ("reason_code", self.reason_code), + ("status", self.status), + ("target_uri", self.target_uri), + ("target_section", self.target_section), + ("scope", self.scope), + ("subtype", self.subtype), + ("source_chapter", self.source_chapter), + ("evidence_uris", self.evidence_uris), + ("related_uris", self.related_uris), + ("report_count", self.report_count), + ("dedupe_key", self.dedupe_key), + ("build_id", self.build_id), + ("closure_status", self.closure_status), + ("closure_reason", self.closure_reason), + ], + ) + frontmatter.append("---") + + lines = [ + *frontmatter, + "", + f"# {self.title}", + "", + "## 问题描述", + "", + self.description, + "", + "## 冲突点", + "", + self.finding or "未单独填写;详见问题描述与证据。", + "", + "## 缺失点", + "", + self.missing or "未单独填写;由对应 audit issue 继续追踪。", + "", + "## 建议", + "", + self.recommendation or self.solution or "待 conductor 裁决。", + "", + "## 证据", + "", + evidence, + "", + ] + if self.related_uris: + lines += [ + "## 关联引用", + "", + *[f"- {uri}" for uri in self.related_uris], + "", + ] + if self.solution: + ops_id = self.opa_id.replace("opa-", "ops-", 1) + # Fallback when caller has no store root_uri in scope; real callers pass ops_uri. + linked_ops_uri = ops_uri or f"{wiki_resources_root()}/OP/{ops_id}" + lines += [ + "## 关联解决方案", + "", + f"- [对应 OPS]({linked_ops_uri})", + "", + ] + lines += [ + f"> 状态:{zh_status(self.status)}({self.status})· 类型:{zh_category(self.category)}({self.category})· 原因:{zh_reason(self.reason_code)}({self.reason_code})· 关闭状态:{zh_closure(self.closure_status)}({self.closure_status})", + "", + ] + return "\n".join(lines) + + +@dataclass +class OPSModel: + """An expert remediation suggestion attached to one OPA record.""" + + ops_id: str + parent_opa: str + title: str = "" + status: str = "unconfirmed" + target_uri: str = "" + solution: str = "" + analysis: str = "" + retrieval_query: str = "" + retrieval_scopes: list[str] = field(default_factory=list) + retrieval_hit_uris: list[str] = field(default_factory=list) + retrieval_used_uris: list[str] = field(default_factory=list) + evidence_uris: list[str] = field(default_factory=list) + related_uris: list[str] = field(default_factory=list) + candidate_content: str = "" + candidate_operations: list[dict[str, object]] = field(default_factory=list) + expected_sha256: str = "" + source_type: str = "pipeline" + reviewed_by: str = "" + review_notes: str = "" + apply_status: str = "not_ready" + apply_error: str = "" + applied_at: str = "" + applied_entity_sha256: str = "" + + def to_markdown(self) -> str: + """Render a source-backed OPS page.""" + solution = self.solution or "<!-- 待补录:请在此描述解决方案 -->" + evidence = "\n".join(f"- [{uri}]({uri})" for uri in self.evidence_uris) or "- 尚无证据" + frontmatter = ["---", f"id: {_yaml_quote(self.ops_id)}"] + frontmatter += _fm_lines( + [ + ("parent_opa", self.parent_opa), + ("status", self.status), + ("target_uri", self.target_uri), + ("retrieval_query", self.retrieval_query), + ("retrieval_scopes", self.retrieval_scopes), + ("retrieval_hit_uris", self.retrieval_hit_uris), + ("retrieval_used_uris", self.retrieval_used_uris), + ("evidence_uris", self.evidence_uris), + ("related_uris", self.related_uris), + ("candidate_content", self.candidate_content), + ("candidate_operations", self.candidate_operations), + ("expected_sha256", self.expected_sha256), + ("source_type", self.source_type), + ("reviewed_by", self.reviewed_by), + ("review_notes", self.review_notes), + ("apply_status", self.apply_status), + ("apply_error", self.apply_error), + ("applied_at", self.applied_at), + ("applied_entity_sha256", self.applied_entity_sha256), + ], + ) + frontmatter.append("---") + return "\n".join( + [ + *frontmatter, + "", + f"# {self.title or self.ops_id}", + "", + "## 关联问题", + "", + f"- [OPA]({self.parent_opa})", + "", + "## 专家分析", + "", + self.analysis or "待补充:说明为什么该建议能解决 OPA 中的冲突或缺失。", + "", + "## 检索凭证", + "", + f"- query: {self.retrieval_query}", + *[f"- scope: {uri}" for uri in self.retrieval_scopes], + *[f"- hit: {uri}" for uri in self.retrieval_hit_uris], + "" if self.retrieval_hit_uris else "- hit: 无(已执行检索,保留 open_gap)", + *[f"- used: {uri}" for uri in self.retrieval_used_uris], + "", + "## 解决方案描述", + "", + solution, + "", + "## 证据引用", + "", + evidence, + "", + "## 关联资源", + "", + *[f"- {uri}" for uri in self.related_uris], + "" if self.related_uris else "- 无", + "", + "> 状态:{}({})· 类型:{}({})· 应用状态:{}({})".format( + zh_status(self.status), + self.status, + "管线生成" if self.source_type == "pipeline" else "外部专家", + self.source_type, + zh_apply(self.apply_status), + self.apply_status, + ), + "", + ], + ) + + +@dataclass +class OPLModel: + """A proposed knowledge update formed from the OP chain or an expert.""" + + opl_id: str + title: str + parent_opa: str + ops_uris: list[str] = field(default_factory=list) + target_uri: str = "" + status: str = "unconfirmed" + proposal: str = "" + rationale: str = "" + evidence_uris: list[str] = field(default_factory=list) + related_uris: list[str] = field(default_factory=list) + source_type: str = "pipeline" + expert_id: str = "" + expert_name: str = "" + source_uri: str = "" + target_concept: str = "" + target_class_name: str = "" + target_object_name: str = "" + expected_sha256: str = "" + candidate_content: str = "" + candidate_operations: list[dict[str, object]] = field(default_factory=list) + apply_status: str = "not_applied" + apply_error: str = "" + applied_at: str = "" + applied_entity_sha256: str = "" + archive_reason: str = "" + + def to_markdown(self) -> str: + """Render an OPL proposal with optional machine-applicable update data.""" + evidence = "\n".join(f"- [{uri}]({uri})" for uri in self.evidence_uris) or "- 尚无证据" + ops = "\n".join(f"- [{uri}]({uri})" for uri in self.ops_uris) or "- 尚无 OPS" + related = "\n".join(f"- {uri}" for uri in self.related_uris) or "- 无" + footer = ( + "> 状态:已应用(applied)。OPL 已通过版本校验并应用到正式实体。" + if self.status == "applied" + else "> 状态:待确认(unconfirmed)。OPL 仅供后续人工/专家确认,不能直接作为正式实体内容发布。" + ) + frontmatter = ["---", f"id: {_yaml_quote(self.opl_id)}"] + frontmatter += _fm_lines( + [ + ("parent_opa", self.parent_opa), + ("ops_uris", self.ops_uris), + ("target_uri", self.target_uri), + ("status", self.status), + ("evidence_uris", self.evidence_uris), + ("related_uris", self.related_uris), + ("source_type", self.source_type), + ("expert_id", self.expert_id), + ("expert_name", self.expert_name), + ("source_uri", self.source_uri), + ("target_concept", self.target_concept), + ("target_class_name", self.target_class_name), + ("target_object_name", self.target_object_name), + ("expected_sha256", self.expected_sha256), + ("candidate_content", self.candidate_content), + ("candidate_operations", self.candidate_operations), + ("apply_status", self.apply_status), + ("apply_error", self.apply_error), + ("applied_at", self.applied_at), + ("applied_entity_sha256", self.applied_entity_sha256), + ("archive_reason", self.archive_reason), + ], + ) + frontmatter.append("---") + return "\n".join( + [ + *frontmatter, + "", + f"# {self.title or self.opl_id}", + "", + "## 关联 OPA", + "", + f"- [OPA]({self.parent_opa})", + "", + "## 关联 OPS", + "", + ops, + "", + "## 初版知识提案", + "", + self.proposal or "待补充:描述拟写入的知识变化。", + "", + "## 形成依据", + "", + self.rationale or "待补充:说明证据如何支持该提案。", + "", + "## 证据引用", + "", + evidence, + "", + "## 关联资源", + "", + related, + "", + footer, + "", + ], + ) + + +# ── URI 工具 ──────────────────────────────────────────────────────────────── + + +def _wiki_resource_hash(*parts: str) -> str: + """生成确定性的 SHA256 哈希 ID(24 字符),用于 viking:// URI。.""" + raw = "\x1f".join(parts).encode("utf-8") + return hashlib.sha256(raw).hexdigest()[:24] + + +def concept_kb_uri(concept_name: str) -> str: + """生成概念的 viking:// URI。.""" + resource_id = _wiki_resource_hash("concept", concept_name) + return f"{wiki_resources_root()}/{resource_id}" + + +def entity_kb_uri(entity_type: str, entity_name: str, model_id: str = "") -> str: + """生成实体的 viking:// URI。.""" + resource_id = _wiki_resource_hash("entity", entity_type, entity_name, model_id) + return f"{wiki_resources_root()}/{resource_id}" + + +# ── 来源引用 ───────────────────────────────────────────────────────────────── + + +@dataclass +class SourceRef: + """来源引用,指向 library/ 中的原始章节。.""" + + library_path: str # e.g. "library/sy215/ch05/chapter.md" + section: str # e.g. "§5.2 液压泵结构与工作原理" + page_range: str = "" # e.g. "p112-114" + + def validate(self) -> list[str]: + errors: list[str] = [] + if not self.library_path: + errors.append("SourceRef.library_path 不能为空") + if not self.section: + errors.append("SourceRef.section 不能为空") + return errors + + +# ── 概念层模型 ─────────────────────────────────────────────────────────────── + + +@dataclass +class ConceptModel: + """概念层数据模型 — 对应 concepts/{category}/{组件名}.md 文件。.""" + + name: str # 组件通用名, e.g. "液压泵" + file_path: str # wiki 中的相对路径, e.g. "concepts/液压泵/液压泵.md" + kb_uri: str = "" # source URI (viking://, hash-based, no CJK) + overview: str = "" # 概述 + aliases: list[str] = field(default_factory=list) # 别称 + models: list[dict[str, Any]] = field(default_factory=list) # 型号明细 + properties: dict[str, Any] = field(default_factory=dict) # 结构化属性(schema 定义 + 自由扩展) + relations: list[dict[str, Any]] = field(default_factory=list) # schema-whitelisted typed edges + references: list[Any] = field( + default_factory=list + ) # 引用列表 (str | dict with title/file_path/kb_uri) + sources: list[SourceRef] = field(default_factory=list) # 来源追踪 + + def __post_init__(self) -> None: + if not self.kb_uri and self.name: + self.kb_uri = concept_kb_uri(self.name) + + def validate(self) -> list[str]: + """校验概念模型完整性。.""" + errors: list[str] = [] + if not self.name: + errors.append("ConceptModel.name 不能为空") + if not self.file_path: + errors.append(f"ConceptModel({self.name}).file_path 不能为空") + if not self.kb_uri: + errors.append(f"ConceptModel({self.name}).kb_uri 为空") + if not self.overview: + errors.append(f"ConceptModel({self.name}).overview 为空,建议补充详细描述") + for src in self.sources: + errors.extend(src.validate()) + return errors + + def to_markdown( + self, + *, + model_id: str | None = None, + referenced_by: list[str] | None = None, + ) -> str: + """生成概念页 Markdown,包含反向实体引用与来源证据。.""" + lines: list[str] = [f"# {self.name}", ""] + + if self.aliases: + lines += [f"**别称**: {', '.join(self.aliases)}", ""] + + if self.overview: + lines += ["## 用途说明", "", self.overview, ""] + + if self.properties: + lines += ["## 结构化属性", ""] + for key, value in self.properties.items(): + if isinstance(value, list): + lines.append(f"- **{key}**: {', '.join(str(v) for v in value)}") + else: + lines.append(f"- **{key}**: {value}") + lines.append("") + + if self.models: + lines += ["## 各机型细化参数记录", ""] + for model in self.models: + model_name = model.get("型号", "未知型号") + machine = model.get("配套机型", "") + heading = f"### {machine} ({model_name})" if machine else f"### {model_name}" + lines += [heading, ""] + for key, value in model.items(): + if key in {"型号", "配套机型"}: + continue + lines.append(f"- {key}: {value}") + lines.append("") + + if self.relations: + lines += ["## Schema 关系", ""] + for relation in self.relations: + relation_name = str(relation.get("relation") or "") + target_type = str(relation.get("target_type") or "") + target = str(relation.get("target") or "") + lines.append(f"- {relation_name} → {target}({target_type})") + lines.append("") + + if self.references: + lines += ["## 引用本概念的页面", ""] + for ref in self.references: + # ref 格式: {"title": "xxx", "file_path": "yyy", "kb_uri": "zzz"} + if isinstance(ref, dict): + title = ref.get("title", ref.get("file_path", "")) + uri = ref.get("kb_uri", "") + lines.append(f"- [{title}]({uri})" if uri else f"- {title}") + else: + lines.append(f"- {ref}") + lines.append("") + + if referenced_by: + lines += ["## 被引用列表【概念对应实体】", ""] + for entity_uri in referenced_by: + lines.append(f"- {entity_uri}") + lines.append("") + + if self.sources: + lines += ["---", "", "## Footnotes 与来源追踪 【概念找原文】", ""] + for i, src in enumerate(self.sources, 1): + page = f" {src.page_range}" if src.page_range else "" + lines.append(f"[^{i}]: {src.section}{page}。[查看原文]({src.library_path})") + lines.append("") + + return "\n".join(lines) + + +# ── 实体层模型 ─────────────────────────────────────────────────────────────── + + +@dataclass +class EntityModel: + """实体层数据模型 — 对应 private/{model}/{type}/{name}.md 文件。.""" + + entity_type: str # "system", "dtc", "symptom", "testaction", etc. + name: str # e.g. "hydraulic-system" + file_path: str # wiki 中的相对路径 + model_id: str = "" + kb_uri: str = "" # source URI (viking://, hash-based, no CJK) + title: str = "" + aliases: list[str] = field(default_factory=list) # 外文术语、厂家俗称、缩写 + summary: str = "" # 一句话语义摘要(用于 L0/L1 导航) + content: str = "" + concepts_used: list[str] = field(default_factory=list) # [[concepts/液压泵]] + relations: list[dict[str, Any]] = field(default_factory=list) # schema-whitelisted typed edges + properties: dict[str, Any] = field(default_factory=dict) # 结构化属性(schema 定义 + 自由扩展) + sources: list[SourceRef] = field(default_factory=list) + + def __post_init__(self) -> None: + if not self.kb_uri and self.name: + self.kb_uri = entity_kb_uri(self.entity_type, self.name, self.model_id) + + def validate(self) -> list[str]: + """校验实体模型完整性。.""" + errors: list[str] = [] + if not self.name: + errors.append("EntityModel.name 不能为空") + if not self.entity_type: + errors.append(f"EntityModel({self.name}).entity_type 不能为空") + if not self.file_path: + errors.append(f"EntityModel({self.name}).file_path 不能为空") + if not self.kb_uri: + errors.append(f"EntityModel({self.name}).kb_uri 为空") + if not self.content: + errors.append(f"EntityModel({self.name}).content 为空") + for src in self.sources: + errors.extend(src.validate()) + return errors + + def to_markdown( + self, + *, + model_id: str | None = None, + reverse_relations: dict[str, list[str]] | None = None, + template: str = "", + ) -> str: + """生成实体页 Markdown。. + + When a template string is provided (from the schema entity type definition), + the page is rendered following the template structure with header fields + (机型/摘要/引用概念/出处) and content sections. Otherwise, falls back to + the generic rendering with structured properties and concept references. + """ + mid = model_id or self.model_id + + if template: + return self._render_template( + template, model_id=mid, reverse_relations=reverse_relations + ) + + lines: list[str] = [] + + if self.title: + lines += [f"# {self.title}", ""] + + if self.aliases: + lines += [f"**别称**: {', '.join(self.aliases)}", ""] + + # ponytail: strip leading H1 from LLM content if it duplicates title + body = self._strip_trailing_concept_refs(self.content) + if self.title: + stripped = body.lstrip() + prefix = f"# {self.title}" + if stripped.startswith(prefix): + rest = stripped[len(prefix) :] + if not rest or rest[0] in ("\n", "\r"): + body = rest.lstrip("\n") + lines += [body, ""] + + if self.properties: + lines += ["## 结构化属性", ""] + for key, value in self.properties.items(): + if isinstance(value, list): + lines.append(f"- **{key}**: {', '.join(str(v) for v in value)}") + else: + lines.append(f"- **{key}**: {value}") + lines.append("") + + if self.concepts_used: + lines += ["## 被引用概念", ""] + for concept_ref in self.concepts_used: + name = self._extract_concept_name(concept_ref) + uri = concept_kb_uri(name) + if mid: + direct_uri = f"{wiki_resources_root()}/{quote(mid, safe='')}/concepts/{quote(name, safe='')}.md" + lines.append(f"- [[{uri}]] ({direct_uri})") + else: + lines.append(f"- [[{uri}]]") + lines.append("") + + if self.relations: + lines += ["## Schema 关系", ""] + for relation in self.relations: + relation_name = str(relation.get("relation") or "") + target_type = str(relation.get("target_type") or "") + target = str(relation.get("target") or "") + inverse = str(relation.get("inverse") or "") + label = f"{relation_name} → {target}" + if target_type: + label += f"({target_type})" + if inverse: + label += f";反向关系:{inverse}" + lines.append(f"- {label}") + lines.append("") + + if reverse_relations: + related = { + path + for paths in reverse_relations.values() + for path in paths + if path not in (self.file_path, self.kb_uri) + } + if related: + lines += ["## 相关实体", ""] + for rel_uri in sorted(related): + lines.append(f"- {rel_uri}") + lines.append("") + + if self.sources: + lines += ["---", "", "## Footnotes 与来源追踪", ""] + for i, src in enumerate(self.sources, 1): + page = f" {src.page_range}" if src.page_range else "" + lines.append(f"[^{i}]: {src.section}{page}。[查看原文]({src.library_path})") + lines.append("") + + return "\n".join(lines) + + def _render_template( + self, + template: str, + *, + model_id: str, + reverse_relations: dict[str, list[str]] | None, + ) -> str: + """Render entity page using a schema-defined Markdown template. + + The template uses ``{placeholder}`` syntax. Known placeholders: + ``{title}``, ``{model}``, ``{summary}``, ``{concepts_used}``, + ``{source}``, ``{content_*}`` (mapped from properties), ``{related_entities}``, + ``{related_system}``, ``{kb_uri}``, ``{code}``, ``{severity}``. + Unknown ``{content_*}`` placeholders default to the entity's full content. + """ + # Build concept reference string + concepts_str = "、".join(self.concepts_used) if self.concepts_used else "—" + + # Build source string + source_str = ( + ";".join( + f"{src.section}{' ' + src.page_range if src.page_range else ''}" + for src in self.sources + ) + if self.sources + else "—" + ) + + # Build related entities string from reverse relations + related_str = "" + if reverse_relations: + related = sorted({ + path + for paths in reverse_relations.values() + for path in paths + if path not in (self.file_path, self.kb_uri) + }) + related_str = "\n".join(f"- {uri}" for uri in related) if related else "" + if not related_str and self.relations: + related_str = ( + "\n".join( + f"- {rel.get('relation', '')} → {rel.get('target', '')}" + for rel in self.relations + if rel.get("relation") != "member_of_concept" + ) + or "" + ) + + # Build related system for DTC/Schematic + related_system = "" + for rel in self.relations: + if rel.get("relation") == "belongs_to_system": + related_system = f"[[private/{model_id}/system/{rel.get('target', '')}]]" + break + + # Map content_* placeholders from properties + render_vars: dict[str, str] = { + "title": self.title or self.name, + "model": model_id, + "summary": self.summary or "", + "concepts_used": concepts_str, + "source": source_str, + "related_entities": related_str or "—", + "related_system": related_system or "—", + "kb_uri": self.kb_uri, + "code": str(self.properties.get("code", "")), + "severity": str(self.properties.get("severity", "")), + } + + # Map content_* placeholders: try properties first, then fall back to content + # Extract all placeholder names from template + import re + + placeholders = re.findall(r"\{([a-z_]+)\}", template) + for ph in placeholders: + if ph.startswith("content_"): + prop_key = ph # e.g. content_function → look for content_function property + if prop_key in self.properties: + render_vars[ph] = str(self.properties[prop_key]) + elif ph == "content": + render_vars[ph] = self.content + else: + # Try to find matching content section in the entity's content + render_vars[ph] = self._extract_content_section(ph.removeprefix("content_")) + + # If no content_* placeholders matched, use full content for generic "content" + if "content" in placeholders and "content" not in render_vars: + render_vars["content"] = self.content + + # Safely format template, leaving unknown placeholders empty + result = self._safe_format(template, render_vars) + return result.rstrip() + "\n" + + @staticmethod + def _safe_format(template: str, variables: dict[str, str]) -> str: + """Format template with variables, replacing unknown placeholders with empty string.""" + import re + + def replacer(match: re.Match[str]) -> str: + key = match.group(1) + return str(variables.get(key, "")) + + return re.sub(r"\{([a-z_]+)\}", replacer, template) + + # Map template placeholder suffixes to Chinese heading keywords + _SECTION_KEYWORDS: ClassVar[dict[str, str]] = { + "function": "功能|概述|原理", + "overview": "功能|概述|原理", + "composition": "组成|结构", + "principle": "原理|工作", + "conditions": "条件|工况", + "description": "描述|说明", + "trigger": "触发|条件", + "reset": "清除|复位", + "troubleshoot": "排查|步骤|诊断", + "causes": "原因|可能", + "repair": "维修|建议", + "purpose": "目的|用途", + "prerequisites": "前置|条件|准备", + "tools": "工具", + "steps": "步骤|操作", + "criteria": "判定|标准", + "notes": "注意|事项", + "applicable": "适用|故障", + "parts": "零件|部件", + "connections": "连接|关系", + "params": "参数|规格", + "schedule": "周期|维护|计划", + "safety": "安全|注意", + "phenomenon": "现象|表现", + "flow": "流程", + } + + def _extract_content_section(self, section_name: str) -> str: + """Try to extract a named section from the entity's content. + + Looks for a markdown heading matching section_name (via keyword mapping) + and returns the content under it. Falls back to the full content. + """ + import re + + keywords = self._SECTION_KEYWORDS.get(section_name, section_name) + # Match: ## heading containing keywords, then capture until next ##+ heading or end + heading_pattern = re.compile( + rf"#+\s*[^\n]*(?:{keywords})[^\n]*\n((?:(?!#+\s)[^\n]*\n?)*)", + re.IGNORECASE, + ) + match = heading_pattern.search(self.content) + if match: + extracted = match.group(1).strip() + if extracted: + return extracted + return self.content.strip() + + @staticmethod + def _strip_trailing_concept_refs(content: str) -> str: + lines = content.rstrip().splitlines() + while lines: + stripped = lines[-1].strip() + if _CONCEPT_NAME_RE.fullmatch(stripped) or stripped in {"", "---"}: + lines.pop() + else: + break + return "\n".join(lines) + + @staticmethod + def _extract_concept_name(ref: str) -> str: + match = _CONCEPT_NAME_RE.search(ref) + return match.group(1) if match else ref.strip() + + +# ── 抽取结果(统一容器) ───────────────────────────────────────────────────── + + +@dataclass +class ExtractionResult: + """知识抽取结果 — 规则引擎与 LLM 抽取器共用。.""" + + concepts: dict[str, ConceptModel] = field(default_factory=dict) + entities: list[EntityModel] = field(default_factory=list) + relations: dict[str, list[str]] = field(default_factory=dict) + opas: list[OPAModel] = field(default_factory=list) + + def merge( + self, + other: ExtractionResult, + *, + relation_cardinalities: dict[tuple[str, str], str] | None = None, + ) -> None: + """将 other 的结果合并到 self(原地修改)。.""" + for name, concept in other.concepts.items(): + if name in self.concepts: + existing_concept = self.concepts[name] + existing_concept.sources.extend(concept.sources) + existing_concept.aliases = list( + dict.fromkeys([*existing_concept.aliases, *concept.aliases]) + ) + if concept.overview and concept.overview != existing_concept.overview: + opa_id = f"opa-concept-{_wiki_resource_hash(name, 'overview')}" + if not any(item.opa_id == opa_id for item in self.opas): + self.opas.append( + OPAModel( + opa_id=opa_id, + title=f"概念 {name} 描述冲突", + description="同一概念在不同章节中出现了不同的用途或定义描述", + category="conflict", + scope="concept", + target_uri=existing_concept.kb_uri, + target_path=existing_concept.file_path, + evidence_uris=list( + dict.fromkeys( + src.library_path + for src in [*existing_concept.sources, *concept.sources] + ), + ), + status="pending", + ), + ) + for key, value in concept.properties.items(): + if ( + key in existing_concept.properties + and existing_concept.properties[key] != value + ): + opa_id = f"opa-concept-property-{_wiki_resource_hash(name, key)}" + if not any(item.opa_id == opa_id for item in self.opas): + self.opas.append( + OPAModel( + opa_id=opa_id, + title=f"概念 {name} 属性冲突", + description=( + f"属性 `{key}` 在不同章节中出现不同取值:{existing_concept.properties[key]!s};{value!s}" + ), + category="conflict", + scope="concept", + target_uri=existing_concept.kb_uri, + target_path=existing_concept.file_path, + target_section=key, + evidence_uris=list( + dict.fromkeys( + src.library_path + for src in [*existing_concept.sources, *concept.sources] + ), + ), + status="pending", + ), + ) + existing_concept.properties[key] = value + existing_concept.relations = [ + *existing_concept.relations, + *[ + relation + for relation in concept.relations + if relation not in existing_concept.relations + ], + ] + for model in concept.models: + self._record_model_conflicts(existing_concept, model) + if model not in existing_concept.models: + existing_concept.models.append(model) + else: + self.concepts[name] = concept + + self._detect_entity_conflicts(other.entities) + existing_entities = { + (entity.entity_type, entity.name, entity.model_id): entity for entity in self.entities + } + for incoming in other.entities: + key = (incoming.entity_type, incoming.name, incoming.model_id) + existing = existing_entities.get(key) + if existing is None: + self.entities.append(incoming) + existing_entities[key] = incoming + continue + if incoming.content and incoming.content != existing.content: + existing.content = ( + f"{existing.content.rstrip()}\n\n---\n\n{incoming.content.lstrip()}" + ) + if incoming.title and not existing.title: + existing.title = incoming.title + if incoming.summary and not existing.summary: + existing.summary = incoming.summary + existing.concepts_used = list( + dict.fromkeys([*existing.concepts_used, *incoming.concepts_used]) + ) + existing.relations = [ + *existing.relations, + *[ + relation + for relation in incoming.relations + if relation not in existing.relations + ], + ] + existing.properties = {**existing.properties, **incoming.properties} + existing.sources.extend(incoming.sources) + self.opas.extend(other.opas) + + for concept_name, entity_paths in other.relations.items(): + bucket = self.relations.setdefault(concept_name, []) + for p in entity_paths: + if p not in bucket: + bucket.append(p) + + if relation_cardinalities: + self.enforce_relation_cardinality(relation_cardinalities) + + def enforce_relation_cardinality( + self, + relation_cardinalities: dict[tuple[str, str], str], + ) -> None: + """约束合并后的关系基数,并把违反 Schema 的边记录为 OPA。. + + 单章节抽取时 ``one`` 关系可能合法,但跨章节合并会产生多个目标。 + 这里保留稳定顺序中的第一个目标,同时把被裁剪的候选和证据写入 OPA, + 不静默丢失冲突信息。 + """ + for concept in self.concepts.values(): + self._enforce_owner_relations( + owner_type="concept", + owner_name=concept.name, + owner_uri=concept.kb_uri, + owner_path=concept.file_path, + relations=concept.relations, + sources=concept.sources, + relation_cardinalities=relation_cardinalities, + ) + for entity in self.entities: + self._enforce_owner_relations( + owner_type=entity.entity_type, + owner_name=entity.name, + owner_uri=entity.kb_uri, + owner_path=entity.file_path, + relations=entity.relations, + sources=entity.sources, + relation_cardinalities=relation_cardinalities, + concepts_used=entity.concepts_used, + ) + + def _enforce_owner_relations( + self, + *, + owner_type: str, + owner_name: str, + owner_uri: str, + owner_path: str, + relations: list[dict[str, Any]], + sources: list[SourceRef], + relation_cardinalities: dict[tuple[str, str], str], + concepts_used: list[str] | None = None, + ) -> None: + unique: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + by_relation: dict[str, list[dict[str, Any]]] = {} + for relation in relations: + name = str(relation.get("relation") or "") + target_type = str(relation.get("target_type") or "") + target = str(relation.get("target") or "") + key = (name, target_type, target) + if key in seen: + continue + seen.add(key) + by_relation.setdefault(name, []).append(relation) + + for relation_name, candidates in by_relation.items(): + cardinality = relation_cardinalities.get((owner_type, relation_name), "many") + if cardinality != "one" or len(candidates) <= 1: + unique.extend(candidates) + continue + kept = candidates[0] + unique.append(kept) + targets = [str(item.get("target") or "") for item in candidates] + opa_id = f"opa-relation-{_wiki_resource_hash(owner_uri, relation_name)}" + if not any(item.opa_id == opa_id for item in self.opas): + self.opas.append( + OPAModel( + opa_id=opa_id, + title=f"{owner_name} 的 {relation_name} 关系基数冲突", + description=( + f"Schema 要求关系 `{relation_name}` cardinality=one,但跨章节抽取到多个目标:{'、'.join(targets)};已保留首个目标,其余目标需要人工确认。" + ), + category="conflict", + scope="concept" if owner_type == "concept" else "entity", + target_uri=owner_uri, + target_path=owner_path, + target_section=relation_name, + evidence_uris=list(dict.fromkeys(src.library_path for src in sources)), + status="pending", + ), + ) + if relation_name == "member_of_concept" and concepts_used is not None: + kept_target = str(kept.get("target") or "") + filtered_refs: list[str] = [] + for ref in concepts_used: + match = _CONCEPT_NAME_RE.search(ref) + if match is None or match.group(1) == kept_target: + filtered_refs.append(ref) + concepts_used[:] = filtered_refs + relations[:] = unique + + def _detect_entity_conflicts(self, incoming: list[EntityModel]) -> None: + """Flag OPA when the same entity appears with different content across sources.""" + existing_map: dict[tuple[str, str, str], list[EntityModel]] = {} + for ent in self.entities: + existing_map.setdefault((ent.entity_type, ent.name, ent.model_id), []).append(ent) + + for new_ent in incoming: + key = (new_ent.entity_type, new_ent.name, new_ent.model_id) + siblings = existing_map.get(key) + if not siblings: + continue + new_norm = _normalize_content(new_ent.content) + for sib in siblings: + if _normalize_content(sib.content) == new_norm: + continue + opa_id = f"opa-ent-{_wiki_resource_hash(new_ent.entity_type, new_ent.name, new_ent.model_id)}" + if any(o.opa_id == opa_id for o in self.opas): + continue + evidence = list( + dict.fromkeys( + [ + sib.kb_uri, + new_ent.kb_uri, + *(src.library_path for src in sib.sources), + *(src.library_path for src in new_ent.sources), + ], + ), + ) + self.opas.append( + OPAModel( + opa_id=opa_id, + title=f"{new_ent.entity_type}/{new_ent.name} 内容冲突", + description=f"实体 {new_ent.name} 在不同章节中提取到不同内容", + category="conflict", + scope="entity", + target_uri=new_ent.kb_uri, + evidence_uris=evidence, + status="pending", + ), + ) + break + + def detect_cross_concept_numerical_conflicts(self) -> None: + """Flag OPA when different concepts report contradictory numerical values for the same parameter.""" + # ponytail: simple heuristic — group by key name, compare numeric values with ±10% tolerance + param_map: dict[str, list[tuple[str, float, str]]] = {} + for concept in self.concepts.values(): + for model in concept.models: + for key, value in model.items(): + if key in ("型号", "配套机型"): + continue + num = _parse_number(value) + if num is None: + continue + param_map.setdefault(key, []).append((concept.name, num, concept.kb_uri)) + + for key, entries in param_map.items(): + if len(entries) < 2: + continue + for i in range(len(entries)): + for j in range(i + 1, len(entries)): + name1, val1, uri1 = entries[i] + name2, val2, uri2 = entries[j] + if name1 == name2: + continue + if val1 == 0 or val2 == 0: + if val1 != val2: + continue + elif abs(val1 - val2) / max(abs(val1), abs(val2)) <= 0.1: + continue + opa_id = f"opa-num-{_wiki_resource_hash(key)}" + if any(o.opa_id == opa_id for o in self.opas): + continue + self.opas.append( + OPAModel( + opa_id=opa_id, + title=f"参数 {key} 数值冲突", + description=f"概念 {name1} 记录 {key}={val1}, 概念 {name2} 记录 {key}={val2}", + category="numerical", + scope="concept", + evidence_uris=[uri1, uri2], + status="pending", + ), + ) + break + else: + continue + break + + def _record_model_conflicts(self, concept: ConceptModel, incoming: dict[str, Any]) -> None: + """Persist contradictory claims for the same machine/component variant.""" + identity_keys = ("配套机型", "型号") + for existing in concept.models: + if any( + existing.get(key) and incoming.get(key) and existing.get(key) != incoming.get(key) + for key in identity_keys + ): + continue + for key in sorted((existing.keys() & incoming.keys()) - set(identity_keys)): + old_value = existing.get(key) + new_value = incoming.get(key) + if old_value in (None, "") or new_value in (None, "") or old_value == new_value: + continue + title = f"{concept.name}{key}取值冲突" + if any( + opa.title == title and opa.description.endswith(f"{new_value!s}") + for opa in self.opas + ): + continue + self.opas.append( + OPAModel( + opa_id="", + title=title, + description=f"同一概念变体的 `{key}` 存在不同取值:{old_value!s};{new_value!s}", + category="conflict", + scope="concept", + target_uri=concept.kb_uri, + target_path=concept.file_path, + target_section="各机型细化参数记录", + evidence_uris=list( + dict.fromkeys(src.library_path for src in concept.sources) + ), + ), + ) + + def validate(self) -> list[str]: + """校验抽取结果中所有模型的完整性。.""" + errors: list[str] = [] + for concept in self.concepts.values(): + errors.extend(concept.validate()) + for entity in self.entities: + errors.extend(entity.validate()) + return errors + + +# ── 构建结果 ───────────────────────────────────────────────────────────────── + + +@dataclass +class WikiBuildResult: + """构建结果。.""" + + model_id: str + concepts: list[ConceptModel] = field(default_factory=list) + entities: list[EntityModel] = field(default_factory=list) + relations: dict[str, list[str]] = field(default_factory=dict) + opas: list[OPAModel] = field(default_factory=list) + stats: dict[str, Any] = field(default_factory=dict) + + @property + def concept_count(self) -> int: + return len(self.concepts) + + @property + def entity_count(self) -> int: + return len(self.entities) + + @property + def opa_count(self) -> int: + return len(self.opas) + + def validate(self) -> list[str]: + """校验构建结果。.""" + errors: list[str] = [] + if not self.model_id: + errors.append("WikiBuildResult.model_id 不能为空") + for concept in self.concepts: + errors.extend(concept.validate()) + for entity in self.entities: + errors.extend(entity.validate()) + return errors + + +# ── Resource Manifest 生成 ─────────────────────────────────────────────────── + + +def opa_file_path(opa: OPAModel, model_id: str) -> str: + """Compute the relative wiki path for an OPA file.""" + target_kind = "concepts" if opa.scope == "concept" else f"entities/{model_id}" + return f"opa/{target_kind}/{opa.opa_id}.md" + + +def build_wiki_resource_manifest( + *, + model_id: str, + concepts: list[ConceptModel], + entities: list[EntityModel], + opas: list[OPAModel] | None = None, +) -> dict[str, Any]: + """构建 wiki 的 resource.json。. + + 每个 concept/entity/opa 对应一个 resource 条目: + - resource_id: viking:// URI 中的 hash ID + - kind: "concept" / "entity_{entity_type}" / "opa" + - uri: viking:// URI + - path: 相对文件路径 + - mime_type: "text/markdown" + - title: 页面标题 + + 下游使用方通过 resource.json 快速查 URI 表获得文件路径。 + """ + resources: list[dict[str, Any]] = [] + + for concept in concepts: + resource_id = concept.kb_uri.split("/")[-1] if concept.kb_uri else "" + resources.append( + { + "resource_id": resource_id, + "kind": "concept", + "uri": concept.kb_uri, + "path": concept.file_path, + "mime_type": "text/markdown", + "title": concept.name, + "name": concept.name, + "aliases": concept.aliases, + "properties": concept.properties, + }, + ) + + for entity in entities: + resource_id = entity.kb_uri.split("/")[-1] if entity.kb_uri else "" + resources.append( + { + "resource_id": resource_id, + "kind": f"entity_{entity.entity_type}", + "uri": entity.kb_uri, + "path": entity.file_path, + "mime_type": "text/markdown", + "title": entity.title or entity.name, + "name": entity.name, + "aliases": entity.aliases, + "entity_type": entity.entity_type, + "properties": entity.properties, + }, + ) + + if opas: + resources.extend( + { + "resource_id": opa.opa_id, + "kind": "opa", + "uri": f"{wiki_resources_root()}/OP/{opa.opa_id}", + "path": opa_file_path(opa, model_id), + "mime_type": "text/markdown", + "title": opa.title, + "target_uri": opa.target_uri, + "status": opa.status, + } + for opa in opas + ) + + return { + "version": 1, + "model_id": model_id, + "uri": f"{wiki_resources_root()}/{model_id}/resource.json", + "resources": resources, + } + + +def build_wiki_resource_index(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]: + """从 resource manifest 构建 resource_id → entry 索引。.""" + resources = manifest.get("resources") + if not isinstance(resources, list): + return {} + index: dict[str, dict[str, Any]] = {} + for entry in resources: + if not isinstance(entry, dict): + continue + resource_id = entry.get("resource_id") + if isinstance(resource_id, str) and resource_id: + index[resource_id] = entry + return index diff --git a/src/wolfharness/capabilities/wiki/namespaces.py b/src/wolfharness/capabilities/wiki/namespaces.py new file mode 100644 index 000000000..b90fea423 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/namespaces.py @@ -0,0 +1,76 @@ +"""Canonical OpenViking resource namespace resolution. + +Single source of truth for the ``viking://resources/<namespace>`` roots used +across the harness and the wiki build store. Historically each module +hard-coded a deployment-specific namespace. Runtime namespaces are now +required environment inputs, so changing a library or target never requires +a code edit and a missing deployment value fails before any write begins. + +The runtime wiki store consumes these through the same environment variables, +so the resolvers here are consistent with how ``create_wiki_store`` picks its root: +``VIKING_NAMESPACE`` -> ``viking://resources/<ns>``. + +Environment variables: + +- ``VIKING_NAMESPACE``: wiki build target resource namespace. +- ``VIKING_RAW_NAMESPACE``: raw manual library resource namespace. +""" + +from __future__ import annotations + +import os + + +_RESOURCES_PREFIX = "viking://resources/" + + +class NamespaceConfigurationError(RuntimeError): + """Raised when a required Viking namespace was not configured.""" + + +def _required_namespace(env_name: str) -> str: + value = os.environ.get(env_name, "").strip().strip("/") + if not value: + raise NamespaceConfigurationError( + f"{env_name} is required when WIKI_STORAGE_BACKEND=viking", + ) + return value + + +def wiki_namespace() -> str: + """Return the wiki build target resource namespace. + + Resolved from the required ``VIKING_NAMESPACE`` deployment setting. + """ + return _required_namespace("VIKING_NAMESPACE") + + +def raw_namespace() -> str: + """Return the raw manual library resource namespace. + + Resolved from the required ``VIKING_RAW_NAMESPACE`` deployment setting. + """ + return _required_namespace("VIKING_RAW_NAMESPACE") + + +def resources_root(namespace: str) -> str: + """Return the ``viking://resources/<namespace>`` root for a namespace.""" + normalized = namespace.strip().strip("/") + if not normalized: + raise NamespaceConfigurationError("Viking resource namespace must not be empty") + return f"{_RESOURCES_PREFIX}{normalized}" + + +def wiki_resources_root() -> str: + """Return the wiki build target root URI (``viking://resources/<ns>``). + + Mirrors ``WikiStore.root_uri`` for the configured namespace, so module + defaults stay consistent with the runtime store root even before a + store is created. + """ + return resources_root(wiki_namespace()) + + +def raw_resources_root() -> str: + """Return the raw manual library root URI (``viking://resources/<ns>``).""" + return resources_root(raw_namespace()) diff --git a/src/wolfharness/capabilities/wiki/planning/__init__.py b/src/wolfharness/capabilities/wiki/planning/__init__.py new file mode 100644 index 000000000..f17700093 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/planning/__init__.py @@ -0,0 +1 @@ +"""wiki capability subpackage: planning.""" diff --git a/src/wolfharness/capabilities/wiki/planning/bom.py b/src/wolfharness/capabilities/wiki/planning/bom.py new file mode 100644 index 000000000..3ec15f08b --- /dev/null +++ b/src/wolfharness/capabilities/wiki/planning/bom.py @@ -0,0 +1,439 @@ +"""BOM taxonomy and component registration.""" + +from __future__ import annotations + +from hashlib import sha256 +import json +import logging +import re + +from httpx import HTTPError +from openviking_sdk.errors import OpenVikingError + +from wolfharness.capabilities.wiki.section_constants import ( + SECTION_MECHANISM, + SECTION_OVERVIEW, + SECTION_SOURCE, +) + + +logger = logging.getLogger(__name__) + +from wolfharness.capabilities.wiki._helpers import _BOM_PATH_PLACEHOLDER_RE, _entity_batch_limit + + +class BomMixin: + """BOM taxonomy and component registration.""" + + def _bom_taxonomy_key(self) -> str: + """Return the persistent global BOM taxonomy registry key.""" + return "index/bom_taxonomy.json" + + def get_bom_taxonomy(self) -> dict[str, dict[str, object]]: + """Read the global BOM Component registry without changing it.""" + raw = self.store.read_text(self._bom_taxonomy_key()) + if raw is None: + return {} + try: + value = json.loads(raw) + except (UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f"Invalid BOM taxonomy registry: {self._bom_taxonomy_key()}") from exc + if not isinstance(value, dict) or not all(isinstance(key, str) for key in value): + raise TypeError(f"Invalid BOM taxonomy registry: {self._bom_taxonomy_key()}") + return {str(uri): record for uri, record in value.items() if isinstance(record, dict)} + + def register_bom_component( + self, + component_uri: str, + bom_path: str, + *, + parent_uri: str = "", + evidence_uris: list[str] | None = None, + applicable_models: list[str] | None = None, + ) -> dict[str, object]: + """Register one global BOM Component identity and its evidence-backed path.""" + identity = self.store.lookup_by_uri(component_uri) + if identity is None or identity[0] != "Component": + raise ValueError(f"Unknown Component URI: {component_uri}") + normalized_path = self.store.normalize_class_name("Component", bom_path.strip()) or "" + if not normalized_path: + raise ValueError("bom_path must not be empty") + if normalized_path.startswith(("关重件/", "普通件/")): + raise ValueError("bom_path must be a logical BOM path, not a physical tier") + evidence = list(dict.fromkeys(uri.strip() for uri in (evidence_uris or []) if uri.strip())) + raw_prefix = self._raw_fs.root_uri + "/" + bom_prefix = (self._bom_fs.root_uri + "/") if self._bom_fs is not None else "" + if not evidence or any( + not (uri.startswith(raw_prefix) or bool(bom_prefix and uri.startswith(bom_prefix))) + for uri in evidence + ): + raise ValueError( + f"BOM registration requires evidence rooted at the raw ({raw_prefix}) or global BOM ({bom_prefix or '<!-- unset -->'}) namespace", + ) + unresolved_evidence = [uri for uri in evidence if self.read_resource(uri) is None] + if unresolved_evidence: + raise ValueError(f"BOM evidence cannot be resolved: {unresolved_evidence[:5]}") + if parent_uri: + parent_identity = self.store.lookup_by_uri(parent_uri) + if parent_identity is None or parent_identity[0] != "Component": + raise ValueError(f"Unknown BOM parent Component URI: {parent_uri}") + if parent_uri == component_uri: + raise ValueError("A Component cannot be its own BOM parent") + registry = self.get_bom_taxonomy() + existing = registry.get(component_uri) + if existing is not None and str(existing.get("bom_path", "")) != normalized_path: + raise ValueError( + f"BOM path conflict for {component_uri}: {existing.get('bom_path', '')!r} vs {normalized_path!r}; create an OPA first", + ) + record: dict[str, object] = { + "component_uri": component_uri, + "bom_path": normalized_path, + "parent_uri": parent_uri, + "evidence_uris": evidence, + "applicable_models": list(dict.fromkeys(applicable_models or [])), + } + registry[component_uri] = record + self.store.write_text( + self._bom_taxonomy_key(), + json.dumps(dict(sorted(registry.items())), ensure_ascii=False, indent=2) + "\n", + ) + return record + + def register_bom_identity_batch( + self, + doc_id: str, + packet_id: str, + bom_source_uri: str, + target_model: str, + components: list[dict[str, object]], + *, + device_object_name: str = "", + device_description: str = "", + system_chapter_uris: list[str] | None = None, + ) -> dict[str, object]: + """Persist a complete BOM identity plan in one idempotent operation. + + A BOM is control metadata, not a chapter-extraction workload. This + operation deliberately keeps BOM handling on the service side: it + records one immutable packet, materializes deterministic Component and + Device skeletons in bounded storage batches, and commits the taxonomy + registry once. It must not be decomposed into distill or worker tasks. + + ``components`` is the already-resolved identity list from the exact + BOM leaf: each item requires ``class_name`` and ``object_name`` and may + provide ``bom_path``, ``parent_uri``, ``evidence_uris`` and + ``applicable_models``. Semantic mechanism enrichment is a separate + guarded worker patch after this identity commit. + """ + if not doc_id.strip() or not packet_id.strip() or not target_model.strip(): + raise ValueError("doc_id, packet_id, and target_model must not be empty") + if not re.fullmatch(r"[A-Za-z0-9_]+", packet_id): + raise ValueError("packet_id must contain only ASCII letters, digits, and underscores") + bom_uri = bom_source_uri.strip() + if not bom_uri or self.read_resource(bom_uri) is None: + raise ValueError(f"BOM source is unreadable: {bom_uri!r}") + if self._bom_fs is not None and not ( + bom_uri == self._bom_fs.root_uri + or bom_uri.startswith(self._bom_fs.root_uri.rstrip("/") + "/") + ): + raise ValueError("bom_source_uri must belong to the configured BOM namespace") + if len(components) > 1000: + raise ValueError("BOM identity batch accepts at most 1000 components") + + normalized_components: list[dict[str, object]] = [] + uri_by_identity: dict[tuple[str, str], str] = {} + for index, item in enumerate(components): + if not isinstance(item, dict): + raise TypeError(f"components[{index}] must be an object") + class_name = item.get("class_name") + object_name = item.get("object_name") + if not isinstance(class_name, str) or not class_name.strip(): + raise ValueError(f"components[{index}].class_name must not be empty") + if not isinstance(object_name, str) or not object_name.strip(): + raise ValueError(f"components[{index}].object_name must not be empty") + normalized_class = self.store.normalize_class_name("Component", class_name.strip()) + normalized_object = object_name.strip() + if _BOM_PATH_PLACEHOLDER_RE.search(normalized_class): + raise ValueError( + f"components[{index}].class_name contains a placeholder or ellipsis", + ) + identity_key = (normalized_class, normalized_object) + if identity_key in uri_by_identity: + raise ValueError(f"Duplicate BOM Component identity: {identity_key}") + component_uri = self.store.entity_uri("Component", normalized_class, normalized_object) + uri_by_identity[identity_key] = component_uri + raw_evidence = item.get("evidence_uris", [bom_uri]) + if not isinstance(raw_evidence, list) or not all( + isinstance(value, str) for value in raw_evidence + ): + raise TypeError(f"components[{index}].evidence_uris must be a string list") + evidence = list( + dict.fromkeys([ + bom_uri, + *(value.strip() for value in raw_evidence if value.strip()), + ]) + ) + raw_models = item.get("applicable_models", [target_model]) + if not isinstance(raw_models, list) or not all( + isinstance(value, str) for value in raw_models + ): + raise TypeError(f"components[{index}].applicable_models must be a string list") + models = list( + dict.fromkeys([value.strip() for value in raw_models if value.strip()]) + ) or [target_model.strip()] + bom_path = item.get("bom_path", normalized_class) + if not isinstance(bom_path, str) or not bom_path.strip(): + raise ValueError(f"components[{index}].bom_path must not be empty") + normalized_path = self.store.normalize_class_name("Component", bom_path.strip()) + if _BOM_PATH_PLACEHOLDER_RE.search(normalized_path): + raise ValueError( + f"components[{index}].bom_path contains a placeholder or ellipsis", + ) + if normalized_path != normalized_class: + raise ValueError( + f"components[{index}].class_name must equal its logical bom_path", + ) + parent_uri = item.get("parent_uri", "") + if not isinstance(parent_uri, str): + raise TypeError(f"components[{index}].parent_uri must be a string") + normalized_components.append( + { + "component_uri": component_uri, + "class_name": normalized_class, + "object_name": normalized_object, + "bom_path": normalized_path, + "parent_uri": parent_uri.strip(), + "evidence_uris": evidence, + "applicable_models": models, + }, + ) + + component_errors: list[dict[str, str]] = [] + eligible_components: list[dict[str, object]] = [] + batch_component_uris = {str(value["component_uri"]) for value in normalized_components} + # Resolve optional parent identities without another model/tool round + # trip. A parent may be an exact URI or an identity in this batch. + for item in normalized_components: + parent_uri = str(item["parent_uri"]) + if ( + parent_uri + and parent_uri not in batch_component_uris + and self.store.lookup_by_uri(parent_uri) is None + ): + component_errors.append({ + "component_uri": str(item["component_uri"]), + "error": f"Unknown BOM parent Component URI: {parent_uri}", + }) + continue + if parent_uri == item["component_uri"]: + component_errors.append({ + "component_uri": str(item["component_uri"]), + "error": "A Component cannot be its own BOM parent", + }) + continue + eligible_components.append(item) + + registry = self.get_bom_taxonomy() + validated_components: list[dict[str, object]] = [] + eligible_uris = {str(value["component_uri"]) for value in eligible_components} + readable_evidence: dict[str, bool] = {bom_uri: True} + for item in eligible_components: + component_uri = str(item["component_uri"]) + parent_uri = str(item["parent_uri"]) + if ( + parent_uri + and parent_uri in batch_component_uris + and parent_uri not in eligible_uris + ): + component_errors.append({ + "component_uri": component_uri, + "error": f"BOM parent is invalid in this batch: {parent_uri}", + }) + continue + evidence = list(item["evidence_uris"]) + if not evidence: + component_errors.append({ + "component_uri": component_uri, + "error": "BOM evidence is empty", + }) + continue + evidence_error = "" + for evidence_uri in evidence: + evidence_readable = readable_evidence.get(evidence_uri) + if evidence_readable is None: + evidence_readable = self.read_resource(evidence_uri) is not None + readable_evidence[evidence_uri] = evidence_readable + if not evidence_readable: + evidence_error = f"BOM evidence cannot be resolved: {evidence_uri}" + break + if evidence_error: + component_errors.append({"component_uri": component_uri, "error": evidence_error}) + continue + existing = registry.get(component_uri) + if existing is not None and str(existing.get("bom_path", "")) != str(item["bom_path"]): + component_errors.append( + { + "component_uri": component_uri, + "error": f"BOM path conflict: {existing.get('bom_path', '')!r} vs {item['bom_path']!r}; create an OPA first", + }, + ) + continue + validated_components.append(item) + + # Persist the source-of-truth packet before any entity mutation. A + # changed packet_id/source hash is rejected by record_source_packet, + # making retries safe and preventing duplicate identity plans. All + # evidence and registry conflicts are validated above so a rejected + # batch cannot leave a misleading packet behind. + packet = self.record_source_packet( + packet_id=packet_id, + doc_id=doc_id, + source_uris=[bom_uri], + packet_body={ + "kind": "bom_identity_plan", + "target_model": target_model.strip(), + "resolved_components": normalized_components, + "resolved_system_chapter_uris": list(dict.fromkeys(system_chapter_uris or [])), + }, + allow_same_snapshot_replace=True, + ) + + entity_items: list[dict[str, object]] = [] + for item in validated_components: + component_uri = str(item["component_uri"]) + if self.store.read_entity_by_uri(component_uri) is not None: + continue + object_name = str(item["object_name"]) + class_name = str(item["class_name"]) + bom_path = str(item["bom_path"]) + evidence = [str(value) for value in item["evidence_uris"]] + models = [str(value) for value in item["applicable_models"]] + parent_uri = str(item["parent_uri"]) + parent_field = f"bom_parent_uri: {parent_uri}\n" if parent_uri else "" + evidence_yaml = "\n".join(f" - {value}" for value in evidence) + models_yaml = "\n".join(f" - {value}" for value in models) + entity_items.append( + { + "concept": "Component", + "class_name": class_name, + "object_name": object_name, + "content": ( + "---\n" + f"id: BOMCOMP-{sha256(component_uri.encode('utf-8')).hexdigest()[:16]}\n" + f"title: {object_name}\n" + f"description: {target_model.strip()} BOM 中确认的 {bom_path} 总成身份。\n" + f"class_name: {class_name}\n" + f"object_name: {object_name}\n" + f"bom_path: {bom_path}\n" + f"{parent_field}" + "bom_evidence:\n" + f"{evidence_yaml}\n" + "status: draft\n" + "applicable_models:\n" + f"{models_yaml}\n" + "---\n" + f"# {object_name}\n\n" + f"## {SECTION_OVERVIEW}\n" + f"BOM 已确认该总成属于 {bom_path},适用机型:{'、'.join(models)}。\n\n" + f"## {SECTION_MECHANISM}\n" + "BOM 身份已登记;工作机理由 bom_enrich worker 在身份入库后基于总成类型和装配路径推理补充,当前阶段不写入未经确认的参数。\n\n" + f"## {SECTION_SOURCE}\n" + f"{evidence_yaml.replace(' - ', '- ')}\n" + ), + }, + ) + + device_name = device_object_name.strip() or target_model.strip() + device_uri = self.store.entity_uri("Device", None, device_name) + if self.store.read_entity_by_uri(device_uri) is None: + component_uris = [str(item["component_uri"]) for item in validated_components] + component_yaml = "\n".join(f" - {uri}" for uri in component_uris) + chapter_uris = list( + dict.fromkeys(uri.strip() for uri in (system_chapter_uris or []) if uri.strip()) + ) + chapter_yaml = "\n".join(f" - {uri}" for uri in chapter_uris) + entity_items.append( + { + "concept": "Device", + "class_name": "", + "object_name": device_name, + "content": ( + "---\n" + f"id: DEVICE-{sha256(device_uri.encode('utf-8')).hexdigest()[:16]}\n" + f"title: {device_name}\n" + f"description: {device_description.strip() or f'{target_model.strip()} 设备型号基线。'}\n" + "status: draft\n" + "critical_components:\n" + f"{component_yaml}\n" + "sources:\n" + f" - {bom_uri}\n" + + (f"system_chapters:\n{chapter_yaml}\n" if chapter_yaml else "") + + "applicable_models:\n" + f" - {target_model.strip()}\n" + "---\n" + f"# {device_name}\n\n" + "## 基础信息\n" + f"{device_description.strip() or f'{target_model.strip()} 设备型号基线。'}\n\n" + "## 包含系统\n" + "BOM 组件树已登记,系统章节与诊断实体在后续章节抽取阶段补充。\n\n" + f"## {SECTION_SOURCE}\n" + f"- {bom_uri}\n" + ), + }, + ) + + written = 0 + for start in range(0, len(entity_items), _entity_batch_limit()): + chunk = entity_items[start : start + _entity_batch_limit()] + try: + result = self.write_entities_batch(chunk) + except (OSError, ValueError, TypeError, HTTPError, OpenVikingError) as exc: + # Preserve per-component isolation without falling back to + # worker fan-out: retry only the failed storage chunk as + # single-item service operations and report the exact URI. + for item in chunk: + try: + result = self.write_entities_batch([item]) + except (OSError, ValueError, TypeError, HTTPError, OpenVikingError) as item_exc: + component_errors.append( + { + "component_uri": self.store.entity_uri( + str(item["concept"]), + str(item.get("class_name", "")) or None, + str(item["object_name"]), + ), + "error": f"batch materialization failed after {type(exc).__name__}: {item_exc}", + }, + ) + else: + written += int(result.get("written_count", 0)) + else: + written += int(result.get("written_count", 0)) + + for item in validated_components: + component_uri = str(item["component_uri"]) + registry[component_uri] = { + "component_uri": component_uri, + "bom_path": str(item["bom_path"]), + "parent_uri": str(item["parent_uri"]), + "evidence_uris": list(item["evidence_uris"]), + "applicable_models": list(item["applicable_models"]), + } + self.store.write_text( + self._bom_taxonomy_key(), + json.dumps(dict(sorted(registry.items())), ensure_ascii=False, indent=2) + "\n", + ) + enrichment = self.bom_enrichment_status(packet_id) + return { + "status": "bom_registered", + "event": "bom_registered", + "packet_uri": packet["packet_uri"], + "device_uri": device_uri, + "component_uris": [str(item["component_uri"]) for item in validated_components], + "component_count": len(validated_components), + "written_count": written, + "registry_count": len(registry), + "component_errors": component_errors, + "enrichment_status": enrichment.get("status", "pending"), + "enrichment_pending_count": len(enrichment.get("pending_uris", [])), + } diff --git a/src/wolfharness/capabilities/wiki/planning/chapters.py b/src/wolfharness/capabilities/wiki/planning/chapters.py new file mode 100644 index 000000000..56506e372 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/planning/chapters.py @@ -0,0 +1,1062 @@ +"""Chapter reading, browsing, planning, and BOM enrichment.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from hashlib import sha256 +import json +import logging +from pathlib import Path +import time + +from openviking_sdk.errors import OpenVikingError + +from wolfharness.capabilities.wiki.io.chapter_scoring import ( + build_fingerprint, + score_chapter_record, + should_auto_register_no_entity, + should_auto_register_no_entity_from_toc, +) +from wolfharness.capabilities.wiki.io.text_compact import compact_chapter +from wolfharness.capabilities.wiki.io.text_parsers import ( + _CHAPTER_PREFIX_RE, + _dir_to_clean_title, +) +from wolfharness.capabilities.wiki.quality import ( + register_raw_chapter_uris, +) +from wolfharness.capabilities.wiki.section_constants import ( + SECTION_MECHANISM, +) + + +logger = logging.getLogger(__name__) + +from typing import TYPE_CHECKING + +from wolfharness.capabilities.wiki._helpers import ( + _BOM_ENRICH_PLACEHOLDER_MARKERS, + _chapter_idempotency_key, + _entity_batch_limit, + _io_worker_limit, +) + + +if TYPE_CHECKING: + from collections.abc import Mapping + + from wolfharness.capabilities.wiki.storage import ( + FSBackend, + ) + + +class ChapterMixin: + """Chapter reading, browsing, planning, and BOM enrichment.""" + + def load_manifest(self) -> list[dict]: + """List parsed documents available on the raw backend. + + Prefers the local build manifests (``documents.json`` / + ``run_info.json``) when present; falls back to discovering + documents from the raw backend keys. + """ + docs: list[dict] = [] + if self._library_root is not None and self._library_root.is_dir(): + path = self._library_root / "documents.json" + if path.is_file(): + data = json.loads(path.read_text(encoding="utf-8")) + manifest = data.get("documents", data) if isinstance(data, dict) else data + if manifest: + return manifest + logger.warning( + "Document manifest %s contains no records; auto-discovering manuals instead", + path, + ) + for child in sorted(self._library_root.iterdir()): + if not child.is_dir(): + continue + run_info = child / "run_info.json" + if not run_info.is_file(): + continue + info = json.loads(run_info.read_text(encoding="utf-8")) + pdf_path = info.get("pdf_path", "") + title = Path(pdf_path).stem if pdf_path else child.name + model = self._extract_model_from_doc(child, pdf_path) + docs.append({"output_id": child.name, "title": title, "model": model}) + if docs: + logger.info("Auto-discovered %d docs from %s", len(docs), self._library_root_uri) + return docs + + # Fallback: discover documents from the raw backend keys. + for doc_id in self._library_doc_ids(): + model = self._extract_model_from_doc(Path(doc_id), "") + docs.append({"output_id": doc_id, "title": doc_id, "model": model}) + logger.info("Auto-discovered %d docs from raw backend", len(docs)) + return docs + + def list_chapters(self, doc_id: str, *, refresh: bool = False) -> list[dict]: + """List all leaf chapter directories for a parsed document. + + Reads the chapter tree from the raw backend (``{doc}/chapters``). + Each entry has ``rootsection``, ``section``, ``subdir``, ``title`` + and ``md_path``. ``subdir`` is the real path under ``chapters/`` + and is used directly in raw URIs returned by ``make_source_uri``. + """ + if refresh: + self._chapters_cache.pop(doc_id, None) + self._raw_doc_prefixes.pop(doc_id, None) + cached = self._chapters_cache.get(doc_id) + if cached is not None and not refresh: + return [dict(chapter) for chapter in cached] + if doc_id not in self._raw_doc_prefixes and (self._doc_prefix_index is None or refresh): + # If the full index is already built (batch path via + # _library_doc_ids), a miss here means the doc genuinely isn't + # in the namespace — skip the targeted search. Otherwise do a + # cheap iterative-deepening lookup instead of a full walk. + resolved = self._resolve_doc_prefix(doc_id) + if resolved: + self._raw_doc_prefixes[doc_id] = resolved + if doc_id in self._raw_doc_prefixes: + document_prefix = self._raw_doc_prefixes[doc_id] or "" + else: + document_prefix = doc_id + keys = self._raw_fs.list_dir(document_prefix, recursive=True) + if not keys and document_prefix == doc_id: + document_prefix, keys = self._discover_nonstandard_doc_files(doc_id) + if document_prefix: + self._raw_doc_prefixes[doc_id] = document_prefix + legacy_prefix = f"{document_prefix}/chapters" if document_prefix else "chapters" + dirs: set[str] = set() + for key in keys: + if key.startswith(legacy_prefix + "/") and key.endswith("chapter.md"): + dirs.add(key[: -len("chapter.md")].rstrip("/")) + out: list[dict[str, str]] = [] + if dirs: + leaf_dirs = sorted( + d + for d in dirs + if not any(d != other and other.startswith(d + "/") for other in dirs) + ) + for directory in leaf_dirs: + rel = directory.removeprefix(legacy_prefix + "/").rstrip("/") + if not rel: + continue + parts = [part for part in rel.split("/") if part] + out.append( + { + "rootsection": _CHAPTER_PREFIX_RE.sub("", parts[0]), + "section": _CHAPTER_PREFIX_RE.sub("", parts[-1]), + "subdir": rel, + "title": _dir_to_clean_title(parts[-1]), + "md_path": f"{legacy_prefix}/{rel}/chapter.md", + }, + ) + else: + ignored_names = {"full.md", "toc.md", "readme.md"} + doc_prefix = f"{document_prefix}/" if document_prefix else "" + for key in sorted(keys): + if not key.startswith(doc_prefix) or not key.endswith(".md"): + continue + if key.rsplit("/", 1)[-1].lower() in ignored_names: + continue + rel = key.removeprefix(doc_prefix) + parts = [part for part in rel.split("/") if part] + if parts and parts[0].casefold() == doc_id.casefold(): + logical_parts = parts[1:] + else: + logical_parts = parts + if not logical_parts: + continue + section_name = Path(logical_parts[-1]).stem + root_name = logical_parts[0] if len(logical_parts) > 1 else section_name + out.append( + { + "rootsection": _CHAPTER_PREFIX_RE.sub("", root_name), + "section": _CHAPTER_PREFIX_RE.sub("", section_name), + "subdir": rel, + "title": _dir_to_clean_title(section_name), + "md_path": key, + }, + ) + for chapter in out: + subdir = str(chapter["subdir"]) + self._chapter_path_aliases[(doc_id, subdir)] = str(chapter["md_path"]) + register_raw_chapter_uris( + [f"{self._raw_fs.root_uri}/{chapter['md_path']}" for chapter in out], + ) + self._chapters_cache[doc_id] = [dict(chapter) for chapter in out] + return [dict(chapter) for chapter in out] + + # ── Filesystem walking ──────────────────────────────────────────────── + + def _chapter_read_candidates(self, doc_id: str, chapter_subdir: str) -> list[str]: + """Return root-relative keys for both logical and catalog paths. + + Chapter planners may return either a path relative to the logical + document or the authoritative catalog-relative ``md_path``. The + latter must not be prefixed with ``doc_id`` a second time. Keeping + this normalization at the read boundary also makes retries safe when + the planner and worker run in separate service instances (where the + in-memory chapter alias is unavailable). + """ + raw_root = self._raw_fs.root_uri.rstrip("/") + "/" + requested = chapter_subdir.strip("/") + if requested.startswith(raw_root): + requested = requested.removeprefix(raw_root).strip("/") + path_parts = requested.split("/") + if ( + not requested + or any(part in {"", ".", ".."} for part in path_parts) + or "\\" in requested + ): + raise ValueError(f"Invalid chapter path: {chapter_subdir!r}") + + candidates: list[str] = [] + alias = self._chapter_path_aliases.get((doc_id, chapter_subdir), "") + if alias: + candidates.append(alias.strip("/")) + candidates.append(requested) + + if doc_id in self._raw_doc_prefixes: + prefix = self._raw_doc_prefixes[doc_id] or "" + else: + prefix = self._chapter_window_root(doc_id)[0] + prefix = prefix.strip("/") + logical_doc = doc_id.strip("/") + + if requested.endswith(".md"): + candidates.extend( + f"{root}/{requested}" + for root in (prefix, logical_doc) + if root and not (requested == root or requested.startswith(root + "/")) + ) + else: + for root in (prefix, logical_doc): + if root and not (requested == root or requested.startswith(root + "/")): + candidates.append(f"{root}/{requested}/chapter.md") + candidates.append(f"{root}/chapters/{requested}/chapter.md") + candidates.append(f"chapters/{requested}/chapter.md") + + return list(dict.fromkeys(candidate for candidate in candidates if candidate)) + + def read_chapter(self, doc_id: str, chapter_subdir: str) -> str: + """Read the full content of a chapter markdown file. + + ``chapter_subdir`` is the real path under ``{doc_id}/chapters/``. + Content is read through the raw backend. + """ + doc_parts = doc_id.split("/") + if ( + not doc_id + or doc_id.startswith("/") + or "\\" in doc_id + or any(part in {"", ".", ".."} for part in doc_parts) + ): + raise ValueError(f"Invalid document id: {doc_id!r}") + extraction_started = time.perf_counter() + content: str | None = None + for candidate in self._chapter_read_candidates(doc_id, chapter_subdir): + content = self._raw_fs.read_text(candidate) + if content is not None: + break + if content is None: + raise FileNotFoundError(f"Chapter not found: {doc_id}/{chapter_subdir}") + # Compact at the read boundary (not in storage): drops OCR HTML + # residue/template noise before the text reaches the model context. + # Raw files and their hashes are untouched, so source references stay + # valid. ~58% fewer input tokens, zero extractable-fact loss. + compacted = compact_chapter(content) + self._record_phase_timing("extraction", extraction_started) + return compacted + + def read_chapters_batch(self, doc_id: str, paths: list[str]) -> list[dict]: + """Read multiple chapters in one call. + + ``paths`` is a list of chapter subdir paths (e.g. + ``["01_故障诊断/01_发动机冒黑烟", "01_故障诊断/02_起动困难"]``). + Returns ``[{"path": ..., "uri": ..., "content": ...}, ...]``. + Failed reads are skipped silently — the caller should compare + the result length against the request length to detect misses. + """ + if not paths: + return [] + + # Viking imports may expose a logical document below a catalog path + # and preserve one additional document-named directory below it. The + # window API returns logical paths, while a worker may submit paths + # relative to either level. Resolve the immediate directory aliases + # once per batch so a miss does not turn into a worker-side browse/ + # retry loop. + read_roots: list[str] = [] + for root in (doc_id.strip("/"), self._raw_doc_prefixes.get(doc_id, "").strip("/")): + if root and root not in read_roots: + read_roots.append(root) + for root in tuple(read_roots): + for entry, is_dir in self._raw_fs.list_entries_with_meta(root): + if is_dir and entry not in read_roots: + read_roots.append(entry) + + def read_one(path: str) -> tuple[str, str, str] | None: + try: + key = self._chapter_path_aliases.get((doc_id, path), "") + candidates = [key] if key else [] + normalized_path = path.strip("/") + raw_prefix = self._raw_fs.root_uri.rstrip("/") + "/" + if normalized_path.startswith(raw_prefix): + normalized_path = normalized_path.removeprefix(raw_prefix) + # ``browse_chapters`` returns the resolved catalog-relative + # md_path, while older workers may send a path relative to the + # logical document. Try the resolved path directly before + # applying document-relative roots; otherwise the document + # prefix is duplicated and the batch looks falsely empty. + candidates.append(normalized_path) + for root in read_roots: + candidates.append(f"{root}/{normalized_path}") + if not normalized_path.endswith(".md"): + candidates.append(f"{root}/chapters/{normalized_path}/chapter.md") + for candidate in dict.fromkeys(candidates): + content = self._raw_fs.read_text(candidate) + if content is not None: + return path, compact_chapter(content), candidate + except (FileNotFoundError, OSError, OpenVikingError): + logger.debug("read_chapters_batch skip %s/%s", doc_id, path) + return None + return None + + max_workers = min(_io_worker_limit(), max(1, len(paths))) + with ThreadPoolExecutor(max_workers=max_workers) as pool: + read_results = list(pool.map(read_one, paths)) + records: list[dict[str, object]] = [] + for item in read_results: + if item is None: + continue + path, content, key = item + self._chapter_path_aliases.setdefault((doc_id, path), key) + records.append( + { + "path": path, + "uri": self.make_source_uri(doc_id, path), + "content": content, + }, + ) + return records + + def browse_chapters(self, doc_id: str, path: str = "") -> dict: + """Browse one level of a document's chapter tree, per-level drill-down. + + Resolves to ``{doc_id}/chapters`` (or ``{doc_id}/chapters/{path}``) and + lists that single directory via a lightweight non-recursive + ``list_entries`` — no deep-tree traversal, so the output is just the + direct children (far smaller than ``list_chapters``'s full listing). + ``browse_chapters(doc_id)`` → top-level rootsections; + ``browse_chapters(doc_id, path)`` → subdirectories at *path*. + + For browsing an arbitrary URI/library (not bound to the manual + ``chapters`` convention), use :meth:`browse`. + + Returns ``{type: "branch", path, children: [{title, path, has_children, uri?}]}``, + ``{type: "leaf", title, uri}``, or ``{type: "not_found", error}``. + """ + # ``browse_chapters`` is manual-chapter-scoped; resolve against the + # raw manual library only. A full raw root URI is accepted verbatim. + base = f"{doc_id}/chapters" if doc_id else "" + rel = f"{base}/{path.strip('/')}".strip("/") if path.strip("/") else base + prefix = self._raw_fs.root_uri.rstrip("/") + "/" + if path.startswith(prefix): + rel = path[len(prefix) :].strip("/") + return self._browse_dir(rel, self._raw_fs) + + def browse(self, uri: str = "") -> dict: + """Browse one level under an arbitrary in-root URI/path. + + Generic drill-down over any library, document, case, wiki, BOM or + other raw-root subtree — no assumption about a ``chapters`` layout. + ``uri`` may be a full ``viking://resources/<ns>/...`` URI (or + ``file://`` raw URI) within the raw manual library **or** the global + BOM library (``_bom_fs``, e.g. ``viking://resources/730/bom/...``), + or a path relative to the relevant root. Empty lists the raw manual + root itself. Each call returns only the direct children of that + location (lightweight non-recursive). + + Returns ``{type: "branch", path, children: [{title, path, has_children, uri?}]}``, + ``{type: "leaf", title, uri}``, or ``{type: "not_found", error}``. + """ + fs = self._raw_fs + if self._bom_fs is not None and ( + uri == self._bom_fs.root_uri or uri.startswith(self._bom_fs.root_uri.rstrip("/") + "/") + ): + fs = self._bom_fs + prefix = fs.root_uri.rstrip("/") + "/" + if uri == fs.root_uri or uri == fs.root_uri.rstrip("/") + "/": + rel = "" + elif uri.startswith(prefix): + rel = uri[len(prefix) :].strip("/") + else: + rel = uri.strip("/") + return self._browse_dir(rel, fs) + + def _chapter_window_root(self, doc_id: str) -> tuple[str, str]: + """Resolve a logical document to its root without building a leaf index.""" + if not doc_id.strip(): + raise ValueError("doc_id must not be empty") + if doc_id in self._raw_doc_prefixes: + # Empty prefix is legal: the library root itself is the document. + document_prefix = self._raw_doc_prefixes[doc_id] or "" + else: + if self._doc_prefix_index is None: + self._build_doc_prefix_index() + if doc_id in self._raw_doc_prefixes: + document_prefix = self._raw_doc_prefixes[doc_id] or "" + else: + document_prefix = ( + doc_id if self._raw_fs.is_dir(doc_id) else self._resolve_doc_prefix(doc_id) + ) + if not document_prefix and doc_id not in self._raw_doc_prefixes: + raise FileNotFoundError(f"Document directory does not exist: {doc_id}") + self._raw_doc_prefixes[doc_id] = document_prefix + chapters_root = f"{document_prefix}/chapters" if document_prefix else "chapters" + root = chapters_root if self._raw_fs.is_dir(chapters_root) else document_prefix + return document_prefix, root + + def _browse_dir(self, rel: str, fs: FSBackend) -> dict: + """Shared layered-browse helper: list one level under a root-relative path. + + ``fs`` is the resolved backend (raw manual library or global BOM + library); ``rel`` is a path relative to ``fs.root_uri``. + """ + pairs = fs.list_entries_with_meta(rel) + if not pairs: + if fs.exists(rel): + return { + "type": "leaf", + "title": _dir_to_clean_title(rel.rsplit("/", 1)[-1]), + "uri": f"{fs.root_uri}/{rel}" if rel else fs.root_uri, + } + return { + "type": "not_found", + "error": f"Path not found: {rel or fs.root_uri}", + } + children: list[dict] = [] + for entry, is_dir in sorted(pairs): + name = entry.rsplit("/", 1)[-1] + if not is_dir and not name.endswith(".md"): + continue + node: dict[str, object] = { + "title": _dir_to_clean_title(name), + "path": entry, + "has_children": is_dir, + } + if not is_dir: + node["uri"] = f"{fs.root_uri}/{entry}" + children.append(node) + return {"type": "branch", "path": rel or fs.root_uri, "children": children} + + # ── Source URI (moved from old builder) ───────────────────────────────── + + def make_source_uri(self, doc_id: str, subdir: str) -> str: + """Build a real-path raw chapter URI. + + Returns ``{root_uri}/{doc_id}/chapters/{subdir}/chapter.md`` where + ``root_uri`` is the active raw backend root (``file://…`` locally, + ``viking://resources/<raw_namespace>`` in viking mode). ``subdir`` + is the real chapter path under ``{doc_id}/chapters/``. + """ + raw_root = self._raw_fs.root_uri.rstrip("/") + "/" + requested = subdir.strip("/") + if requested.startswith(raw_root): + return f"{raw_root}{requested.removeprefix(raw_root)}" + alias = self._chapter_path_aliases.get((doc_id, subdir)) + if alias is not None: + return f"{self._raw_fs.root_uri}/{alias}" + if subdir.endswith(".md"): + prefix = self._raw_doc_prefixes.get(doc_id, "").strip("/") + logical_doc = doc_id.strip("/") + if requested == prefix or (prefix and requested.startswith(prefix + "/")): + return f"{self._raw_fs.root_uri}/{requested}" + if requested == logical_doc or requested.startswith(logical_doc + "/"): + return f"{self._raw_fs.root_uri}/{requested}" + for root in (prefix, logical_doc): + if root: + candidate = f"{root}/{requested}" + if self._raw_fs.exists(candidate): + return f"{self._raw_fs.root_uri}/{candidate}" + return f"{self._raw_fs.root_uri}/{doc_id}/{subdir}" + prefix = self._raw_doc_prefixes.get(doc_id, "") + if doc_id in self._raw_doc_prefixes and not prefix.strip(): + return f"{self._raw_fs.root_uri}/chapters/{subdir}/chapter.md" + return f"{self._raw_fs.root_uri}/{doc_id}/chapters/{subdir}/chapter.md" + + def inspect_wiki_state(self) -> dict[str, int | str]: + """Return the deterministic build mode without mutating build state. + + Source packets are stable, build-scoped records and may legitimately + exist before the first entity is materialized. A state inspection + must therefore never treat an empty entity library as permission to + delete them. ``record_source_packet`` already replaces packets from + older build ids in place after validating the current source snapshot. + """ + entity_count = len(self.store.list_entities()) + if entity_count == 0: + formal_paths: set[Path] = set() + for relative_root in self.store.CONCEPT_DIRS.values(): + keys = self.store.list_dir(relative_root, recursive=True) + for key in keys: + rel_parts = Path(key).relative_to(Path(relative_root)).parts + if "tmp" in rel_parts or "profile" in rel_parts: + continue + formal_paths.add(Path(key)) + entity_count = len(formal_paths) + mode = "empty" if entity_count == 0 else "incremental" + draft_count = 0 + profile_draft_count = 0 + return { + "mode": mode, + "entity_count": entity_count, + # Retained as a compatibility field for callers that still log + # it. State inspection is read-only, so the value is always 0. + "purged_source_packets": 0, + "draft_count": draft_count, + "profile_draft_count": profile_draft_count, + "storage_backend": "viking" if self.store.root_uri.startswith("viking://") else "local", + "wiki_root": self.store.root_uri, + "library_root": self._raw_fs.root_uri, + "bom_root": self._bom_fs.root_uri if self._bom_fs is not None else "", + "case_root": str(self._case_root) if self._case_root is not None else "", + "faultannotated_root": str(self._faultannotated_root) + if self._faultannotated_root is not None + else "", + } + + def inspect_build_checkpoint(self) -> dict[str, object]: + """Read the last durable build checkpoint, if one exists.""" + checkpoint = self.store.read_json("index/build_checkpoint.json") + if checkpoint is None: + return {"exists": False, "stage": ""} + return {"exists": True, **checkpoint} + + def plan_chapter_work( + self, + doc_id: str, + *, + build_id: str, + limit: int = 6, + active_packet_ids: list[str] | None = None, + audit_profile: str = "manual", + refresh: bool = False, + preview: bool = False, + preview_offset: int = 0, + preview_limit: int = 50, + ) -> dict[str, object]: + """Return the next receipt-missing chapters for dynamic dispatch. + + The service owns inventory, stable packet identities and completion + detection. The conductor owns scheduling: it chooses ``limit`` from + the workers currently available and may change that value every call. + No batch index or cursor participates in progress, so a restart or a + different worker count cannot skip chapters. + """ + normalized_build_id = build_id.strip() + if not normalized_build_id: + raise ValueError("build_id is required for chapter work planning") + if limit < 1 or limit > 32: + raise ValueError("limit must be between 1 and 32") + if audit_profile not in {"manual", "case"}: + raise ValueError("audit_profile must be manual or case") + bom_status = self.bom_enrichment_status() + if str(bom_status.get("status", "")) == "pending": + raw_pending_uris = bom_status.get("pending_uris", []) + raw_missing_uris = bom_status.get("missing_uris", []) + pending_uri_values = raw_pending_uris if isinstance(raw_pending_uris, list) else [] + missing_uri_values = raw_missing_uris if isinstance(raw_missing_uris, list) else [] + pending_bom_uris = [str(uri) for uri in pending_uri_values if str(uri)] + missing_bom_uris = [str(uri) for uri in missing_uri_values if str(uri)] + raise ValueError( + "BOM identity is registered but semantic enrichment is incomplete; " + "run plan_bom_enrichment() and complete guarded Component patches before " + f"chapter planning. pending={pending_bom_uris[:10]}, missing={missing_bom_uris[:10]}", + ) + + plan_id = sha256(f"{normalized_build_id}\x1f{doc_id}".encode()).hexdigest()[:24] + plan_key = f"index/chapter_plans/{plan_id}.json" + persisted = None if refresh else self.store.read_json(plan_key) + raw_chapters = persisted.get("chapters") if isinstance(persisted, dict) else None + reusable_plan = ( + isinstance(persisted, dict) + and persisted.get("version") == 4 + and persisted.get("doc_id") == doc_id + and persisted.get("build_id") == normalized_build_id + and persisted.get("audit_profile", "manual") == audit_profile + and isinstance(raw_chapters, list) + and all( + isinstance(item, dict) + and all( + str(item.get(field, "")).strip() + for field in ("uri", "packet_id", "shard_id", "task_description") + ) + for item in raw_chapters + ) + ) + if not reusable_plan: + listed = self.list_chapters(doc_id, refresh=True) + if not listed: + raise ValueError(f"Document has no readable leaf chapters: {doc_id}") + paths = [str(chapter["md_path"]) for chapter in listed] + with ThreadPoolExecutor(max_workers=min(_io_worker_limit(), len(paths) or 1)) as pool: + chapter_contents = list(pool.map(self._raw_fs.read_text, paths)) + toc_no_entity_decisions = [ + should_auto_register_no_entity_from_toc( + rootsection=str(chapter["rootsection"]), + section=str(chapter["section"]), + title=str(chapter["title"]), + ) + for chapter in listed + ] + fingerprint = build_fingerprint( + [ + content + for content, toc_no_entity in zip( + chapter_contents, + toc_no_entity_decisions, + strict=True, + ) + if isinstance(content, str) and not toc_no_entity + ], + ) + chapters: list[dict[str, object]] = [] + for chapter, content, toc_auto_no_entity in zip( + listed, + chapter_contents, + toc_no_entity_decisions, + strict=True, + ): + uri = self.make_source_uri(doc_id, str(chapter["subdir"])) + identity = sha256(f"{doc_id}\x1f{uri}".encode()).hexdigest()[:20] + packet_id = f"chapter_{identity}" + shard_id = f"chapter_{identity}" + idempotency_key = _chapter_idempotency_key(normalized_build_id, doc_id, uri) + score_record = ( + score_chapter_record(content, fingerprint) + if isinstance(content, str) + else { + "score": 0.0, + "action": "read", + "signal_breakdown": {"unreadable": 1}, + } + ) + auto_no_entity = isinstance(content, str) and should_auto_register_no_entity( + content, + score_record, + directory_administrative=toc_auto_no_entity, + ) + auto_no_entity_reason = "" + if auto_no_entity: + auto_no_entity_reason = ( + "deterministic_directory_administrative" + if toc_auto_no_entity + else "deterministic_low_information" + ) + chapters.append( + { + "uri": uri, + "rootsection": chapter["rootsection"], + "section": chapter["section"], + "subdir": chapter["subdir"], + "title": chapter["title"], + "md_path": chapter["md_path"], + "packet_id": packet_id, + "shard_id": shard_id, + "idempotency_key": idempotency_key, + "score": score_record["score"], + "score_action": score_record["action"], + "auto_no_entity": auto_no_entity, + "auto_no_entity_reason": auto_no_entity_reason, + "task_description": "\n".join( + [ + "worker_role: wiki_extraction_worker", + "phase=1A_source_analysis", + "chapter_count=1", + f"build_id={normalized_build_id}", + f"doc_id={doc_id}", + f"audit_profile={audit_profile}", + "depends_on_stage=bom_enriched", + f"shard_id={shard_id}", + f"chunk_id={shard_id}", + "chunk_of=1", + f"packet_id={packet_id}", + f"idempotency_key={idempotency_key}", + f"chapter_uri={uri}", + "analysis_kinds=causal,procedure,assembly,specification,device", + "heartbeat=task_update before first chapter read", + "expected_artifacts=source_packet", + "Read this chapter only and call record_source_packet for the exact source URI.", + ], + ), + }, + ) + if auto_no_entity: + assert isinstance(content, str) + self.record_source_packet( + packet_id=packet_id, + doc_id=doc_id, + source_uris=[uri], + status="complete", + evidence_count=0, + packet_body={ + "kind": "no_entity", + "reason_code": auto_no_entity_reason, + "score": score_record["score"], + "signal_breakdown": score_record["signal_breakdown"], + }, + source_contents={uri: content}, + build_id=normalized_build_id, + ) + self.store.write_json( + plan_key, + { + "version": 4, + "plan_id": plan_id, + "doc_id": doc_id, + "build_id": normalized_build_id, + "audit_profile": audit_profile, + "chapter_count": len(chapters), + "chapters": chapters, + }, + durable=True, + ) + else: + assert isinstance(raw_chapters, list) + chapters = [dict(item) for item in raw_chapters] + + pending: list[dict[str, object]] = [] + completed_count = 0 + checkpoint = self.store.read_json("index/build_checkpoint.json") + ownership_checkpoint: Mapping[str, object] = ( + checkpoint + if isinstance(checkpoint, dict) + else {"doc_id": doc_id, "input_docs": [doc_id]} + ) + # Map every source URI covered by a complete packet of this build to + # that packet's key. Low-value registrations write ONE multi-URI packet + # (e.g. '<build_id>_lowvalue'), while per-chapter completion only looks + # at the chapter-scoped packet key; without this fallback those chapters + # stay pending forever and get re-dispatched after pre-filtering. + complete_uri_owners: dict[str, str] = {} + for packet_key_chapter_scan in self.store.list_dir("source_packets", recursive=True): + if not packet_key_chapter_scan.endswith(".json"): + continue + owning = self.store.read_json(packet_key_chapter_scan) + if not isinstance(owning, dict) or str(owning.get("status", "")) != "complete": + continue + owning_build_id = str(owning.get("build_id", "")).strip() + if owning_build_id and owning_build_id != normalized_build_id: + continue + raw_owning_uris = ( + owning.get("source_uris", []) if isinstance(owning.get("source_uris"), list) else [] + ) + for raw_uri in raw_owning_uris: + if isinstance(raw_uri, str) and raw_uri not in complete_uri_owners: + complete_uri_owners[raw_uri] = packet_key_chapter_scan + for chapter in chapters: + packet_id = str(chapter.get("packet_id", "")) + uri = str(chapter.get("uri", "")) + packet_key = self._source_packet_key(packet_id) if packet_id else "" + packet = self.store.read_json(packet_key) if packet_key else None + completed = self._chapter_packet_complete_for_build( + packet_key, + packet if isinstance(packet, dict) else None, + build_id=normalized_build_id, + doc_id=doc_id, + source_uri=uri, + checkpoint=ownership_checkpoint, + ) + if completed or uri in complete_uri_owners: + completed_count += 1 + else: + pending.append(chapter) + active = {packet_id.strip() for packet_id in (active_packet_ids or []) if packet_id.strip()} + dispatchable = [ + chapter for chapter in pending if str(chapter.get("packet_id", "")) not in active + ] + selected = dispatchable[:limit] + auto_no_entity_count = sum(bool(chapter.get("auto_no_entity")) for chapter in chapters) + pending_packet_ids = {str(ch.get("packet_id", "")) for ch in pending} + + def _build_preview_entry(ch: dict[str, object]) -> dict[str, object]: + return { + "title": str(ch.get("title", "")), + "section": str(ch.get("section", "")), + "rootsection": str(ch.get("rootsection", "")), + "score": ch.get("score", 0.0), + "score_action": ch.get("score_action", ""), + "packet_id": str(ch.get("packet_id", "")), + "uri": str(ch.get("uri", "")), + "auto_no_entity": bool(ch.get("auto_no_entity")), + "completed": str(ch.get("packet_id", "")) not in pending_packet_ids, + } + + # ponytail: chapter_preview was returned on every call (~60K tokens for + # 337 chapters). Now opt-in via preview=True with pagination so the + # conductor pulls chapter metadata on demand instead of every response. + preview_total = len(chapters) + if preview: + pv_end = min(preview_offset + preview_limit, preview_total) + chapter_preview: list[dict[str, object]] = [ + _build_preview_entry(ch) for ch in chapters[preview_offset:pv_end] + ] + preview_has_more = pv_end < preview_total + else: + chapter_preview = [] + preview_has_more = preview_total > 0 + + pre_filter_required = ( + self.store.read_json( + f"index/chapter_plans/{plan_id}.prefilter.json", + ) + is None + ) + if pre_filter_required: + selected = [] + return { + "version": 4, + "plan_id": plan_id, + "doc_id": doc_id, + "build_id": normalized_build_id, + "chapter_count": len(chapters), + "completed_count": completed_count, + "auto_no_entity_count": auto_no_entity_count, + "dispatchable_chapter_count": len(chapters) - completed_count, + "pending_count": len(pending), + "in_flight_count": len(pending) - len(dispatchable), + "available_count": 0 if pre_filter_required else len(dispatchable), + "chapters": selected, + "work_items": selected, + "count": len(selected), + "chapter_preview": chapter_preview, + "preview_total": preview_total, + "preview_has_more": preview_has_more if preview else False, + "preview_available": preview_total > 0, + "pre_filter_required": pre_filter_required, + "pre_filter_message": ( + "PRE_FILTER_REQUIRED: call plan_chapter_work with preview=True " + "(and preview_offset/preview_limit for pagination) to retrieve " + "chapter metadata for filtering. Then call " + "register_no_entity_chapters(doc_id, build_id, " + "packet_id='<build_id>_lowvalue', source_uris=[low-value URIs]) " + "to skip administrative/preface chapters. work_items are withheld " + "until pre-filtering is done." + ) + if pre_filter_required + else "", + "done": not pending, + } + + @staticmethod + def _bom_mechanism_needs_enrichment(content: str | None) -> bool: + """Return whether a Component page still has the registration stub.""" + if not content: + return True + marker = f"## {SECTION_MECHANISM}" + start = content.find(marker) + if start < 0: + return True + section = content[start + len(marker) :] + next_heading = section.find("\n## ") + if next_heading >= 0: + section = section[:next_heading] + body = section.strip() + return not body or any( + marker_text in body for marker_text in _BOM_ENRICH_PLACEHOLDER_MARKERS + ) + + def bom_enrichment_status(self, packet_id: str = "") -> dict[str, object]: + """Inspect registered BOM Components without invoking a model. + + The identity packet is the durable input to this check. A Component + is pending until its page contains a non-placeholder ``工作机理`` + section, so the chapter preflight cannot silently bypass BOM enrich. + """ + requested_packet_id = packet_id.strip() + checkpoint = self.store.read_json("index/build_checkpoint.json") + active_build_id = ( + str(checkpoint.get("build_id", "")).strip() + if not requested_packet_id and isinstance(checkpoint, dict) + else "" + ) + packet_ids = ( + [requested_packet_id] + if requested_packet_id + else [ + Path(key).stem + for key in self.store.list_dir("source_packets", recursive=False) + if key.endswith(".json") + ] + ) + selected: list[tuple[str, dict[str, object]]] = [] + for candidate in packet_ids: + packet = self.store.read_json(self._source_packet_key(candidate)) + if ( + active_build_id + and isinstance(packet, dict) + and str(packet.get("build_id", "")).strip() != active_build_id + ): + continue + body = packet.get("packet") if isinstance(packet, dict) else None + if not isinstance(body, dict) or body.get("kind") != "bom_identity_plan": + continue + selected.append((candidate, packet)) + if not selected: + return { + "status": "not_required", + "packet_id": requested_packet_id, + "packet_ids": [], + "pending_uris": [], + "missing_uris": [], + } + + components_by_uri: dict[str, str] = {} + for candidate, packet in selected: + body = packet.get("packet") + components = body.get("resolved_components", []) if isinstance(body, dict) else [] + for item in components: + if not isinstance(item, dict): + continue + uri = str(item.get("component_uri", "")).strip() + if uri: + components_by_uri.setdefault(uri, candidate) + component_uris = list(components_by_uri) + pending_uris: list[str] = [] + missing_uris: list[str] = [] + for uri in component_uris: + content = self.store.read_entity_by_uri(uri) + if content is None: + missing_uris.append(uri) + elif self._bom_mechanism_needs_enrichment(content): + pending_uris.append(uri) + status = "pending" if pending_uris or missing_uris else "enriched" + return { + "status": status, + "packet_id": requested_packet_id + if requested_packet_id + else (selected[0][0] if len(selected) == 1 else ""), + "packet_ids": [candidate for candidate, _packet in selected], + "component_count": len(component_uris), + "component_uris": component_uris, + "pending_uris": pending_uris, + "missing_uris": missing_uris, + } + + def plan_bom_enrichment( + self, + packet_id: str = "", + *, + max_parallel_shards: int = 1, + ) -> dict[str, object]: + """Create deterministic, URI-disjoint BOM enrich work descriptions. + + ``max_parallel_shards`` is supplied from the conductor's live worker + capacity. The service partitions the current pending URI set into at + most that many balanced shards, while retaining the generic entity + batch limit as a per-task safety bound. When more work remains than + one wave can safely carry, a later planner call returns the next wave. + """ + if max_parallel_shards < 1: + raise ValueError("max_parallel_shards must be positive") + status = self.bom_enrichment_status(packet_id) + selected_packet_id = str(status.get("packet_id", "")) + if str(status.get("status", "")) == "not_required": + return { + "status": "not_required", + "shards": [], + "candidate_count": 0, + "dispatch_count": 0, + "remaining_count": 0, + "shard_count": 0, + } + pending = {str(uri) for uri in status.get("pending_uris", []) if str(uri)} + missing = {str(uri) for uri in status.get("missing_uris", []) if str(uri)} + items: list[dict[str, str]] = [] + packet_ids = [str(value) for value in status.get("packet_ids", []) if str(value).strip()] + for current_packet_id in packet_ids or [selected_packet_id]: + packet = self.store.read_json(self._source_packet_key(current_packet_id)) + body = packet.get("packet") if isinstance(packet, dict) else None + target_model = ( + str(body.get("target_model", "")).strip() if isinstance(body, dict) else "" + ) + components = body.get("resolved_components", []) if isinstance(body, dict) else [] + for item in components: + if not isinstance(item, dict): + continue + uri = str(item.get("component_uri", "")).strip() + if uri not in pending | missing: + continue + items.append( + { + "component_uri": uri, + "class_name": str(item.get("class_name", "")), + "object_name": str(item.get("object_name", "")), + "bom_path": str(item.get("bom_path", "")), + "packet_id": current_packet_id, + "target_model": target_model, + }, + ) + unique_items = {str(item["component_uri"]): item for item in items} + items = sorted(unique_items.values(), key=lambda item: str(item["component_uri"])) + parallel_shards = min(max_parallel_shards, len(items)) if items else 0 + chunk_size = ( + min( + _entity_batch_limit(), + (len(items) + parallel_shards - 1) // parallel_shards, + ) + if parallel_shards + else 0 + ) + dispatch_items = items[: chunk_size * parallel_shards] if chunk_size else [] + shards: list[dict[str, object]] = [] + for index, start in enumerate(range(0, len(dispatch_items), chunk_size), 1): + chunk = dispatch_items[start : start + chunk_size] + uris = [str(item["component_uri"]) for item in chunk] + shard_id = f"bom_enrich_{index}" + shard_packet_ids = sorted({str(item["packet_id"]) for item in chunk}) + shard_packet_id = shard_packet_ids[0] if shard_packet_ids else selected_packet_id + target_models = sorted({ + str(item["target_model"]) for item in chunk if str(item["target_model"]).strip() + }) + target_model = target_models[0] if target_models else "unknown" + shards.append( + { + "shard_id": shard_id, + "component_uris": uris, + "write_set": uris, + "write_scope": f"bom_enrich:{shard_id}", + "task_description": "\n".join( + [ + "worker_role: wiki_extraction_worker", + "phase=phase0", + "phase0_operation=bom_enrich", + "audit_profile=manual", + f"packet_id={shard_packet_id}", + f"packet_ids={json.dumps(shard_packet_ids, ensure_ascii=False, separators=(',', ':'))}", + f"target_model={target_model}", + "expected_artifacts=bom_enriched", + "depends_on_stage=bom_registered", + f"shard_id={shard_id}", + f"write_scope=bom_enrich:{shard_id}", + f"write_set={json.dumps(uris, ensure_ascii=False, separators=(',', ':'))}", + "bom_enrichment_items=" + + json.dumps(chunk, ensure_ascii=False, separators=(",", ":")), + "Use diff_entity then one guarded patch_entity per Component.", + ], + ), + }, + ) + return { + "status": "pending" if shards else "enriched", + "packet_id": selected_packet_id, + "packet_ids": packet_ids, + "shards": shards, + "candidate_count": len(items), + "dispatch_count": len(dispatch_items), + "remaining_count": len(items) - len(dispatch_items), + "shard_count": len(shards), + "missing_uris": sorted(missing), + } diff --git a/src/wolfharness/capabilities/wiki/planning/materialization.py b/src/wolfharness/capabilities/wiki/planning/materialization.py new file mode 100644 index 000000000..1df65ad8f --- /dev/null +++ b/src/wolfharness/capabilities/wiki/planning/materialization.py @@ -0,0 +1,1118 @@ +"""Materialization planning and template batch execution.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from hashlib import sha256 +import json +import logging + +from wolfharness.capabilities.wiki.io.template_materializer import ( + assemble_template_entity, + strip_device_prefix, +) +from wolfharness.capabilities.wiki.quality import parse_frontmatter +from wolfharness.capabilities.wiki.schema_loader import get_schema_version +from wolfharness.capabilities.wiki.storage import ( + LocalFS, + LocalVikingFS, +) + + +logger = logging.getLogger(__name__) + +from typing import TYPE_CHECKING + +from wolfharness.capabilities.wiki._helpers import ( + _entity_batch_limit, + _materialization_task_byte_limit, +) + + +if TYPE_CHECKING: + from collections.abc import Mapping + from pathlib import Path + + +def _core_section_filled(content: str, section_heading: str) -> bool: + """Check if entity content has a body section with real (non-placeholder) content.""" + idx = content.find(section_heading) + if idx < 0: + return False + after = content[idx + len(section_heading) :] + # Stop at the next section heading + next_section = after.find("\n## ") + if next_section >= 0: + after = after[:next_section] + after = after.strip() + if not after: + return False + # Treat placeholder text as empty + return "待补充" not in after + + +class MaterializationMixin: + """Materialization planning and template batch execution.""" + + def _materialization_candidates( + self, + *, + build_id: str = "", + doc_id: str = "", + ) -> dict[str, dict[str, object]]: + """Return current candidate inputs grouped by canonical entity URI.""" + candidates: list[dict[str, object]] = [] + packet_keys = [ + key + for key in self.store.list_dir("source_packets", recursive=False) + if key.endswith(".json") + ] + for key in packet_keys: + packet = self.store.read_json(key) + if not isinstance(packet, dict): + continue + packet_id = str(packet.get("packet_id", "")).strip() + packet_build_id = str(packet.get("build_id", "")).strip() + packet_doc_id = str(packet.get("doc_id", "")).strip() + # "legacy" is a degraded-mode fallback build_id used when the + # conductor loses the original build_id. In that case, do NOT + # filter by build_id — scan all packets so materialization can + # recover candidates from the original build's source packets. + if build_id and build_id not in ("legacy", packet_build_id): + continue + if doc_id and not ( + self._doc_id_variants(packet_doc_id) & self._doc_id_variants(doc_id) + ): + continue + if str(packet.get("status", "complete")) != "complete": + continue + body = packet.get("packet") + if ( + not packet_id + or not isinstance(body, dict) + or body.get("kind") in {"bom_identity_plan", "no_entity"} + ): + continue + raw_candidates = body.get("normalized_identity_candidates") + if raw_candidates is None: + raw_candidates = body.get("entity_candidates", body.get("entities", [])) + if not isinstance(raw_candidates, list): + continue + for candidate in raw_candidates: + if not isinstance(candidate, dict): + continue + concept = candidate.get("concept") + class_name = candidate.get("class_name", candidate.get("class")) + object_name = candidate.get("object_name", candidate.get("name")) + if not isinstance(concept, str) or not concept.strip(): + continue + # For Symptom, class_name is optional (directory layout drops class level) + needs_class = concept != "Symptom" + if needs_class and (not isinstance(class_name, str) or not class_name.strip()): + continue + if not isinstance(object_name, str) or not object_name.strip(): + continue + normalized_concept = concept.strip() + cls_value = ( + class_name.strip() + if isinstance(class_name, str) and class_name.strip() + else None + ) + if normalized_concept not in self.store.CONCEPT_DIRS: + # A malformed candidate must not abort the whole planner + # and force the conductor back into manual re-planning. + # Keep the packet available for audit, but only schedule + # concepts the store can materialize deterministically. + continue + uri = self.store.entity_uri(normalized_concept, cls_value, object_name.strip()) + raw_source_uris = packet.get("source_uris", []) + source_uris = raw_source_uris if isinstance(raw_source_uris, list) else [] + candidates.append( + { + "packet_id": packet_id, + "concept": normalized_concept, + "class_name": cls_value or "", + "object_name": object_name.strip(), + "entity_uri": uri, + "write_set": [uri], + "source_hash": str(packet.get("source_hash", "")), + "extractor_config_hash": str(packet.get("extractor_config_hash", "")), + "source_uris": [ + str(uri) for uri in source_uris if isinstance(uri, str) and uri.strip() + ], + "materialization_inputs": [ + { + "packet_id": packet_id, + "source_hash": str(packet.get("source_hash", "")), + "extractor_config_hash": str( + packet.get("extractor_config_hash", "") + ), + }, + ], + }, + ) + + unique: dict[str, dict[str, object]] = {} + for candidate in candidates: + uri = str(candidate["entity_uri"]) + existing = unique.get(uri) + if existing is None: + unique[uri] = candidate + continue + raw_packet_ids = existing.get("packet_ids", [existing["packet_id"]]) + packet_ids = ( + list(raw_packet_ids) + if isinstance(raw_packet_ids, list) + else [existing["packet_id"]] + ) + packet_id = str(candidate["packet_id"]) + if packet_id not in packet_ids: + packet_ids.append(packet_id) + existing["packet_ids"] = packet_ids + raw_inputs = existing.get("materialization_inputs", []) + inputs = list(raw_inputs) if isinstance(raw_inputs, list) else [] + inputs.append( + { + "packet_id": packet_id, + "source_hash": str(candidate.get("source_hash", "")), + "extractor_config_hash": str(candidate.get("extractor_config_hash", "")), + }, + ) + existing["materialization_inputs"] = inputs + raw_existing_sources = existing.get("source_uris", []) + raw_candidate_sources = candidate.get("source_uris", []) + existing_sources = ( + raw_existing_sources if isinstance(raw_existing_sources, list) else [] + ) + candidate_sources = ( + raw_candidate_sources if isinstance(raw_candidate_sources, list) else [] + ) + source_uris = [str(uri) for uri in existing_sources] + source_uris.extend(str(uri) for uri in candidate_sources) + existing["source_uris"] = list(dict.fromkeys(source_uris)) + + for candidate in unique.values(): + raw_inputs = candidate.get("materialization_inputs", []) + inputs = list(raw_inputs) if isinstance(raw_inputs, list) else [] + if not inputs: + inputs = [ + { + "packet_id": str(candidate["packet_id"]), + "source_hash": str(candidate.get("source_hash", "")), + "extractor_config_hash": str(candidate.get("extractor_config_hash", "")), + }, + ] + candidate["materialization_inputs"] = sorted( + inputs, key=lambda item: str(item["packet_id"]) + ) + candidate["materialization_input_hash"] = sha256( + json.dumps( + { + "entity_uri": candidate["entity_uri"], + "inputs": candidate["materialization_inputs"], + "schema_version": get_schema_version(), + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8"), + ).hexdigest() + return unique + + def _chapter_plan_pending_count( + self, doc_id: str, build_id: str + ) -> list[dict[str, str]] | None: + """Return pending chapter details when a server plan exists. + + Each element is ``{"uri": ..., "packet_id": ..., "reason": ...}`` so + callers can act on specific chapters instead of guessing from a count. + """ + plan_id = sha256(f"{build_id}\x1f{doc_id}".encode()).hexdigest()[:24] + persisted = self.store.read_json(f"index/chapter_plans/{plan_id}.json") + if not isinstance(persisted, dict): + return None + chapters = persisted.get("chapters") + if not isinstance(chapters, list): + return None + checkpoint = self.store.read_json("index/build_checkpoint.json") + ownership_checkpoint: Mapping[str, object] = ( + checkpoint + if isinstance(checkpoint, dict) + else {"doc_id": doc_id, "input_docs": [doc_id]} + ) + pending: list[dict[str, str]] = [] + for item in chapters: + if not isinstance(item, dict): + pending.append({"uri": "", "packet_id": "", "reason": "malformed plan entry"}) + continue + packet_id = str(item.get("packet_id", "")) + uri = str(item.get("uri", "")) + packet_key = self._source_packet_key(packet_id) if packet_id else "" + packet = self.store.read_json(packet_key) if packet_key else None + if not self._chapter_packet_complete_for_build( + packet_key, + packet if isinstance(packet, dict) else None, + build_id=build_id, + doc_id=doc_id, + source_uri=uri, + checkpoint=ownership_checkpoint, + ): + if not isinstance(packet, dict): + reason = "packet missing" + elif str(packet.get("status", "")) != "complete": + reason = f"status={packet.get('status', 'missing')}" + elif uri not in ( + packet.get("source_uris") if isinstance(packet.get("source_uris"), list) else [] + ): + reason = "source_uri not in packet" + else: + reason = f"build_id mismatch (packet={packet.get('build_id', '')})" + pending.append({"uri": uri, "packet_id": packet_id, "reason": reason}) + return pending + + def plan_materialization_work( + self, + *, + build_id: str = "", + doc_id: str = "", + active_entity_uris: list[str] | None = None, + audit_profile: str = "manual", + max_parallel_shards: int | None = None, + ) -> dict[str, object]: + """Plan only unconsumed, URI-disjoint 1B materialization inputs. + + ``active_entity_uris`` is the conductor's bounded live write-set view. + Excluding those items lets it fill newly free worker slots without a + fixed wave barrier or duplicate dispatch. ``max_parallel_shards`` + bounds each response so embedded candidates cannot overflow the + conductor context; omitted calls use a conservative first-wave limit. + """ + if max_parallel_shards is not None and max_parallel_shards < 1: + raise ValueError("max_parallel_shards must be positive when provided") + response_shard_limit = min(max_parallel_shards or 50, 50) + checkpoint = self.store.read_json("index/build_checkpoint.json") + if isinstance(checkpoint, dict): + build_id = build_id.strip() or str(checkpoint.get("build_id", "")).strip() + doc_id = doc_id.strip() or str(checkpoint.get("doc_id", "")).strip() + if audit_profile not in {"manual", "case"}: + raise ValueError("audit_profile must be manual or case") + if build_id and doc_id: + pending_chapters = self._chapter_plan_pending_count(doc_id, build_id) + if pending_chapters: + detail = "; ".join(f"{p['uri']} ({p['reason']})" for p in pending_chapters[:10]) + raise ValueError( + f"Chapter source-packet coverage is incomplete; " + f"finish pending chapter work before materialization. " + f"pending_count={len(pending_chapters)}, pending: {detail}", + ) + + all_candidates = self._materialization_candidates(build_id=build_id, doc_id=doc_id) + # In legacy recovery mode, receipts may have been written under the + # original build_id. Try both "legacy" and any build_id found on the + # candidates' source packets to avoid re-materializing already-done + # entities. + receipt_build_ids = [build_id or "legacy"] + if build_id == "legacy": + for candidate in all_candidates.values(): + pid = str(candidate.get("packet_id", "")) + if pid: + pkt = self.store.read_json(self._source_packet_key(pid)) + if isinstance(pkt, dict): + bid = str(pkt.get("build_id", "")).strip() + if bid and bid not in receipt_build_ids: + receipt_build_ids.append(bid) + break # one packet is enough to discover the original build_id + unique: dict[str, dict[str, object]] = {} + completed_count = 0 + for uri, candidate in all_candidates.items(): + already_done = False + for rbid in receipt_build_ids: + receipt = self.store.read_json(self._materialization_receipt_key(rbid, uri)) + if ( + isinstance(receipt, dict) + and str(receipt.get("materialization_input_hash", "")) + == str(candidate.get("materialization_input_hash", "")) + and self.store.read_entity_by_uri(str(receipt.get("result_entity_uri", uri))) + is not None + ): + already_done = True + break + if already_done: + completed_count += 1 + continue + unique[uri] = candidate + + active = { + uri.strip() + for uri in (active_entity_uris or []) + if uri.strip() and uri.strip() in unique + } + dispatchable = {uri: candidate for uri, candidate in unique.items() if uri not in active} + + grouped: dict[tuple[str, str], list[dict[str, object]]] = {} + for candidate in dispatchable.values(): + concept = str(candidate["concept"]) + # Symptom no longer partitions by class_name (directory layout + # drops the class level), so all Symptoms share one partition — + # unrelated URI sets still run concurrently. + class_partition = "" + grouped.setdefault((concept, class_partition), []).append(candidate) + shards: list[dict[str, object]] = [] + shard_index = 0 + packet_payload_cache: dict[str, dict[str, object]] = {} + + def packet_payload(packet_id: str) -> dict[str, object]: + cached = packet_payload_cache.get(packet_id) + if cached is not None: + return cached + packet = self.store.read_json(self._source_packet_key(packet_id)) + payload = { + "source_uris": packet.get("source_uris", []) if isinstance(packet, dict) else [], + "packet": packet.get("packet", {}) if isinstance(packet, dict) else {}, + } + packet_payload_cache[packet_id] = payload + return payload + + def materialization_chunks(items: list[dict[str, object]]) -> list[list[dict[str, object]]]: + chunks: list[list[dict[str, object]]] = [] + current: list[dict[str, object]] = [] + for item in items: + trial = [*current, item] + packet_ids: set[str] = set() + for candidate in trial: + raw_packet_ids = candidate.get("packet_ids", [candidate["packet_id"]]) + candidate_packet_ids = ( + raw_packet_ids + if isinstance(raw_packet_ids, list) + else [candidate["packet_id"]] + ) + packet_ids.update( + str(packet_id) + for packet_id in candidate_packet_ids + if str(packet_id).strip() + ) + estimated_payload = { + "entity_candidates": trial, + "source_packet_payloads": { + packet_id: packet_payload(packet_id) for packet_id in sorted(packet_ids) + }, + } + exceeds_count = len(trial) > _entity_batch_limit() + exceeds_bytes = ( + len( + json.dumps( + estimated_payload, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8"), + ) + > _materialization_task_byte_limit() + ) + if current and (exceeds_count or exceeds_bytes): + chunks.append(current) + current = [item] + else: + current = trial + if current: + chunks.append(current) + return chunks + + def _is_single_packet(candidate: dict[str, object]) -> bool: + # A candidate is multi-packet when its aggregated identity spans + # more than one source packet (cross-packet merge). Such + # candidates need LLM reconciliation; single-packet ones can be + # rendered from packet fields alone (template path, zero LLM). + pids = candidate.get("packet_ids", [candidate["packet_id"]]) + return not (isinstance(pids, list) and len(pids) > 1) + + for concept, class_partition in sorted(grouped): + items = sorted( + grouped[(concept, class_partition)], key=lambda item: str(item["entity_uri"]) + ) + # Split single-packet (template) from multi-packet (llm) BEFORE + # chunking. Previously the whole (concept, class) chunk shared one + # strategy via ``all(...)``: a single multi-packet candidate forced + # every single-packet sibling through the LLM path. Partitioning + # first lets single-packet entities form their own template shards + # (zero LLM, zero embedded payload) while only the genuine + # multi-packet minority pays for LLM reconciliation. + for items_subset, subset_strategy in ( + ([it for it in items if _is_single_packet(it)], "template"), + ([it for it in items if not _is_single_packet(it)], "llm"), + ): + for chunk in materialization_chunks(items_subset): + shard_index += 1 + uris = [str(item["entity_uri"]) for item in chunk] + packet_id_set: set[str] = set() + for item in chunk: + raw_packet_ids = item.get("packet_ids", [item["packet_id"]]) + item_packet_ids = ( + raw_packet_ids + if isinstance(raw_packet_ids, list) + else [item["packet_id"]] + ) + packet_id_set.update( + str(packet_id) + for packet_id in item_packet_ids + if str(packet_id).strip() + ) + packet_ids = sorted(packet_id_set) + shard_id = f"materialize_{concept.casefold()}_{shard_index}" + scope = ( + "materialize:" + sha256("\n".join(uris).encode("utf-8")).hexdigest()[:16] + ) + strategy = subset_strategy + if strategy == "template": + description = "\n".join( + [ + "worker_role: wiki_extraction_worker", + "phase=1B_materialization", + f"build_id={build_id or 'legacy'}", + f"doc_id={doc_id}", + f"packet_id={packet_ids[0]}", + f"packet_ids={json.dumps(packet_ids, ensure_ascii=False, separators=(',', ':'))}", + f"entity_type={concept}", + f"audit_profile={audit_profile}", + "depends_on_stage=1B_materialization", + "expected_artifacts=materialized_entities", + f"shard_id={shard_id}", + f"chunk_id={shard_id}", + "chunk_of=1", + f"write_scope={scope}", + f"write_set={json.dumps(uris, ensure_ascii=False, separators=(',', ':'))}", + "materialization_strategy=template", + "materialization_mode=embedded_candidates_only", + "source_packet_reread=forbidden", + "raw_chapter_reread=forbidden", + "Call materialize_template_batch(packet_ids=<packet_ids>, entity_uris=<write_set>, build_id=<build_id>, doc_id=<doc_id>) directly. Do NOT dispatch a worker. The tool reads packets server-side, assembles entity bodies from packet fields, and writes them. Check fallback_to_llm in the result; dispatch workers only for those.", + "entity_candidates=" + + json.dumps(chunk, ensure_ascii=False, separators=(",", ":")), + ], + ) + else: + is_local_backend = isinstance(self.store._fs, (LocalFS, LocalVikingFS)) + llm_lines = [ + "worker_role: wiki_extraction_worker", + "phase=1B_materialization", + f"build_id={build_id or 'legacy'}", + f"doc_id={doc_id}", + f"packet_id={packet_ids[0]}", + f"packet_ids={json.dumps(packet_ids, ensure_ascii=False, separators=(',', ':'))}", + f"entity_type={concept}", + f"audit_profile={audit_profile}", + "depends_on_stage=1B_materialization", + "expected_artifacts=materialized_entities", + f"shard_id={shard_id}", + f"chunk_id={shard_id}", + "chunk_of=1", + f"write_scope={scope}", + f"write_set={json.dumps(uris, ensure_ascii=False, separators=(',', ':'))}", + "materialization_strategy=llm", + ] + if is_local_backend: + llm_lines.extend([ + "materialization_mode=local_packet_reference", + "source_packet_reread=allowed", + "raw_chapter_reread=forbidden", + "Build is local: call record_source_packet to register evidence. Do NOT embed payloads.", + "entity_candidates=" + + json.dumps(chunk, ensure_ascii=False, separators=(",", ":")), + ]) + else: + source_packet_payloads = { + packet_id: packet_payload(packet_id) for packet_id in packet_ids + } + llm_lines.extend([ + "materialization_mode=embedded_candidates_only", + "source_packet_reread=forbidden", + "raw_chapter_reread=forbidden", + "Use source_packet_payloads and entity_candidates embedded below; do not decode the source again.", + "source_packet_payloads=" + + json.dumps( + source_packet_payloads, + ensure_ascii=False, + separators=(",", ":"), + ), + "entity_candidates=" + + json.dumps(chunk, ensure_ascii=False, separators=(",", ":")), + ]) + llm_lines.append( + "existing_entity_check=mandatory: Before writing any entity, " + "call list_children(<wiki_root>/<Concept>) to check if the entity " + "already exists. If it exists, use patch_entity (with expected_sha256 " + "from diff_entity) to merge — do NOT blind-write with write_entity. " + "If facts conflict, create_opa instead of overwriting.", + ) + description = "\n".join(llm_lines) + shards.append( + { + "shard_id": shard_id, + "concept": concept, + "packet_ids": packet_ids, + "entity_uris": uris, + "write_set": uris, + "write_scope": scope, + "candidate_count": len(chunk), + "strategy": strategy, + "task_description": description, + }, + ) + returned_candidate_count = sum( + int(shard["candidate_count"]) for shard in shards[:response_shard_limit] + ) + dispatchable_shards = shards + shards = dispatchable_shards[:response_shard_limit] + return { + "scope_source": "source_packets", + "build_id": build_id, + "doc_id": doc_id, + "shards": shards, + "shard_count": len(shards), + "candidate_count": returned_candidate_count, + "dispatchable_shard_count": len(dispatchable_shards), + "remaining_shard_count": len(dispatchable_shards) - len(shards), + "pending_count": len(unique), + "in_flight_count": len(active), + "total_candidate_count": len(all_candidates), + "completed_candidate_count": completed_count, + "done": not unique, + "idempotent": True, + } + + def record_materialization_receipt( + self, + build_id: str, + entity_uris: list[str], + resolved_entity_uris: dict[str, str] | None = None, + ) -> dict[str, object]: + """Commit receipts after a shard writes its planned entity identities. + + ``resolved_entity_uris`` explicitly records a source-backed identity + normalization (planned URI -> actual URI). The receipt remains keyed + by the planned input, so normalization cannot make the planner + dispatch the same candidate forever. + """ + normalized_build_id = build_id.strip() + if not normalized_build_id: + raise ValueError("build_id is required") + selected = list(dict.fromkeys(uri.strip() for uri in entity_uris if uri.strip())) + if not selected: + raise ValueError("entity_uris must not be empty") + candidates = self._materialization_candidates(build_id=normalized_build_id) + resolved = { + planned.strip(): actual.strip() + for planned, actual in (resolved_entity_uris or {}).items() + if planned.strip() and actual.strip() + } + writes: list[tuple[str, str]] = [] + for uri in selected: + candidate = candidates.get(uri) + if candidate is None: + raise ValueError(f"Entity is not a current materialization candidate: {uri}") + result_uri = resolved.get(uri, uri) + result_identity = self.store.lookup_by_uri(result_uri) + if result_identity is None or str(result_identity[0]) != str(candidate["concept"]): + raise ValueError( + f"Resolved materialization URI must exist and retain the planned concept: {uri} -> {result_uri}", + ) + content = self.store.read_entity_by_uri(result_uri) + if content is None: + raise ValueError(f"Materialized entity does not exist: {result_uri}") + record = { + "version": 2, + "build_id": normalized_build_id, + "entity_uri": uri, + "result_entity_uri": result_uri, + "packet_ids": candidate.get("packet_ids", [candidate.get("packet_id", "")]), + "materialization_input_hash": candidate["materialization_input_hash"], + "result_entity_sha256": sha256(content.encode("utf-8")).hexdigest(), + "recorded_at": datetime.now(UTC).isoformat(), + } + writes.append( + ( + self._materialization_receipt_key(normalized_build_id, uri), + json.dumps(record, ensure_ascii=False, indent=2) + "\n", + ), + ) + self.store.commit_many(writes) + return { + "build_id": normalized_build_id, + "recorded_count": len(writes), + "entity_uris": selected, + "resolved_entity_uris": {uri: resolved[uri] for uri in selected if uri in resolved}, + } + + # ── Chapter map (title ↔ URI, enables direct TOC-title-based lookup) ────── + + def materialize_template_batch( + self, + *, + packet_ids: list[str], + entity_uris: list[str], + build_id: str = "", + doc_id: str = "", + ) -> dict[str, object]: + """Materialize entities from packet fields without an LLM worker. + + Fast path for single-packet candidates: reads the source packet body, + assembles a draft entity page from ``source_subject`` / + ``explicit_facts`` / ``parts_and_specs`` / ``evidence_map`` / + ``ordered_actions``, and writes it via ``write_entities_batch``. + + Entities whose object_name carries a device-model prefix (e.g. + ``SY75后处理DPF``) are automatically normalized by stripping the + prefix; the planned URI → actual URI mapping is recorded via + ``resolved_entity_uris`` in the materialization receipt. + + Entities that fail validation (e.g. irreparable object_name) are + returned in ``fallback_to_llm`` so the conductor can dispatch them to + a worker instead. + """ + normalized_build_id = build_id.strip() + if not normalized_build_id: + checkpoint = self.store.read_json("index/build_checkpoint.json") + if isinstance(checkpoint, dict): + normalized_build_id = str(checkpoint.get("build_id", "")).strip() + doc_id = doc_id.strip() or str(checkpoint.get("doc_id", "")).strip() + if not normalized_build_id: + raise ValueError("build_id is required (pass explicitly or set checkpoint)") + + checkpoint = self.store.read_json("index/build_checkpoint.json") + device_id = ( + str(checkpoint.get("device_id", "")).strip() if isinstance(checkpoint, dict) else "" + ) + series_id = ( + str(checkpoint.get("series_id", "")).strip() if isinstance(checkpoint, dict) else "" + ) + device_model = device_id or series_id + + candidates = self._materialization_candidates(build_id=normalized_build_id, doc_id=doc_id) + requested = list(dict.fromkeys(uri.strip() for uri in entity_uris if uri.strip())) + if not requested: + raise ValueError("entity_uris must not be empty") + + batch_limit = _entity_batch_limit() + written_uris: list[str] = [] + resolved_map: dict[str, str] = {} + fallback_to_llm: list[dict[str, str]] = [] + errors: list[dict[str, str]] = [] + skipped_existing = 0 + + # Group by packet for efficient reads; only single-packet candidates + # are template-eligible. Multi-packet (merge) candidates go to fallback. + pending: list[dict[str, object]] = [] + for uri in requested: + candidate = candidates.get(uri) + if candidate is None: + fallback_to_llm.append({ + "entity_uri": uri, + "reason": "not a current materialization candidate", + }) + continue + raw_packet_ids = candidate.get("packet_ids", [candidate.get("packet_id", "")]) + pid_list = ( + raw_packet_ids + if isinstance(raw_packet_ids, list) + else [str(candidate.get("packet_id", ""))] + ) + if len(pid_list) > 1: + fallback_to_llm.append({ + "entity_uri": uri, + "reason": "multi-packet merge requires LLM", + }) + continue + pending.append(candidate) + + # Process in batches respecting the entity write limit. + for batch_start in range(0, len(pending), batch_limit): + batch = pending[batch_start : batch_start + batch_limit] + entities_to_write: list[dict[str, object]] = [] + batch_meta: list[ + tuple[str, str, str, str, str] + ] = [] # (planned_uri, concept, class_name, actual_object_name, packet_id) + + for candidate in batch: + concept = str(candidate["concept"]) + class_name = str(candidate["class_name"]) + original_object_name = str(candidate["object_name"]) + planned_uri = str(candidate["entity_uri"]) + packet_id = str(candidate.get("packet_id", "")) + + packet = self.store.read_json(self._source_packet_key(packet_id)) + if not isinstance(packet, dict): + fallback_to_llm.append({ + "entity_uri": planned_uri, + "reason": f"packet not readable: {packet_id}", + }) + continue + body = packet.get("packet") + if not isinstance(body, dict): + fallback_to_llm.append({ + "entity_uri": planned_uri, + "reason": f"packet body missing: {packet_id}", + }) + continue + + # Component/Fault with empty mechanism text → LLM inference needed + if concept == "Component": + if not str(body.get("working_mechanism", "")).strip(): + fallback_to_llm.append({ + "entity_uri": planned_uri, + "reason": "empty working_mechanism requires LLM inference", + }) + continue + elif concept == "Fault": + if not str(body.get("failure_mechanism", "")).strip(): + fallback_to_llm.append({ + "entity_uri": planned_uri, + "reason": "empty failure_mechanism requires LLM inference", + }) + continue + + # Strip device-model prefix for Component (validation rule). + actual_object_name = original_object_name + if concept == "Component" and device_model: + actual_object_name = strip_device_prefix( + original_object_name, device_id, series_id + ) + + content = assemble_template_entity( + packet_body=body, + concept=concept, + class_name=class_name, + object_name=actual_object_name, + device_model=device_model, + source_uris=list(candidate.get("source_uris", [])), # type: ignore[arg-type] + ) + + # Incremental fill-blanks: skip entities whose core section + # is already filled (confirmed/published OR draft with real content). + existing = self.store.read_entity(concept, class_name or None, actual_object_name) + expected_sha = "" + if existing is not None: + existing_fm = parse_frontmatter(existing) + existing_status = str(existing_fm.get("status", "")).strip() + if existing_status in ("confirmed", "published"): + skipped_existing += 1 + continue + # Check if the core section already has real content + core_section = ( + "## 工作机理" + if concept == "Component" + else "## 失效机理" + if concept == "Fault" + else None + ) + if core_section and _core_section_filled(existing, core_section): + skipped_existing += 1 + continue + expected_sha = sha256(existing.encode("utf-8")).hexdigest() + + entities_to_write.append({ + "concept": concept, + "class_name": class_name, + "object_name": actual_object_name, + "content": content, + "expected_sha256": expected_sha, + }) + batch_meta.append((planned_uri, concept, class_name, actual_object_name, packet_id)) + + if not entities_to_write: + continue + + try: + write_result = self.write_entities_batch(entities_to_write) + raw_written = write_result.get("uris", []) + written_list: list[str] = ( + [str(u) for u in raw_written] if isinstance(raw_written, list) else [] + ) + for idx, (planned_uri, _concept, _cls, _obj, _pid) in enumerate(batch_meta): + actual_uri = written_list[idx] if idx < len(written_list) else planned_uri + if actual_uri != planned_uri: + resolved_map[planned_uri] = actual_uri + written_uris.append(planned_uri) + # Create Symptom Profile skeletons with resolved sibling URIs. + # A plain skeleton (draft + placeholders) needs no LLM; only + # skeleton creation failures fall back to the LLM worker. + for planned_uri, _concept, _cls, _obj, packet_id in batch_meta: + if _concept != "Symptom": + continue + try: + symptom_uri = resolved_map.get(planned_uri, planned_uri) + packet = self.store.read_json(self._source_packet_key(packet_id)) + if not isinstance(packet, dict): + raise ValueError(f"packet not readable: {packet_id}") + raw_sources = packet.get("source_uris", []) + source_uris = [ + str(uri) for uri in raw_sources if isinstance(uri, str) and uri.strip() + ] + component = next( + ( + entry + for entry in batch_meta + if entry[4] == packet_id and entry[1] == "Component" + ), + None, + ) + if component is None: + raise ValueError( + f"no sibling Component in packet {packet_id} for profile skeleton", + ) + fault_uris = [ + self.store.entity_uri(entry[1], entry[2] or None, entry[3]) + for entry in batch_meta + if entry[4] == packet_id and entry[1] == "Fault" + ] + device_uri = "" + if device_model: + try: + device_uri = self.store.entity_uri("Device", None, device_model) + except ValueError: + device_uri = "" + if not device_uri: + device_entities = self.store.list_entities("Device") + if device_entities: + device_uri = device_entities[0][3] + profile_id = ( + f"{device_model}_{component[3]}" + .lower() + .replace(" ", "_") + .replace("/", "_") + .replace("\\", "_") + .replace("/", "_") + .replace("\", "_")[:80] + ) + content = self._symptom_profile_skeleton( + profile_id=profile_id, + symptom_uri=symptom_uri, + device_model=device_model, + component_name=component[3], + component_uri=self.store.entity_uri( + component[1], component[2] or None, component[3] + ), + fault_uris=fault_uris, + device_uri=device_uri, + source_uris=source_uris, + ) + self.write_symptom_profile(symptom_uri, profile_id, content) + except (ValueError, TypeError, KeyError) as exc: + fallback_to_llm.append({ + "entity_uri": planned_uri, + "reason": f"symptom profile creation requires LLM: {exc}", + }) + except (ValueError, TypeError) as exc: + # Validation rejected the write — retry per-entity, fallback to LLM only where the single write also fails. + for idx, (planned_uri, _concept, _cls, _obj, _pid) in enumerate(batch_meta): + raw_single = entities_to_write[idx] if idx < len(entities_to_write) else None + if isinstance(raw_single, list): + try: + self.write_entities_batch(raw_single) + continue + except (ValueError, TypeError) as single_exc: + fallback_to_llm.append({ + "entity_uri": planned_uri, + "reason": f"per-entity retry: {single_exc}", + }) + continue + fallback_to_llm.append({ + "entity_uri": planned_uri, + "reason": f"batch validation: {exc}", + }) + + # Record materialization receipts for successfully written entities. + recorded_count = 0 + if written_uris: + try: + receipt = self.record_materialization_receipt( + build_id=normalized_build_id, + entity_uris=written_uris, + resolved_entity_uris=resolved_map or None, + ) + recorded_count = int(str(receipt.get("recorded_count", 0))) + except (ValueError, KeyError) as exc: + errors.append({"receipt_error": str(exc)}) + + return { + "build_id": normalized_build_id, + "written_count": len(written_uris), + "recorded_count": recorded_count, + "fallback_count": len(fallback_to_llm), + "fallback_to_llm": fallback_to_llm, + "resolved_entity_uris": resolved_map, + "skipped_existing": skipped_existing, + "errors": errors, + } + + def _symptom_profile_skeleton( + self, + *, + profile_id: str, + symptom_uri: str, + device_model: str, + component_name: str, + component_uri: str, + fault_uris: list[str], + device_uri: str, + source_uris: list[str], + ) -> str: + """Build a draft Symptom Profile skeleton with resolved sibling URIs. + + Body sections carry LLM placeholders; a later worker phase fills them + with content extracted from the source chapters. + """ + fault_block = "".join(f" - {uri}\n" for uri in fault_uris) or " []\n" + source_block = "".join(f" - {uri}\n" for uri in source_uris) or " []\n" + return ( + "---\n" + f"id: PRO-{profile_id}\n" + f"profile_id: {profile_id}\n" + f"parent_symptom: {symptom_uri}\n" + "status: draft\n" + "applicable_models:\n" + f" - {device_model}\n" + "device_refs:\n" + f" - {device_uri}\n" + f"direct_component_uri: {component_uri}\n" + "possible_faults:\n" + f"{fault_block}" + "sources:\n" + f"{source_block}" + "---\n" + "\n" + "## 适用配置\n" + "\n" + f"{device_model} + {component_name}\n" + "\n" + "## 表现特征\n" + "\n" + "> 待 LLM 补充:从源章节提取该设备+Component组合下的具体表现特征。\n" + "\n" + "## 差异\n" + "\n" + "> 待 LLM 补充:与其他设备/配置组合的差异。\n" + "\n" + "## 可能失效机理\n" + "\n" + "> 待 LLM 补充:从源章节提取可能的失效机理。\n" + "\n" + "## 推荐诊断流程\n" + "\n" + "> 待 LLM 补充:从源章节提取推荐诊断流程。\n" + "\n" + "## 来源\n" + "\n" + "".join(f"[{uri}]({uri})\n" for uri in source_uris) + ) + + def _source_belongs_to_doc(self, uri: str, doc_id: str) -> bool: + """Return whether a raw URI belongs to the selected logical document. + + Parsed manuals normally live at ``<root>/<doc_id>/chapters``. Some + Viking imports preserve the source tree under an extra catalog + directory (for example ``menu/<display-doc-id>/...``). The discovered + prefix is authoritative when available; the segment fallback keeps + packet recording usable before the chapter map is persisted. + """ + root_prefix = self._raw_fs.root_uri.rstrip("/") + "/" + if not uri.startswith(root_prefix): + return False + relative = uri.removeprefix(root_prefix).strip("/") + logical_doc = doc_id.strip("/") + relative_folded = relative.casefold() + logical_doc_folded = logical_doc.casefold() + if logical_doc_folded and ( + relative_folded == logical_doc_folded + or relative_folded.startswith(logical_doc_folded + "/") + ): + return True + prefix = self._raw_doc_prefixes.get(doc_id, "") + # Empty prefix means the document IS the raw root (library root is the + # document); every raw URI under root belongs to it. + if doc_id in self._raw_doc_prefixes and not prefix.strip(): + return True + if prefix and relative.startswith(prefix.strip("/") + "/"): + return True + expected = logical_doc_folded + return any(part.casefold() == expected for part in relative.split("/")[:-1]) + + def _discover_nonstandard_doc_files(self, doc_id: str) -> tuple[str, list[str]]: + """Find a logical document nested below a catalog directory. + + The remote raw backend may not provide recursive glob, so this walks + only directory listings and stops the search once a directory segment + matches the requested document id. It returns the matched prefix and + all markdown leaves below it. + """ + expected = doc_id.casefold() + visited: set[str] = set() + + def collect(prefix: str) -> list[str]: + leaves: list[str] = [] + pending = [prefix] + while pending: + current = pending.pop() + if current in visited: + continue + visited.add(current) + for entry in self._raw_fs.list_entries(current, recursive=False): + if self._raw_fs.is_dir(entry): + pending.append(entry) + elif entry.endswith(".md"): + leaves.append(entry) + return sorted(leaves) + + def search(prefix: str) -> tuple[str, list[str]] | None: + for entry in self._raw_fs.list_entries(prefix, recursive=False): + if not self._raw_fs.is_dir(entry): + continue + name = entry.rsplit("/", 1)[-1] + if name.casefold() == expected: + return entry, collect(entry) + found = search(entry) + if found is not None: + return found + return None + + found = search("") + return found if found is not None else ("", []) + + def _chapter_map_path(self) -> Path: + """Path to the persisted chapter map JSON file.""" + return self.store.root / "chapter_map.json" + + def _save_chapter_map(self, doc_id: str, chapters: list[dict]) -> None: + """Build and persist title → raw chapter URI mapping for a document.""" + mapping = self._build_chapter_map(doc_id, chapters, self.make_source_uri) + self._chapter_map_cache[doc_id] = mapping + map_path = self._chapter_map_path() + all_maps: dict[str, dict[str, str]] = {} + raw = self.store.read_text(self.store._key_of(map_path)) + if raw is not None: + all_maps = json.loads(raw) + all_maps[doc_id] = mapping + self.store.write_json(self.store._key_of(map_path), all_maps) + + def read_chapter_map(self, doc_id: str) -> dict[str, str]: + """Return the title → raw chapter URI mapping for a document. + + Keys are clean chapter titles (e.g. ``发动机冒黑烟``) or raw TOC + titles. Values are real-path raw URIs of the form + ``{root_uri}/{doc_id}/chapters/<subdir>/chapter.md``. + """ + cached = self._chapter_map_cache.get(doc_id) + if cached is not None: + return cached + # Try persisted map first. + map_path = self._chapter_map_path() + raw = self.store.read_text(self.store._key_of(map_path)) + if raw is not None: + all_maps: dict[str, dict[str, str]] = json.loads(raw) + if doc_id in all_maps: + mapping = all_maps[doc_id] + self._chapter_map_cache[doc_id] = mapping + return mapping + chapters = self.list_chapters(doc_id) + mapping = self._build_chapter_map(doc_id, chapters, self.make_source_uri) + self._chapter_map_cache[doc_id] = mapping + return mapping + + # ── Library / chapter browsing ────────────────────────────────────────── diff --git a/src/wolfharness/capabilities/wiki/planning/relations.py b/src/wolfharness/capabilities/wiki/planning/relations.py new file mode 100644 index 000000000..a7a51add1 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/planning/relations.py @@ -0,0 +1,1322 @@ +"""Checkpoint, link sync, and relation planning/closure.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from hashlib import sha256 +import json +import logging +import os +from pathlib import Path +import re +import time + +from wolfharness.capabilities.wiki.quality import ( + BuildProfile, + all_relation_uris, + classify_raw_source_uri, + extract_malformed_wiki_uris, + extract_source_uris, + has_unresolved_placeholder, + is_optional_relation_field, + parse_frontmatter, +) +from wolfharness.capabilities.wiki.schema_loader import get_concept_schema, get_schema_version +from wolfharness.capabilities.wiki.section_constants import ( + SECTION_COMMON_FAILURE_MODES, + SECTION_DISASSEMBLY_STEPS, +) + + +logger = logging.getLogger(__name__) + +from typing import TYPE_CHECKING + +from wolfharness.capabilities.wiki._helpers import ( + _RELATION_CLOSURE_READY_STAGES, + _entity_batch_limit, + _io_worker_limit, +) + + +if TYPE_CHECKING: + from collections.abc import Callable + + +class RelationMixin: + """Checkpoint, link sync, and relation planning/closure.""" + + def checkpoint_build( + self, + doc_id: str, + device_id: str, + series_id: str, + stage: str, + *, + build_id: str = "", + input_hash: str = "", + config_hash: str = "", + source_snapshot_id: str = "", + snapshot_id: str = "", + input_docs: tuple[str, ...] = (), + schema_version: str = "", + audit_profile: BuildProfile = "manual", + last_error_code: str = "", + last_error: str = "", + ) -> dict[str, object]: + """Persist a resumable build checkpoint for the current wiki root.""" + checkpoint = self._build_checkpoint_record( + doc_id, + device_id, + series_id, + stage, + build_id=build_id, + input_hash=input_hash, + config_hash=config_hash, + source_snapshot_id=source_snapshot_id, + snapshot_id=snapshot_id, + input_docs=input_docs, + schema_version=schema_version, + audit_profile=audit_profile, + last_error_code=last_error_code, + last_error=last_error, + ) + self.store.write_json("index/build_checkpoint.json", checkpoint) + if stage in _RELATION_CLOSURE_READY_STAGES: + self._relation_manifest_packet_ids(checkpoint) + return {"exists": True, **checkpoint} + + def _build_checkpoint_record( + self, + doc_id: str, + device_id: str, + series_id: str, + stage: str, + *, + build_id: str = "", + input_hash: str = "", + config_hash: str = "", + source_snapshot_id: str = "", + snapshot_id: str = "", + input_docs: tuple[str, ...] = (), + schema_version: str = "", + audit_profile: BuildProfile = "manual", + last_error_code: str = "", + last_error: str = "", + ) -> dict[str, object]: + """Build checkpoint content without changing the visible build state.""" + if not stage.strip(): + raise ValueError("checkpoint stage must not be empty") + audit_profile = self._validate_audit_profile(audit_profile) + schema_version = schema_version or get_schema_version() + if not build_id: + build_id = sha256( + f"{doc_id}\x1f{device_id}\x1f{series_id}\x1f{input_hash}\x1f{config_hash}\x1f{','.join(input_docs)}\x1f{schema_version}\x1f{audit_profile}".encode(), + ).hexdigest()[:16] + existing = self.store.read_json("index/build_checkpoint.json") + existing_build_id = str(existing.get("build_id", "")) if isinstance(existing, dict) else "" + if existing is not None: + existing_profile = str(existing.get("audit_profile", "manual")) + if build_id == existing_build_id and existing_profile != audit_profile: + raise ValueError( + f"Build audit profile cannot change after checkpoint creation: existing={existing_profile}, requested={audit_profile}", + ) + started_at = ( + str(existing.get("started_at", "")) + if isinstance(existing, dict) and existing_build_id == build_id + else "" + ) + if not started_at: + started_at = datetime.now(UTC).isoformat() + updated_at = datetime.now(UTC).isoformat() + return { + "build_id": build_id, + "doc_id": doc_id, + "device_id": device_id, + "series_id": series_id, + "stage": stage, + "input_hash": input_hash, + "config_hash": config_hash, + "source_snapshot_id": source_snapshot_id, + "snapshot_id": snapshot_id, + "input_docs": list(input_docs), + "schema_version": schema_version, + "audit_profile": audit_profile, + "last_error_code": last_error_code, + "last_error": last_error, + "started_at": started_at, + "updated_at": updated_at, + } + + # ── Entity CRUD ───────────────────────────────────────────────────────── + + def _related_entity_links( + self, + concept: str, + relation_field: str, + component_uri: str, + ) -> list[tuple[str, str]]: + """Find real target pages whose relation field names *component_uri*.""" + links: list[tuple[str, str]] = [] + for _concept, _class_name, object_name, uri in self.store.list_entities(concept): + page = self.store.read_entity_by_uri(uri) + if page is None: + continue + relation_uris = all_relation_uris(page, concept, relation_field, self.store.root_uri) + if component_uri not in relation_uris: + continue + frontmatter = parse_frontmatter(page) + title = frontmatter.get("title") + label = title.strip() if isinstance(title, str) and title.strip() else object_name + links.append((label, uri)) + return links + + def _sync_component_narrative_links( + self, + component_uri: str, + *, + fault_links: list[tuple[str, str]] | None = None, + procedure_links: list[tuple[str, str]] | None = None, + force: bool = False, + ) -> None: + """Materialize reverse Fault/Procedure edges in a Component body. + + Structured edges remain authoritative in Fault/Procedure frontmatter; + this helper only mirrors those already-resolved edges into the two + human-readable Component sections required by the graph contract. + """ + with self._relation_sync_lock: + self._sync_component_narrative_links_locked( + component_uri, + fault_links=fault_links, + procedure_links=procedure_links, + force=force, + ) + + def _sync_component_narrative_links_locked( + self, + component_uri: str, + *, + fault_links: list[tuple[str, str]] | None = None, + procedure_links: list[tuple[str, str]] | None = None, + force: bool = False, + ) -> None: + """Run Component reverse-link synchronization under its shared lock.""" + relation_mode = os.environ.get("WIKI_RELATION_SYNC_MODE", "immediate").strip().lower() + if not force and relation_mode == "deferred": + return + info = self.store.lookup_by_uri(component_uri) + if info is None or info[0] != "Component": + return + concept, class_name, object_name = info + content = self.store.read_entity_by_uri(component_uri) + if content is None: + return + resolved_fault_links = ( + fault_links + if fault_links is not None + else self._related_entity_links("Fault", "affected_components", component_uri) + ) + resolved_procedure_links = ( + procedure_links + if procedure_links is not None + else self._related_entity_links("Procedure", "target_components", component_uri) + ) + updated = self._append_section_links( + content, SECTION_COMMON_FAILURE_MODES, resolved_fault_links + ) + updated = self._append_section_links( + updated, SECTION_DISASSEMBLY_STEPS, resolved_procedure_links + ) + updated = self._dedupe_h2_sections(updated) + if updated == content: + return + updated = self.store.resolve_body_refs(updated, None) + updated = self.store.dedup_citations(updated) + self.store.write_entity(concept, class_name, object_name, updated) + self.store.register_natural_key(concept, class_name, object_name, component_uri) + logger.info( + "Materialized Component narrative links: %s (faults=%d, procedures=%d)", + component_uri, + len(resolved_fault_links), + len(resolved_procedure_links), + ) + + def _sync_symptom_profile_index(self, symptom_uri: str, *, force: bool = False) -> None: + """Backfill ## Profile 索引 in a Symptom index.md from its profile files.""" + with self._relation_sync_lock: + self._sync_symptom_profile_index_locked(symptom_uri, force=force) + + def _sync_symptom_profile_index_locked(self, symptom_uri: str, *, force: bool = False) -> None: + """Run Symptom profile index synchronization under its shared lock.""" + relation_mode = os.environ.get("WIKI_RELATION_SYNC_MODE", "immediate").strip().lower() + if not force and relation_mode == "deferred": + return + info = self.store.lookup_by_uri(symptom_uri) + if info is None or info[0] != "Symptom": + return + concept, class_name, object_name = info + content = self.store.read_entity_by_uri(symptom_uri) + if content is None: + return + profiles = self.store.list_symptom_profiles(symptom_uri) + if not profiles: + return + rows: list[str] = [] + for profile_id, profile_uri in profiles: + profile_content = self.store.read_entity_by_uri(profile_uri) + if profile_content is None: + continue + fm = parse_frontmatter(profile_content) + device_ref = fm.get("device_refs", []) + direct_comp = fm.get("direct_component_uri", "") + applicable = fm.get("applicable_models", []) + if isinstance(applicable, list) and applicable: + desc = ", ".join(str(item) for item in applicable[:2]) + elif isinstance(device_ref, list) and device_ref: + first = device_ref[0] + desc = first.rsplit("/", 1)[-1] if isinstance(first, str) else profile_id + else: + desc = profile_id + if direct_comp and isinstance(direct_comp, str): + desc = f"{desc} + {direct_comp.rsplit('/', 1)[-1]}" + rows.append(f"| [{profile_id}]({profile_uri}) | {desc} |") + if not rows: + return + table_header = "| Profile | 设备 + 直接关联 Component |\n|---|---|" + table_body = "\n".join(rows) + section_body = f"{table_header}\n{table_body}" + updated = self._replace_h2_section(content, "Profile 索引", section_body) + updated = self._dedupe_h2_sections(updated) + if updated == content: + return + updated = self.store.resolve_body_refs(updated, None) + updated = self.store.dedup_citations(updated) + self.store.write_entity(concept, class_name, object_name, updated) + self.store.register_natural_key(concept, class_name, object_name, symptom_uri) + logger.info("Synced Symptom profile index: %s (%d profiles)", symptom_uri, len(rows)) + + @staticmethod + def _replace_h2_section(content: str, heading: str, body: str) -> str: + """Replace one existing ``##`` section or append it once.""" + matches = list(re.finditer(r"^##\s+(.+?)\s*$", content, re.MULTILINE)) + target_index = next( + (index for index, match in enumerate(matches) if match.group(1).strip() == heading), + None, + ) + rendered = f"\n\n## {heading}\n\n{body.rstrip()}\n" + if target_index is None: + return content.rstrip() + rendered + match = matches[target_index] + end = matches[target_index + 1].start() if target_index + 1 < len(matches) else len(content) + return ( + content[: match.start()] + + f"## {heading}\n\n{body.rstrip()}\n\n" + + content[end:].lstrip("\n") + ) + + def _sync_device_diagnostic_links(self) -> int: + """Materialize each Device's resolved Symptom→Fault→Component table. + + Device ``symptom_refs`` and Profile/Fault frontmatter are the + authoritative graph. This pass only mirrors already-resolved URIs; + it never invents a relation from display names. It runs once at the + relation join, so workers remain isolated while the Device page gets a + deterministic, complete diagnostic index. + """ + synced = 0 + with self._relation_sync_lock: + # Pre-collect DTCs by model prefix for the 控制器与故障码 section. + # DTC class_name format: <model>_<controller_role> (e.g. SY75C_主控制器) + dtc_by_model: dict[str, list[tuple[str, str, str, str]]] = {} + for _c, dtc_class, _o, dtc_uri in self.store.list_entities("DTC"): + dtc_content = self.store.read_entity_by_uri(dtc_uri) + if dtc_content is None: + continue + dtc_fm = parse_frontmatter(dtc_content) + code = str(dtc_fm.get("code", _o)) + role = str(dtc_fm.get("controller_role", "")) + if not role and "_" in dtc_class: + role = dtc_class.split("_", 1)[-1] + fault_uris = list( + all_relation_uris(dtc_content, "DTC", "related_faults", self.store.root_uri) + ) + fault_display = "" + if fault_uris: + fc = self.store.read_entity_by_uri(fault_uris[0]) + if fc: + ff = parse_frontmatter(fc) + fname = str(ff.get("title", fault_uris[0].rsplit("/", 1)[-1])) + fault_display = f"[{fname.replace('|', '/')}]({fault_uris[0]})" + model_prefix = dtc_class.split("_", 1)[0] if "_" in dtc_class else dtc_class + dtc_by_model.setdefault(model_prefix, []).append(( + role, + code, + dtc_uri, + fault_display, + )) + for _concept, class_name, object_name, device_uri in self.store.list_entities("Device"): + content = self.store.read_entity_by_uri(device_uri) + if content is None: + continue + device_components = all_relation_uris( + content, "Device", "critical_components", self.store.root_uri + ) + symptom_uris = all_relation_uris( + content, "Device", "symptom_refs", self.store.root_uri + ) + device_fm = parse_frontmatter(content) + raw_models = device_fm.get("applicable_models", []) + if isinstance(raw_models, list): + device_models = [str(m) for m in raw_models] + elif raw_models: + device_models = [str(raw_models)] + else: + device_models = [] + dtc_rows: list[tuple[str, str, str, str]] = [] + for model in device_models: + dtc_rows.extend(dtc_by_model.get(model, [])) + rows: dict[tuple[str, str, str], tuple[str, str, str, str]] = {} + symptom_links: list[tuple[str, str]] = [] + + for symptom_uri in symptom_uris: + symptom_content = self.store.read_entity_by_uri(symptom_uri) + if symptom_content is None: + continue + symptom_frontmatter = parse_frontmatter(symptom_content) + symptom_name = str( + symptom_frontmatter.get("title", symptom_uri.rsplit("/", 1)[-1]) + ) + symptom_links.append((symptom_name, symptom_uri)) + symptom_sources = extract_source_uris(symptom_content) + profiles = self.list_symptom_profiles(symptom_uri) + for profile in profiles: + profile_uri = str(profile.get("uri", "")) + profile_content = self.store.read_entity_by_uri(profile_uri) + if profile_content is None: + continue + profile_devices = all_relation_uris( + profile_content, "Symptom", "device_refs", self.store.root_uri + ) + if profile_devices and device_uri not in profile_devices: + continue + direct_component = list( + all_relation_uris( + profile_content, + "Symptom", + "direct_component_uri", + self.store.root_uri, + ) + ) + profile_faults = list( + all_relation_uris( + profile_content, + "Symptom", + "possible_faults", + self.store.root_uri, + ) + ) + for fault_uri in profile_faults: + fault_content = self.store.read_entity_by_uri(fault_uri) + if fault_content is None: + continue + fault_frontmatter = parse_frontmatter(fault_content) + affected_components = list( + all_relation_uris( + fault_content, + "Fault", + "affected_components", + self.store.root_uri, + ) + ) + component_candidates = [ + uri + for uri in dict.fromkeys([*direct_component, *affected_components]) + if not device_components or uri in device_components + ] + if not component_candidates: + continue + fault_name = str( + fault_frontmatter.get("title", fault_uri.rsplit("/", 1)[-1]) + ) + raw_sources = [ + uri + for uri in [ + *extract_source_uris(profile_content), + *extract_source_uris(fault_content), + *symptom_sources, + ] + if classify_raw_source_uri(uri, raw_root_uri=self._raw_fs.root_uri) + is not None + ] + evidence = raw_sources[0] if raw_sources else "" + for component_uri in component_candidates: + component_info = self.store.lookup_by_uri(component_uri) + component_name = ( + str(component_info[2]) + if component_info is not None + else component_uri.rsplit("/", 1)[-1] + ) + key = (symptom_uri, fault_uri, component_uri) + rows[key] = (symptom_name, fault_name, component_name, evidence) + + if not rows: + if not symptom_links and not dtc_rows: + continue + # No profile→fault→component chain resolved; fall back to + # listing symptom_refs as readable links in the body. + updated = content + if symptom_links: + table_lines = [ + "| 故障现象 |", + "| --- |", + ] + for sname, suri in sorted(symptom_links): + table_lines.append(f"| [{sname.replace('|', '/')}]({suri}) |") + updated = self._replace_h2_section( + updated, "常见故障及故障机理", "\n".join(table_lines) + ) + updated = self._dedupe_h2_sections(updated) + updated = self.store.resolve_body_refs(updated, None) + updated = self.store.dedup_citations(updated) + # DTC section + if dtc_rows: + dtc_table = [ + "| 控制器 | 故障码 | 关联失效 |", + "| --- | --- | --- |", + ] + for role, code, dtc_uri, fault_display in sorted(dtc_rows): + dtc_table.append( + f"| {role} | [{code}]({dtc_uri}) | {fault_display or '—'} |" + ) + updated = self._replace_h2_section( + updated, "控制器与故障码", "\n".join(dtc_table) + ) + updated = self._dedupe_h2_sections(updated) + if updated != content: + self.store.write_entity("Device", class_name, object_name, updated) + synced += 1 + continue + table_lines = [ + "| 故障现象 | 失效机理 | 关联部件 | 原文证据 |", + "| --- | --- | --- | --- |", + ] + for symptom_uri, fault_uri, component_uri in sorted(rows): + symptom_name, fault_name, component_name, evidence = rows[ + (symptom_uri, fault_uri, component_uri) + ] + evidence_cell = f"[原文]({evidence})" if evidence else "来源未说明" + table_lines.append( + "| " + f"[{symptom_name.replace('|', '/')}]({symptom_uri}) | " + f"[{fault_name.replace('|', '/')}]({fault_uri}) | " + f"[{component_name.replace('|', '/')}]({component_uri}) | {evidence_cell} |", + ) + updated = self._replace_h2_section( + content, "常见故障及故障机理", "\n".join(table_lines) + ) + updated = self._dedupe_h2_sections(updated) + updated = self.store.resolve_body_refs(updated, None) + updated = self.store.dedup_citations(updated) + # DTC section + if dtc_rows: + dtc_table = [ + "| 控制器 | 故障码 | 关联失效 |", + "| --- | --- | --- |", + ] + for role, code, dtc_uri, fault_display in sorted(dtc_rows): + dtc_table.append( + f"| {role} | [{code}]({dtc_uri}) | {fault_display or '—'} |" + ) + updated = self._replace_h2_section( + updated, "控制器与故障码", "\n".join(dtc_table) + ) + updated = self._dedupe_h2_sections(updated) + if updated != content: + self.store.write_entity("Device", class_name, object_name, updated) + synced += 1 + return synced + + def _relation_field_shape(self, concept: str, field: str) -> str: + """Return the schema-declared shape of one relation field.""" + schema = get_concept_schema(concept) + frontmatter = schema.get("frontmatter") + if not isinstance(frontmatter, list): + return "unknown" + for raw_field in frontmatter: + if not isinstance(raw_field, dict) or raw_field.get("name") != field: + continue + field_type = raw_field.get("type") + return str(field_type) if isinstance(field_type, str) else "unknown" + return "unknown" + + def _relation_field_names(self, concept: str) -> tuple[str, ...]: + """Return relation fields from the authoritative schema.""" + schema = get_concept_schema(concept) + frontmatter = schema.get("frontmatter") + if not isinstance(frontmatter, list): + return () + names = [ + str(item["name"]) + for item in frontmatter + if isinstance(item, dict) + and isinstance(item.get("name"), str) + and item.get("type") in {"ref", "ref_list"} + ] + return tuple(names) + + def _entity_relation_work_items(self) -> list[dict[str, object]]: + """Discover relation work from committed entities, not extraction state. + + Source packets are evidence for extraction and checkpoints are restart + metadata. Neither is authoritative for deciding whether a committed + page still needs a relation. This scan looks only at the current + entity library, schema relation fields and unresolved placeholders; + OPA/OPS review state is deliberately not a relation-work trigger. + """ + entity_records = self.store.list_entities() + known_uris = {str(record[3]) for record in entity_records} + root_uri = self.store.root_uri.rstrip("/") + "/" + + def relation_values(value: object) -> list[str]: + if isinstance(value, str): + return [value.strip()] if value.strip().startswith(root_uri) else [] + if isinstance(value, list): + return [uri for item in value for uri in relation_values(item)] + return [] + + items: list[dict[str, object]] = [] + for concept, class_name, object_name, uri in entity_records: + entity_uri = str(uri) + content = self.store.read_entity_by_uri(entity_uri) + if content is None: + continue + relation_fields = tuple( + field + for field in self._relation_field_names(str(concept)) + if not is_optional_relation_field(str(concept), field) + ) + if not relation_fields: + # Under the default backbone policy this page has no + # publication-critical outgoing relation. Do not schedule an + # LLM merely to beautify optional Procedure/DTC/Part links. + continue + frontmatter = parse_frontmatter(content) + missing_fields: list[str] = [] + dangling_fields: list[str] = [] + for field in relation_fields: + value = frontmatter.get(field) + values = relation_values(value) + if value is None or value in ("", []): + # Body fallback: check if the body section has URIs before + # flagging this field missing (body-first authoring). + if all_relation_uris(content, str(concept), field, root_uri): + continue + missing_fields.append(field) + continue + if ( + values + and any(uri_value.split("#", 1)[0] not in known_uris for uri_value in values) + ) or (isinstance(value, str) and value.strip().startswith(root_uri) and not values): + dangling_fields.append(field) + + placeholder = bool(has_unresolved_placeholder(content)) + malformed = bool(extract_malformed_wiki_uris(content)) + reasons: list[str] = [] + if missing_fields: + reasons.append("missing_relation_field") + if dangling_fields: + reasons.append("dangling_relation_uri") + if placeholder: + reasons.append("unresolved_placeholder") + if malformed: + reasons.append("malformed_uri") + if not reasons: + continue + query_parts = [str(object_name), str(class_name)] + query_parts.extend(missing_fields) + query_parts.extend(dangling_fields) + query = " ".join(part for part in query_parts if part.strip()) + items.append( + { + "entity_uri": entity_uri, + "concept": str(concept), + "class_name": str(class_name), + "object_name": str(object_name), + "write_set": [entity_uri], + "relation_fields": sorted({*missing_fields, *dangling_fields}), + "reasons": reasons, + "retrieval_query": query, + "expected_sha256": sha256(content.encode("utf-8")).hexdigest(), + }, + ) + return items + + def plan_relation_work( + self, + *, + active_entity_uris: list[str] | None = None, + max_parallel_shards: int | None = None, + ) -> dict[str, object]: + """Plan retrieval-and-patch work from the current committed Wiki. + + This is the primary relation planner. It intentionally does not read + checkpoints, manifests or source packets. A returned item is a bounded + entity write set; the worker must retrieve real neighbouring pages, + verify identity/evidence, then use guarded ``patch_entity``. Empty + fields are review work, never permission to invent a URI. + """ + if max_parallel_shards is not None and max_parallel_shards < 1: + raise ValueError("max_parallel_shards must be positive when provided") + active_uris = {uri.strip() for uri in active_entity_uris or [] if uri.strip()} + relation_items = self._entity_relation_work_items() + grouped: dict[str, list[dict[str, object]]] = {} + for item in relation_items: + grouped.setdefault(str(item["concept"]), []).append(item) + all_shards: list[dict[str, object]] = [] + active_excluded_count = 0 + shard_index = 0 + for concept in sorted(grouped): + concept_items = grouped[concept] + for start in range(0, len(concept_items), _entity_batch_limit()): + shard_index += 1 + chunk = concept_items[start : start + _entity_batch_limit()] + entity_uris = [str(item["entity_uri"]) for item in chunk] + shard_id = f"entity_relation_{concept.casefold()}_{shard_index}" + task_description = "\n".join( + [ + "worker_role: wiki_relation_worker", + "depends_on_stage: entity_library_materialized", + "relation_planner: plan_relation_work", + f"relation_shard_id: {shard_id}", + f"write_scope: entity_relation_retrieval:{shard_id}", + f"write_set: {json.dumps(entity_uris, ensure_ascii=False, separators=(',', ':'))}", + "relation_work_items: " + + json.dumps(chunk, ensure_ascii=False, separators=(",", ":")), + "expected_artifacts: patched_entities_or_source_honest_open_gap", + ], + ) + shard: dict[str, object] = { + "shard_id": shard_id, + "concept": concept, + "entity_uris": entity_uris, + "write_set": entity_uris, + "relation_work_items": chunk, + "candidate_count": len(chunk), + "write_scope": f"entity_relation_retrieval:{shard_id}", + "task_description": task_description, + } + if active_uris.intersection(entity_uris): + active_excluded_count += len(chunk) + continue + all_shards.append(shard) + shards = all_shards[:max_parallel_shards] if max_parallel_shards is not None else all_shards + returned_candidate_count = sum(int(shard["candidate_count"]) for shard in shards) + dispatchable_candidate_count = sum(int(shard["candidate_count"]) for shard in all_shards) + current_uris = sorted( + known_uri for known_uri in {str(record[3]) for record in self.store.list_entities()} + ) + scope_id = ( + "entity-relation-" + sha256("\n".join(current_uris).encode("utf-8")).hexdigest()[:16] + ) + ledger_uri = self._write_relation_work_ledger( + scope_id, + scope_source="entity_library", + scope_trusted=True, + packet_ids=set(), + items=relation_items, + ) + return { + "scope_source": "entity_library", + "scope_id": scope_id, + "work_ledger_uri": ledger_uri, + "shards": shards, + "shard_count": len(shards), + "candidate_count": returned_candidate_count, + "pending_count": len(relation_items), + "active_excluded_count": active_excluded_count, + "remaining_count": dispatchable_candidate_count - returned_candidate_count, + "join": { + "required": False, + "service_action": "rebuild_all_backlinks", + "entity_uris": sorted({str(item["entity_uri"]) for item in relation_items}), + "write_set": sorted({str(item["entity_uri"]) for item in relation_items}), + "sync_component_links": True, + "depends_on_shards": [str(shard["shard_id"]) for shard in shards], + "task_description": "", + }, + "idempotent": True, + } + + def _resolve_relation_identity(self, value: object) -> str | None: + """Resolve a packet identity without fuzzy name matching.""" + if not isinstance(value, dict): + return None + concept = value.get("concept") + class_name = value.get("class_name", value.get("class")) + object_name = value.get("object_name", value.get("object")) + if not all(isinstance(item, str) and item.strip() for item in (concept, object_name)): + return None + assert isinstance(concept, str) + assert isinstance(object_name, str) + normalized_class = ( + class_name.strip() if isinstance(class_name, str) and class_name.strip() else None + ) + return self.store.lookup_natural_key(concept, normalized_class, object_name.strip()) + + def _resolve_relation_uri(self, value: object) -> str | None: + """Resolve an explicitly supplied URI only when its page exists.""" + if not isinstance(value, str) or not value.strip(): + return None + candidate = value.strip() + if candidate in {"open_gap", "needs_predecessor", "unresolved"}: + return None + resolved = self.store.resolve_redirect(candidate) + if self.store.lookup_by_uri(resolved) is None: + return None + return resolved if self.store.read_entity_by_uri(resolved) is not None else None + + def _relation_context( + self, + selected_packets: set[str], + ) -> tuple[ + dict[str, tuple[str, str, str]], + Callable[[object], str | None], + Callable[[object], str | None], + list[tuple[str, dict[str, object], str]], + ]: + """Load one immutable relation snapshot for planning and closure. + + Relation planning and execution must resolve identities from the same + snapshot. Keeping this discovery in one helper prevents the planner + from drifting away from the actual closure semantics and avoids a + remote lookup for every candidate endpoint. + """ + entity_records = self.store.list_entities() + known_uris = {str(record[3]) for record in entity_records} + known_info_by_uri = { + str(record[3]): (str(record[0]), str(record[1]), str(record[2])) + for record in entity_records + } + known_by_identity = { + ( + str(record[0]), + str(self.store.logical_class_name(record[0], record[1]) or ""), + str(record[2]), + ): str(record[3]) + for record in entity_records + } + + def resolve_uri(value: object) -> str | None: + if not isinstance(value, str) or not value.strip(): + return None + candidate = value.strip().split("#", 1)[0] + if candidate in known_uris: + return candidate + return self._resolve_relation_uri(candidate) + + def resolve_identity(value: object) -> str | None: + if not isinstance(value, dict): + return None + concept = value.get("concept") + class_name = value.get("class_name", "") + object_name = value.get("object_name") + if not all(isinstance(item, str) for item in (concept, class_name, object_name)): + return None + key = ( + concept, + str(self.store.logical_class_name(concept, class_name) or ""), + object_name, + ) + return known_by_identity.get(key) or self._resolve_relation_identity(value) + + packet_keys = [ + key + for key in self.store.list_dir("source_packets", recursive=True) + if key.endswith(".json") + ] + if selected_packets: + packet_keys = [key for key in packet_keys if Path(key).stem in selected_packets] + + def read_packet(key: str) -> tuple[str, dict[str, object] | None]: + return key, self.store.read_json(key) + + with ThreadPoolExecutor( + max_workers=min(_io_worker_limit(), max(1, len(packet_keys))) + ) as pool: + packet_records = list(pool.map(read_packet, packet_keys)) + candidates: list[tuple[str, dict[str, object], str]] = [] + for key, packet in packet_records: + if not isinstance(packet, dict): + continue + packet_id = str(packet.get("packet_id", "")) + if selected_packets and packet_id not in selected_packets: + continue + body = packet.get("packet") + if not isinstance(body, dict): + continue + raw_edges: object = body.get("local_relation_edges") + if raw_edges is None: + raw_edges = body.get("cross_concept_handoffs") + if raw_edges is None: + raw_edges = body.get("relation_candidates") + if isinstance(raw_edges, list): + candidates.extend( + (packet_id, raw_edge, key) + for raw_edge in raw_edges + if isinstance(raw_edge, dict) + ) + return known_info_by_uri, resolve_uri, resolve_identity, candidates + + def plan_relation_shards( + self, + entity_uris: list[str] | None = None, + *, + packet_ids: list[str] | None = None, + ) -> dict[str, object]: + """Plan only the non-empty, disjoint relation write shards. + + Shards are derived from materialized relation candidates, not from a + fixed worker count. A source entity belongs to exactly one concept + shard, so two shard workers never patch the same source URI. Optional + relation fields and unresolved source-honest gaps are reported but do + not create empty tasks. + """ + checkpoint = self.inspect_build_checkpoint() + stage = str(checkpoint.get("stage", "")) + if not checkpoint.get("exists") or stage not in _RELATION_CLOSURE_READY_STAGES: + current_stage = stage or "missing" + raise ValueError( + f"Relation planning is gated until materialization is complete; current checkpoint stage={current_stage!r}. Call checkpoint_build(stage='materialized') first.", + ) + + global_scope = entity_uris is None and packet_ids is None + manifest_packet_ids = self._relation_manifest_packet_ids(checkpoint) + scope = { + uri.split("#", 1)[0] + for uri in (entity_uris or []) + if isinstance(uri, str) and uri.strip() + } + selected_packets = { + packet_id.strip() for packet_id in (packet_ids or []) if packet_id.strip() + } + if packet_ids is None: + selected_packets = set(manifest_packet_ids) + known_info_by_uri, resolve_uri, resolve_identity, candidates = self._relation_context( + selected_packets + ) + groups: dict[str, dict[str, object]] = {} + unresolved_count = 0 + deferred_optional_count = 0 + candidate_packet_ids = {packet_id for packet_id, _edge, _packet_key in candidates} + + for packet_id, edge, _packet_key in candidates: + from_uri = resolve_uri(edge.get("from_uri")) or resolve_identity( + edge.get("from_identity") + ) + relation = edge.get("relation", edge.get("field")) + relation_name = relation.strip() if isinstance(relation, str) else "" + if from_uri is None or not relation_name: + unresolved_count += 1 + continue + from_info = known_info_by_uri.get(from_uri) + if from_info is None or relation_name not in set( + self._relation_field_names(from_info[0]) + ): + unresolved_count += 1 + continue + if scope and from_uri not in scope: + continue + if is_optional_relation_field(from_info[0], relation_name): + deferred_optional_count += 1 + continue + target_uri = resolve_uri(edge.get("to_uri")) or resolve_identity( + edge.get("to_identity") + ) + if target_uri is None: + unresolved_count += 1 + continue + + concept = from_info[0] + group = groups.setdefault( + concept, + { + "concept": concept, + "entity_uris": set(), + "packet_ids": set(), + "packet_ids_by_entity": {}, + "candidate_count_by_entity": {}, + "relation_fields": set(), + "candidate_count": 0, + }, + ) + entity_set = group["entity_uris"] + packet_set = group["packet_ids"] + relation_set = group["relation_fields"] + assert isinstance(entity_set, set) + assert isinstance(packet_set, set) + assert isinstance(relation_set, set) + packet_ids_by_entity = group["packet_ids_by_entity"] + candidate_count_by_entity = group["candidate_count_by_entity"] + assert isinstance(packet_ids_by_entity, dict) + assert isinstance(candidate_count_by_entity, dict) + entity_set.add(from_uri) + packet_set.add(packet_id) + entity_packet_ids = packet_ids_by_entity.setdefault(from_uri, set()) + entity_candidate_count = candidate_count_by_entity.get(from_uri, 0) + assert isinstance(entity_packet_ids, set) + assert isinstance(entity_candidate_count, int) + entity_packet_ids.add(packet_id) + candidate_count_by_entity[from_uri] = entity_candidate_count + 1 + relation_set.add(relation_name) + group["candidate_count"] = int(group["candidate_count"]) + 1 + + shards: list[dict[str, object]] = [] + all_entity_uris: set[str] = set() + shard_index = 0 + for concept in sorted(groups): + group = groups[concept] + entity_set = group["entity_uris"] + packet_set = group["packet_ids"] + relation_set = group["relation_fields"] + assert isinstance(entity_set, set) + assert isinstance(packet_set, set) + assert isinstance(relation_set, set) + packet_ids_by_entity = group["packet_ids_by_entity"] + candidate_count_by_entity = group["candidate_count_by_entity"] + assert isinstance(packet_ids_by_entity, dict) + assert isinstance(candidate_count_by_entity, dict) + entity_list = sorted(str(uri) for uri in entity_set) + for start in range(0, len(entity_list), _entity_batch_limit()): + shard_index += 1 + entity_chunk = entity_list[start : start + _entity_batch_limit()] + packet_list = sorted( + { + str(packet_id) + for entity_uri in entity_chunk + for packet_id in packet_ids_by_entity.get(entity_uri, set()) + }, + ) + candidate_count = sum( + int(candidate_count_by_entity.get(entity_uri, 0)) for entity_uri in entity_chunk + ) + all_entity_uris.update(entity_chunk) + shards.append( + { + "shard_id": f"relation_{concept.casefold()}_{shard_index}", + "concept": concept, + "entity_uris": entity_chunk, + "write_set": entity_chunk, + "packet_ids": packet_list, + "relation_fields": sorted(str(field) for field in relation_set), + "candidate_count": candidate_count, + "sync_component_links": False, + }, + ) + + join = { + "required": bool(shards), + "entity_uris": sorted(all_entity_uris), + "write_set": sorted(all_entity_uris), + "packet_ids": sorted(candidate_packet_ids), + "sync_component_links": True, + "depends_on_shards": [str(shard["shard_id"]) for shard in shards], + } + if global_scope: + self.store.write_json( + self._relation_manifest_key(str(checkpoint["build_id"])), + { + "version": 1, + "build_id": str(checkpoint["build_id"]), + "doc_id": str(checkpoint.get("doc_id", "")), + "input_docs": checkpoint.get("input_docs", []), + "packet_ids": sorted(manifest_packet_ids), + "candidate_packet_ids": sorted(candidate_packet_ids), + "candidate_entity_uris": sorted(all_entity_uris), + "updated_at": datetime.now(UTC).isoformat(), + }, + ) + return { + "shards": shards, + "shard_count": len(shards), + "candidate_count": len(candidates), + "manifest_packet_ids": sorted(manifest_packet_ids), + "candidate_packet_ids": sorted(candidate_packet_ids), + "global_scope": global_scope, + "unresolved_count": unresolved_count, + "deferred_optional_count": deferred_optional_count, + "join": join, + "idempotent": True, + } + + def build_relation_closure( + self, + entity_uris: list[str] | None = None, + *, + packet_ids: list[str] | None = None, + sync_component_links: bool = True, + ) -> dict[str, object]: + """Resolve source-packet relation candidates after materialization. + + The operation is deterministic and idempotent. It consumes + ``local_relation_edges`` (or the compatibility field + ``cross_concept_handoffs``) from source packets, resolves exact + natural keys once all entities are visible, merges each relation field + with optimistic locking, and materializes body links in one closure + pass. Unresolved candidates are returned as stable records for OPA or + a later build; they are never converted into guessed URIs. + """ + checkpoint = self.inspect_build_checkpoint() + stage = str(checkpoint.get("stage", "")) + if not checkpoint.get("exists") or stage not in _RELATION_CLOSURE_READY_STAGES: + current_stage = stage or "missing" + raise ValueError( + f"Relation closure is gated until materialization is complete; current checkpoint stage={current_stage!r}. Call checkpoint_build(stage='materialized') first.", + ) + + relation_started = time.perf_counter() + scope = { + uri.split("#", 1)[0] + for uri in (entity_uris or []) + if isinstance(uri, str) and uri.strip() + } + manifest_packet_ids = self._relation_manifest_packet_ids(checkpoint) + selected_packets = { + packet_id.strip() for packet_id in (packet_ids or []) if packet_id.strip() + } + if packet_ids is None: + selected_packets = set(manifest_packet_ids) + known_info_by_uri, resolve_uri, resolve_identity, candidates = self._relation_context( + selected_packets + ) + + relation_scope_complete = True + coverage_missing_packets: set[str] = set() + coverage_missing_entities: set[str] = set() + if sync_component_links and manifest_packet_ids: + _all_info, all_resolve_uri, all_resolve_identity, all_candidates = ( + self._relation_context(manifest_packet_ids) + ) + required_packet_ids = {packet_id for packet_id, _edge, _packet_key in all_candidates} + coverage_missing_packets = required_packet_ids - selected_packets + for _packet_id, edge, _packet_key in all_candidates: + from_uri = all_resolve_uri(edge.get("from_uri")) or all_resolve_identity( + edge.get("from_identity") + ) + relation = edge.get("relation", edge.get("field")) + relation_name = relation.strip() if isinstance(relation, str) else "" + if from_uri is None or not relation_name or from_uri not in _all_info: + continue + if is_optional_relation_field(_all_info[from_uri][0], relation_name): + continue + coverage_missing_entities.add(from_uri) + if scope: + coverage_missing_entities.difference_update(scope) + else: + coverage_missing_entities.clear() + relation_scope_complete = not coverage_missing_packets and not coverage_missing_entities + if not relation_scope_complete: + missing_packets = sorted(coverage_missing_packets) + missing_entities = sorted(coverage_missing_entities) + raise ValueError( + "Relation join scope is incomplete for the current build; " + f"missing_packets={missing_packets[:10]}, missing_entities={missing_entities[:10]}. " + "Run plan_relation_shards() without a scope and complete every returned shard before join.", + ) + + updates: dict[str, dict[str, set[str]]] = {} + unresolved: list[dict[str, object]] = [] + touched_components: set[str] = set() + linked_edge_count = 0 + deferred_optional_count = 0 + for packet_id, edge, packet_key in candidates: + from_uri = resolve_uri(edge.get("from_uri")) or resolve_identity( + edge.get("from_identity") + ) + relation = edge.get("relation", edge.get("field")) + relation_name = relation.strip() if isinstance(relation, str) else "" + if from_uri is None or not relation_name: + unresolved.append( + { + "packet_id": packet_id, + "packet_key": packet_key, + "from_uri": from_uri or edge.get("from_uri", ""), + "relation": relation_name, + "to_uri": edge.get("to_uri", ""), + "to_identity": edge.get("to_identity", {}), + "status": str(edge.get("status", "needs_predecessor")), + "reason": "source_or_target_not_materialized_or_relation_missing", + }, + ) + continue + from_info = known_info_by_uri.get(from_uri) or self.store.lookup_by_uri(from_uri) + if from_info is None or relation_name not in set( + self._relation_field_names(from_info[0]) + ): + unresolved.append( + { + "packet_id": packet_id, + "packet_key": packet_key, + "from_uri": from_uri, + "relation": relation_name, + "to_uri": edge.get("to_uri", ""), + "status": "invalid_relation_field", + "reason": "relation_not_declared_by_source_concept_schema", + }, + ) + continue + if scope and from_uri not in scope: + continue + if is_optional_relation_field(from_info[0], relation_name): + deferred_optional_count += 1 + continue + target_uri = resolve_uri(edge.get("to_uri")) or resolve_identity( + edge.get("to_identity") + ) + if target_uri is None: + unresolved.append( + { + "packet_id": packet_id, + "packet_key": packet_key, + "from_uri": from_uri, + "relation": relation_name, + "to_uri": edge.get("to_uri", ""), + "to_identity": edge.get("to_identity", {}), + "status": str(edge.get("status", "needs_predecessor")), + "reason": "source_or_target_not_materialized_or_relation_missing", + }, + ) + continue + updates.setdefault(from_uri, {}).setdefault(relation_name, set()).add(target_uri) + target_info = known_info_by_uri.get(target_uri) or self.store.lookup_by_uri(target_uri) + if target_info is not None and target_info[0] == "Component": + touched_components.add(target_uri) + linked_edge_count += 1 + + patches: list[dict[str, object]] = [] + skipped: list[str] = [] + update_items = sorted(updates.items()) + + def read_update( + item: tuple[str, dict[str, set[str]]], + ) -> tuple[str, dict[str, set[str]], str | None]: + uri, fields = item + return uri, fields, self.store.read_entity_by_uri(uri) + + with ThreadPoolExecutor( + max_workers=min(_io_worker_limit(), max(1, len(update_items))) + ) as pool: + update_records = list(pool.map(read_update, update_items)) + for from_uri, fields, content in update_records: + info = known_info_by_uri.get(from_uri) or self.store.lookup_by_uri(from_uri) + if info is None or content is None: + continue + concept, class_name, object_name = info + frontmatter = parse_frontmatter(content) + operations: list[dict[str, object]] = [] + for field, targets in sorted(fields.items()): + current = frontmatter.get(field) + if isinstance(current, list): + existing = [item for item in current if isinstance(item, str) and item.strip()] + merged = list(dict.fromkeys([*existing, *sorted(targets)])) + if merged != existing: + operations.append({"op": "fm_set_list", "field": field, "values": merged}) + elif isinstance(current, str) and current.strip(): + if current.strip() not in targets: + unresolved.append( + { + "from_uri": from_uri, + "relation": field, + "to_uri": sorted(targets), + "status": "cardinality_conflict", + "reason": "scalar_relation_already_has_a_different_target", + }, + ) + elif self._relation_field_shape(concept, field) == "ref": + target = sorted(targets)[0] + if len(targets) > 1: + unresolved.append( + { + "from_uri": from_uri, + "relation": field, + "to_uri": sorted(targets), + "status": "cardinality_conflict", + "reason": "schema_declares_scalar_relation_but_candidates_are_multiple", + }, + ) + else: + operations.append({"op": "fm_set", "field": field, "value": target}) + else: + operations.append({ + "op": "fm_set_list", + "field": field, + "values": sorted(targets), + }) + if not operations: + skipped.append(from_uri) + continue + current_hash = sha256(content.encode("utf-8")).hexdigest() + patches.append( + { + "concept": concept, + "class_name": class_name or "", + "object_name": object_name, + "operations": operations, + "expected_sha256": current_hash, + }, + ) + + preloaded = { + uri: content for uri, _fields, content in update_records if content is not None + } + patched: list[str] = [] + batch_limit = _entity_batch_limit() + for start in range(0, len(patches), batch_limit): + result = self.patch_entities_batch( + patches[start : start + batch_limit], + sync_component_links=sync_component_links, + preloaded_contents=preloaded, + ) + patched.extend(str(uri) for uri in result.get("uris", []) if isinstance(uri, str)) + + if sync_component_links: + for component_uri in sorted(touched_components): + self._sync_component_narrative_links(component_uri, force=True) + device_diagnostic_page_count = ( + self._sync_device_diagnostic_links() if sync_component_links else 0 + ) + self._record_phase_timing("relation", relation_started) + # Advance checkpoint so restart can distinguish "relations done" from + # "merely materialized"; relation_closure is idempotent and in the + # ready-set, so re-calls are safe. + self.checkpoint_build( + doc_id=str(checkpoint.get("doc_id", "")), + device_id=str(checkpoint.get("device_id", "")), + series_id=str(checkpoint.get("series_id", "")), + stage="relation_closure", + build_id=str(checkpoint.get("build_id", "")), + input_hash=str(checkpoint.get("input_hash", "")), + config_hash=str(checkpoint.get("config_hash", "")), + source_snapshot_id=str(checkpoint.get("source_snapshot_id", "")), + snapshot_id=str(checkpoint.get("snapshot_id", "")), + input_docs=tuple(checkpoint.get("input_docs", ()) or ()), + schema_version=str(checkpoint.get("schema_version", "")), + audit_profile=self._validate_audit_profile( + str(checkpoint.get("audit_profile", "manual")), + ), + ) + return { + "candidate_count": len(candidates), + "linked_edge_count": linked_edge_count, + "patched_entity_count": len(patched), + "skipped_entity_count": len(skipped), + "deferred_optional_count": deferred_optional_count, + "relation_scope_complete": relation_scope_complete, + "manifest_packet_count": len(manifest_packet_ids), + "selected_packet_count": len(selected_packets), + "device_diagnostic_page_count": device_diagnostic_page_count, + "patched_entity_uris": patched, + "unresolved": unresolved, + "idempotent": True, + } diff --git a/src/wolfharness/capabilities/wiki/quality.py b/src/wolfharness/capabilities/wiki/quality.py new file mode 100644 index 000000000..ef80b7ce8 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/quality.py @@ -0,0 +1,793 @@ +"""Deterministic diagnostic-closure checks for wiki entities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +import os +import re +from typing import Literal, NotRequired, TypedDict, get_args + +import yaml + +from wolfharness.capabilities.wiki.namespaces import resources_root +from wolfharness.capabilities.wiki.schema_loader import get_concept_schema +from wolfharness.capabilities.wiki.section_constants import ( + PLACEHOLDER_TEXT_RE, + SECTION_FAILURE_MECHANISM, + SECTION_IMPACT_SCOPE, + SECTION_MECHANISM, + SECTION_POSSIBLE_FAILURE, + SECTION_REPAIR_METHOD, + SECTION_VERIFICATION, +) + + +_SECTION_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE) +_WIKI_CONCEPTS = frozenset({"Device", "Component", "DTC", "Symptom", "Fault", "Procedure", "OP"}) + + +def _configured_root(env_name: str) -> str: + """Return an env-configured root or a namespace that can never be persisted.""" + namespace = os.environ.get(env_name, "").strip() + return resources_root(namespace) if namespace else "viking://unconfigured" + + +# Store creation replaces both roots before any entity validation. Keeping the +# pre-store boundary namespace-agnostic avoids embedding a deployment target in +# library code while still allowing modules to import before runtime config loads. +_wiki_root_uri = _configured_root("VIKING_NAMESPACE") +_raw_source_root_uri = _configured_root("VIKING_RAW_NAMESPACE") + +# (concept or "_Profile") → {frontmatter_field: (body_section_heading, target_concept)} +_BODY_LINK_MAP: dict[str, dict[str, tuple[str, str]]] = { + "Fault": { + "affected_components": (SECTION_IMPACT_SCOPE, "Component"), + "verification_procedures": (SECTION_VERIFICATION, "Procedure"), + "repair_procedures": (SECTION_REPAIR_METHOD, "Procedure"), + }, + "DTC": { + "related_faults": (SECTION_POSSIBLE_FAILURE, "Fault"), + }, + "_Profile": { + "possible_faults": (SECTION_POSSIBLE_FAILURE, "Fault"), + }, + "Procedure": { + "target_components": ("操作目的", "Component"), + }, + "Device": { + "critical_components": ("关重件清单", "Component"), + }, +} + + +def _is_profile(content: str) -> bool: + """Detect Symptom Profile by presence of ``profile_id`` in frontmatter.""" + fm = parse_frontmatter(content) + return "profile_id" in fm + + +_WIKI_URI_RE: re.Pattern[str] +_MALFORMED_WIKI_URI_RE: re.Pattern[str] + + +def _compile_wiki_regexes(root_uri: str) -> None: + """Recompile URI-matching regexes against a root URI prefix.""" + global _WIKI_URI_RE, _MALFORMED_WIKI_URI_RE # noqa: PLW0603 - module singletons reconfigured at runtime + esc = re.escape(root_uri.rstrip("/")) + _WIKI_URI_RE = re.compile( + rf"{esc}/(?:Device|Component|DTC|Symptom|Fault|Procedure|OP)" + r"/(?:[0-9a-f]{24}|[^/#\s<>]+(?:/[^/#\s<>]+)*)" + r"(?:#[^\s<>]+)?", + ) + _MALFORMED_WIKI_URI_RE = re.compile( + rf"{esc}/(?:Device|Component|DTC|Symptom|Fault|Procedure|OP)/" + r"(?![0-9a-f]{24}(?:#|$))" + r"([^/#\s\)\]\"'\u3000\u3001\u3002]*)" + r"(?=$|[<{\)\]\s,;:!?。,;:!?])", + ) + + +def set_wiki_root_uri(uri: str) -> None: + """Set the wiki root URI prefix used by all URI checks in this module.""" + global _wiki_root_uri # noqa: PLW0603 - module config setter + _wiki_root_uri = uri.rstrip("/") + _compile_wiki_regexes(_wiki_root_uri) + + +def is_wiki_uri(uri: str) -> bool: + return uri.startswith(_wiki_root_uri + "/") + + +def wiki_uri_prefix() -> str: + return _wiki_root_uri + + +def set_raw_source_root_uri(uri: str) -> None: + """Set the active raw-source root used by write-time validators.""" + global _raw_source_root_uri # noqa: PLW0603 - module config setter + _raw_source_root_uri = uri.rstrip("/") + + +# Initial regexes for the default prefix; recompiled by set_wiki_root_uri() +# when a store is created (viking backend → viking://resources/<namespace>). +_compile_wiki_regexes(_wiki_root_uri) +# Any RFC-3986 scheme: viking://, file://, kb://, http://, etc. +# Agents fetch source content from arbitrary MCP providers; the scheme is +# an opaque provenance identifier, not a hardcoded file-backend selector. +_URI_START_RE = re.compile(r"[a-z][a-z0-9+.\-]*://") +_URI_TRAILING_PUNCTUATION = ".,;:!?,。;:!?、" + +# Raw manual chapter URIs. Addressable backend paths +# ``viking://.../chapters/<subdir>/chapter.md``, which may contain CJK +# characters and spaces (``01_1 前言``). +_RAW_CHAPTER_URI_RE = re.compile( + r"viking://[^\n)\]>]+?/chapters/[^\n)\]>]*?chapter\.md", +) + +_RAW_CHAPTER_PREFIXES: tuple[str, ...] = () +_RAW_CHAPTER_URIS: frozenset[str] = frozenset() + +_CROSS_NAMESPACE_RAW_URI_RE = re.compile( + r"^viking://resources/[^/]+/raw/(?P<relative>.+\.md)$", +) + +_CROSS_NAMESPACE_BOM_URI_RE = re.compile( + r"^viking://resources/[^/]+/bom/(?P<relative>.+\.md)$", +) + +_EXTERNAL_URI_RE = re.compile(r"^(?!viking://|file://)[a-z][a-z0-9+.\-]*://") + +_SOURCE_URI_PREFIX_RE = re.compile(r"^[a-z][a-z0-9+.\-]*://") + + +def is_source_uri_scheme(value: str) -> bool: + """Fast check for any ``scheme://`` URI prefix (viking, file, kb, http, …).""" + return bool(_SOURCE_URI_PREFIX_RE.match(value)) + + +BuildProfile = Literal["manual", "case"] +BUILD_PROFILES = frozenset(get_args(BuildProfile)) + + +class RawSourceKind(StrEnum): + """Supported provenance resource families.""" + + MANUAL_CHAPTER = "manual_chapter" + CASE = "case" + EXTERNAL = "external" + + +class SourceReadStatus(StrEnum): + """Result of resolving a provenance URI through its owning backend.""" + + OK = "ok" + NOT_FOUND = "not_found" + UNAVAILABLE = "unavailable" + INVALID_URI = "invalid_uri" + + +class IssueDisposition(StrEnum): + """Workflow required to handle one deterministic audit finding.""" + + REPAIR_ONLY = "repair_only" + GAP = "gap" + CONFLICT = "conflict" + + +@dataclass(frozen=True, slots=True) +class SourceReadResult: + """Typed result for a raw-source read and content fingerprint.""" + + uri: str + kind: RawSourceKind | None + status: SourceReadStatus + content: str | None = None + content_hash: str | None = None + error_code: str = "" + + +def classify_raw_source_uri( + uri: str, + *, + raw_root_uri: str | None = None, +) -> RawSourceKind | None: + """Classify a provenance URI without binding it to one namespace. + + The active raw root is supplied by the caller, so local and remote builds + share the same contract. A chapter leaf is a manual source; any other + Markdown leaf under the active raw root is a single-file case source. + Directory URIs accept the shape of their conventional ``chapter.md`` + leaf. Cross-namespace raw libraries are recognized by their public + resource shape rather than a configured namespace or collection + identifier. + """ + if not isinstance(uri, str): + return None + value = uri.strip() + if not value: + return None + # Workers may pass directory URIs (no ``.md`` leaf) or fragment-anchored + # chapter URIs; normalize both before matching. + value = value.partition("#")[0].rstrip("/") + if value in _RAW_CHAPTER_URIS: + return RawSourceKind.MANUAL_CHAPTER + # A directory URI maps to its conventional leaf, mirroring the + # ``chapters/<subdir>/chapter.md`` layout without a backend read. + leaf = value if value.endswith(".md") else f"{value}/chapter.md" + cross_namespace = _CROSS_NAMESPACE_RAW_URI_RE.fullmatch(leaf) + if cross_namespace is not None: + relative = cross_namespace.group("relative") + if "/chapters/" in f"/{relative}" and relative.endswith("/chapter.md"): + return RawSourceKind.MANUAL_CHAPTER + return RawSourceKind.CASE + if _CROSS_NAMESPACE_BOM_URI_RE.fullmatch(leaf) is not None: + return RawSourceKind.CASE + root = (raw_root_uri if raw_root_uri is not None else _raw_source_root_uri).rstrip("/") + if not root or not value.startswith(root + "/"): + if _EXTERNAL_URI_RE.match(value): + return RawSourceKind.EXTERNAL + return None + relative = value.removeprefix(root + "/") + if not relative: + if _EXTERNAL_URI_RE.match(value): + return RawSourceKind.EXTERNAL + return None + if "/chapters/" in f"/{relative}" and relative.endswith("/chapter.md"): + return RawSourceKind.MANUAL_CHAPTER + # A non-``.md`` path under the raw root is a directory source; classify by + # shape — the caller verifies existence downstream. + return RawSourceKind.CASE + + +def is_raw_chapter_uri(uri: str) -> bool: + """Return ``True`` for any raw manual chapter URI (real backend path). + + Real backend paths match by their ``/chapters/…/chapter.md`` shape, which + is distinct from wiki entity URIs (``…/Device/…``) and OPA resource URIs + (``…/OP/…``). + """ + if not isinstance(uri, str): + return False + return ( + uri in _RAW_CHAPTER_URIS + or uri.startswith(_RAW_CHAPTER_PREFIXES) + or ("/chapters/" in uri and uri.endswith("chapter.md")) + ) + + +def is_external_source_uri(uri: str) -> bool: + """Return ``True`` for an external MCP source URI (non-viking/non-file scheme).""" + return classify_raw_source_uri(uri) is RawSourceKind.EXTERNAL + + +_BOM_SOURCE_URI_RE = re.compile(r"^viking://resources/[^/]+/bom/(?P<relative>.+\.md)$") + + +def is_bom_source_uri(uri: str) -> bool: + """Return ``True`` for a cross-namespace BOM library URI. + + The global BOM library (e.g. ``viking://resources/730/bom/component/…``) is + a name-shape sibling of the raw chapter library (``…/raw/…``) but lives + under ``/bom/``, so it is neither a raw-chapter URI nor an external one. + Write-time validators must accept it as real provenance evidence for + BOM-driven Component identity. + """ + if not isinstance(uri, str): + return False + return _BOM_SOURCE_URI_RE.fullmatch(uri.strip()) is not None + + +def register_raw_chapter_uris(uris: list[str]) -> None: + """Register exact manual leaves discovered from the active raw backend. + + Parsed PDF libraries may store chapter Markdown directly in a nested TOC + tree instead of the legacy ``chapters/.../chapter.md`` layout. Exact URI + registration keeps manual/case classification evidence-based without + encoding tenant, model, or directory names in validation logic. + """ + global _RAW_CHAPTER_URIS # noqa: PLW0603 - process-wide validation registry + normalized = frozenset(uri.strip() for uri in uris if uri.strip()) + _RAW_CHAPTER_URIS = _RAW_CHAPTER_URIS.union(normalized) + + +def is_case_source_uri(uri: str, *, raw_root_uri: str | None = None) -> bool: + """Return whether *uri* identifies a supported single-file case source.""" + return classify_raw_source_uri(uri, raw_root_uri=raw_root_uri) is RawSourceKind.CASE + + +def is_raw_source_uri(uri: str, *, raw_root_uri: str | None = None) -> bool: + """Return whether *uri* belongs to any supported provenance family.""" + return classify_raw_source_uri(uri, raw_root_uri=raw_root_uri) is not None + + +_UNRESOLVED_PLACEHOLDER_RE = re.compile( + r"(?:" + r"\b(?:TODO|TBD|FIXME|XXX)\b" + r"|\{\{[^}]+\}\}" + r"|<待[^>]*>" + r"|<TODO[^>]*>" + r")", + re.IGNORECASE, +) + + +class QualityIssue(TypedDict): + """One actionable corpus quality issue.""" + + uri: str + concept: str + code: str + severity: str + message: str + disposition: NotRequired[str] + opa_reason_code: NotRequired[str] + target_uri: NotRequired[str] + gap_category: NotRequired[str] + repair_action: NotRequired[str] + + +class CoverageSummary(TypedDict): + """Completion counters for one relationship rule.""" + + eligible: int + complete: int + percent: float + + +class WikiAuditReport(TypedDict): + """Serializable result returned by the ``audit_wiki`` build tool.""" + + passed: bool + profile: str + entity_count: int + confirmed_count: int + draft_count: int + deprecated_count: int + error_count: int + warning_count: int + issue_counts: dict[str, int] + filtered_issue_count: int + returned_issue_count: int + next_offset: int + snapshot_id: str + source_snapshot_id: NotRequired[str] + relation_coverage: dict[str, CoverageSummary] + confirmed_candidates: list[str] + issues: list[QualityIssue] + + +@dataclass(frozen=True, slots=True) +class RequirementCheck: + """One structured or narrative relationship requirement.""" + + code: str + complete: bool + message: str + disposition: IssueDisposition = IssueDisposition.GAP + opa_reason_code: str = "content_missing" + # Concepts the requirement links to (e.g. ``("Symptom",)`` for + # ``Fault.symptom.body_link``). When any target concept is already + # materialized in the library, a failing check means "build the missing + # link" (repair), not "content is missing from source" (permanent gap). + target_concepts: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class AuditIssuePolicy: + """Workflow policy attached to a stable audit issue code.""" + + disposition: IssueDisposition + opa_reason_code: str = "" + + +_REPAIR_ONLY = AuditIssuePolicy(IssueDisposition.REPAIR_ONLY) +_REFERENCE_GAP = AuditIssuePolicy(IssueDisposition.GAP, "content_missing") +_SOURCE_GAP = AuditIssuePolicy(IssueDisposition.GAP, "content_missing") +_CONTENT_GAP = AuditIssuePolicy(IssueDisposition.GAP, "content_missing") +_FACT_CONFLICT = AuditIssuePolicy(IssueDisposition.CONFLICT, "fact_conflict") + +AUDIT_ISSUE_POLICIES: dict[str, AuditIssuePolicy] = { + "entity_target_missing": _REPAIR_ONLY, + "unconfirmed_entity": _REPAIR_ONLY, + "dangling_relation_target": _REFERENCE_GAP, + "typed_relation_wrong_concept": _FACT_CONFLICT, + "dangling_wiki_reference": _REFERENCE_GAP, + "Procedure.specification_ref_unresolvable": _REFERENCE_GAP, + "Profile.direct_component_not_in_device_bom": _FACT_CONFLICT, + "Profile.parent_symptom_not_indexed_by_device": _REFERENCE_GAP, + "source_unresolvable": _SOURCE_GAP, + "unresolved_placeholder": _REPAIR_ONLY, + "dangling_reference": _REFERENCE_GAP, + "malformed_uri": _REPAIR_ONLY, + "Component.working_mechanism": _CONTENT_GAP, + "Fault.failure_mechanism": _CONTENT_GAP, + "empty_wiki": _REPAIR_ONLY, +} + + +def audit_issue_policy(code: str) -> AuditIssuePolicy | None: + """Return the explicit workflow policy for one audit issue code. + + Validation hooks are deterministic write-format checks and therefore + share one namespace-level policy. All other codes must be registered + explicitly or supplied by the producer (for example a + :class:`RequirementCheck`). Unknown codes deliberately return ``None`` + so discovery fails closed instead of inventing a gap category. + """ + if code.startswith("hook."): + return _REPAIR_ONLY + return AUDIT_ISSUE_POLICIES.get(code) + + +def parse_frontmatter(content: str) -> dict[str, object]: + """Parse top-level YAML frontmatter without accepting scalar roots.""" + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return {} + end = next( + (index for index, line in enumerate(lines[1:], 1) if line.strip() == "---"), + None, + ) + if end is None: + return {} + try: + loaded = yaml.safe_load("\n".join(lines[1:end])) + except yaml.YAMLError: + return {} + if not isinstance(loaded, dict): + return {} + return {key: value for key, value in loaded.items() if isinstance(key, str)} + + +def extract_sections(content: str) -> dict[str, str]: + """Return ``##`` sections after stripping YAML frontmatter.""" + body = content + lines = content.splitlines() + if lines and lines[0].strip() == "---": + end = next( + (index for index, line in enumerate(lines[1:], 1) if line.strip() == "---"), + None, + ) + if end is not None: + body = "\n".join(lines[end + 1 :]) + + matches = list(_SECTION_RE.finditer(body)) + return { + match.group(1).strip(): body[ + match.end() : matches[index + 1].start() if index + 1 < len(matches) else len(body) + ].strip() + for index, match in enumerate(matches) + } + + +def has_usable_procedure_criteria(section: str) -> bool: + """Return whether a Procedure criteria section contains a real assertion. + + A page-level raw citation or text such as ``见来源`` is provenance, not a + pass/fail standard. The function intentionally does not require a + numeric value: qualitative criteria such as ``无泄漏`` are valid too. + """ + return _section_has_substance(section, min_chars=0) + + +# Placeholder phrase detector (pattern centralized in section_constants). +_PLACEHOLDER_TEXT_RE = re.compile(PLACEHOLDER_TEXT_RE) + + +def _section_has_substance(section: str, *, min_chars: int = 10) -> bool: + """Return whether a body section has substantive explanatory content. + + Strips URIs and punctuation, rejects pure placeholder phrases, and + optionally enforces a minimum character count on the remainder. + """ + without_uris = re.sub(r"[a-z][a-z0-9+.\-]*://[^\s)\]>]+", "", section) + normalized = re.sub(r"[\s\[\]()()<>::,,。;;、|#*_\-]+", "", without_uris) + if not normalized: + return False + if min_chars and len(normalized) < min_chars: + return False + return not _PLACEHOLDER_TEXT_RE.match(normalized) + + +def extract_wiki_uris(content: str) -> set[str]: + """Extract complete wiki URIs, including readable paths with parentheses. + + Markdown's closing ``)`` is treated as a delimiter only when it is not + balancing an opening ASCII parenthesis inside the URI. This avoids the + historical URI truncation that turned a valid path such as ``主泵(变量)`` + into a dangling prefix. + """ + result: set[str] = set() + for uri in extract_source_uris(content): + remainder = uri.removeprefix(_wiki_root_uri + "/") + path, _, fragment = remainder.partition("#") + parts = path.split("/") + if len(parts) < 2 or not all(parts) or parts[0] not in _WIKI_CONCEPTS: + continue + if fragment and any(character.isspace() for character in fragment): + continue + result.add(uri) + return result + + +def all_relation_uris(content: str, concept: str, field: str, root_uri: str = "") -> set[str]: + """Get URIs for a relation field from frontmatter AND body section. + + Checks frontmatter first; also extracts ``viking://`` URIs from the + corresponding body section per ``_BODY_LINK_MAP``. Returns the + combined set (fragment-anchors stripped). + """ + uris: set[str] = set() + frontmatter = parse_frontmatter(content) + + # From frontmatter + fm_value = frontmatter.get(field) + prefix = root_uri.rstrip("/") + "/" if root_uri else "" + if isinstance(fm_value, str) and fm_value.strip(): + if not prefix or fm_value.startswith(prefix): + uris.add(fm_value.split("#", 1)[0]) + elif isinstance(fm_value, list): + for item in fm_value: + if isinstance(item, str) and item.strip() and (not prefix or item.startswith(prefix)): + uris.add(item.split("#", 1)[0]) + + # From body section (fallback for fields in _BODY_LINK_MAP) + body_key = "_Profile" if _is_profile(content) else concept + field_info = _BODY_LINK_MAP.get(body_key, {}).get(field) + if field_info: + heading, _ = field_info + sections = extract_sections(content) + section_text = sections.get(heading, "") + uris.update(u.split("#", 1)[0] for u in extract_wiki_uris(section_text)) + + return uris + + +def extract_malformed_wiki_uris(content: str) -> list[str]: + """Return class-only / placeholder ``{root_uri}/Concept/`` tails. + + Empty tail (missing Object segment) or placeholder templates + (``<...>`` / ``{...}``) are malformed; readable two-segment URIs pass. + """ + malformed: list[str] = [] + for uri in extract_source_uris(content): + remainder = uri.removeprefix(_wiki_root_uri + "/") + path = remainder.partition("#")[0] + parts = path.split("/") + if not parts or parts[0] not in _WIKI_CONCEPTS: + continue + if ( + len(parts) < 2 + or not all(parts[1:]) + or any(part.startswith(("<", "{")) for part in parts[1:]) + # ``open_gap: ...`` is body prose, not a navigable entity URI. + # Treating it as a two-segment URI creates dangling references + # that cannot be resolved or repaired deterministically. + or any(part.startswith("open_gap:") for part in parts[1:]) + ): + malformed.append("/".join(parts[1:])) + return malformed + + +def extract_source_uris(content: str) -> set[str]: + """Extract complete source URIs with Markdown-aware delimiters. + + Recognizes ``viking://`` and ``file://`` tokens. Readable entity URIs may + contain spaces in their object name. A space is still a delimiter in + prose, but it is part of a Markdown link destination + (``[label](viking://...)``) or a quoted YAML scalar. The old lexer + truncated those destinations at the first space and produced false + dangling links. + """ + result: set[str] = set() + # Raw chapter URIs may contain spaces (CJK names like "01_1 前言"). + # _RAW_CHAPTER_URI_RE handles these; match first so the space-delimited + # lexer below skips the overlapping region instead of truncating at the space. + chapter_spans: list[tuple[int, int]] = [] + for m in _RAW_CHAPTER_URI_RE.finditer(content): + result.add(m.group()) + chapter_spans.append((m.start(), m.end())) + for start_match in _URI_START_RE.finditer(content): + if any(s <= start_match.start() < e for s, e in chapter_spans): + continue + index = start_match.start() + cursor = start_match.end() + parentheses = 0 + preceding = content[index - 1] if index else "" + markdown_destination = preceding == "(" + quoted_scalar = preceding if preceding in {'"', "'"} else "" + while cursor < len(content): + character = content[cursor] + if quoted_scalar: + if character == quoted_scalar or character in "\r\n": + break + cursor += 1 + continue + if markdown_destination: + if character == "(": + parentheses += 1 + elif character == ")": + if parentheses == 0: + break + parentheses -= 1 + cursor += 1 + continue + if character.isspace() or character in "\"'<>[]": + break + if character == "(": + parentheses += 1 + elif character == ")": + if parentheses == 0: + break + parentheses -= 1 + cursor += 1 + token = content[index:cursor].rstrip(_URI_TRAILING_PUNCTUATION) + if token: + result.add(token) + return result + + +def has_unresolved_placeholder(content: str) -> bool: + """Return whether agent TODO language remains in formal content.""" + return _UNRESOLVED_PLACEHOLDER_RE.search(content) is not None + + +def entity_status(content: str) -> str: + """Return the normalized frontmatter status.""" + status = parse_frontmatter(content).get("status", "") + return status.strip() if isinstance(status, str) else "" + + +def force_confirmed_status(content: str) -> str: + """Return a validation-only copy whose status is ``confirmed``.""" + if re.search(r"^status\s*:", content, re.MULTILINE): + return re.sub( + r"^status\s*:.*$", + "status: confirmed", + content, + count=1, + flags=re.MULTILINE, + ) + if content.startswith("---\n"): + return content.replace("---\n", "---\nstatus: confirmed\n", 1) + return content + + +def confirmation_requirements( + content: str, + concept: str, + class_name: str = "", +) -> tuple[RequirementCheck, ...]: + """Evaluate graph edges required for end-to-end diagnostic traversal.""" + frontmatter = parse_frontmatter(content) + sections = extract_sections(content) + checks: list[RequirementCheck] = [] + + def field_present( + code: str, field: str, message: str, *, target_concepts: tuple[str, ...] = () + ) -> None: + fm_present = _has_value(frontmatter.get(field)) or bool( + all_relation_uris(content, concept, field) + ) + checks.append( + RequirementCheck( + code=code, + complete=fm_present, + message=message, + disposition=IssueDisposition.GAP, + opa_reason_code="content_missing", + target_concepts=target_concepts, + ), + ) + + if _has_value(frontmatter.get("profile_id")): + return tuple(checks) + + if concept == "Device": + field_present( + "Device.critical_components", + "critical_components", + "设备必须引用其关重件清单。", + target_concepts=("Component",), + ) + elif concept == "Component": + # Component associations are commonly owned by Device, Fault and + # Procedure pages. They are validated corpus-wide through + # backlinks/graph isolation rather than by forcing empty lists onto + # every Component page. + # Working mechanism is the terminal node of the diagnostic chain: + # Symptom → Fault → Component → 工作机理. Without substantive + # content here the chain dead-ends and the OPA is actionable. + working = sections.get(SECTION_MECHANISM, "") + checks.append( + RequirementCheck( + code="Component.working_mechanism", + complete=_section_has_substance(working), + message="部件必须包含工作机理章节的实质性内容,描述其工作原理。", + disposition=IssueDisposition.GAP, + opa_reason_code="content_missing", + ), + ) + elif concept == "DTC": + field_present( + "DTC.related_faults", + "related_faults", + "DTC 必须至少引用一个故障实体。", + target_concepts=("Fault",), + ) + elif concept == "Fault": + # Failure mechanism is the core diagnostic content: without it + # the page is a label, not an explanation of how the part fails. + failure_mechanism = sections.get(SECTION_FAILURE_MECHANISM, "") + checks.append( + RequirementCheck( + code="Fault.failure_mechanism", + complete=_section_has_substance(failure_mechanism), + message="故障必须包含失效机理章节的实质性内容,描述部件以何种方式失效。", + disposition=IssueDisposition.GAP, + opa_reason_code="content_missing", + ), + ) + + return tuple(checks) + + +def _has_value(value: object) -> bool: + if isinstance(value, str): + return bool(value.strip()) + if isinstance(value, (list, dict, tuple, set)): + return bool(value) + return value is not None and value is not False + + +def _has_concept_uri(content: str, concept: str) -> bool: + return ( + re.search( + rf"{re.escape(_wiki_root_uri)}/{re.escape(concept)}/[^/\s()<>#]+", + content, + ) + is not None + ) + + +def is_optional_relation_field(concept: str, field: str) -> bool: + """True when a schema relation field has no ``required: true`` marker. + + Unknown concept or field defaults to False (treated as required) so + callers never defer relation work on schema shapes they cannot see. + """ + try: + schema = get_concept_schema(concept) + except KeyError: + return False + frontmatter = schema.get("frontmatter") + if not isinstance(frontmatter, list): + return False + for item in frontmatter: + if isinstance(item, dict) and item.get("name") == field: + return not bool(item.get("required")) + return False + + +def is_optional_relation_issue(code: str, concept: str) -> bool: + """True when a relationship_completeness audit flags only optional relations. + + Under the backbone policy only ``required: true`` schema relation + fields are publication-critical; when a concept declares none, the + issue is deferred instead of opening an OPA. + """ + if code != "relationship_completeness": + return False + try: + schema = get_concept_schema(concept) + except KeyError: + return False + frontmatter = schema.get("frontmatter") + if not isinstance(frontmatter, list): + return False + return not any( + item.get("type") in {"ref", "ref_list"} and item.get("required") + for item in frontmatter + if isinstance(item, dict) + ) diff --git a/src/wolfharness/capabilities/wiki/schema_loader.py b/src/wolfharness/capabilities/wiki/schema_loader.py new file mode 100644 index 000000000..3c9befa19 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/schema_loader.py @@ -0,0 +1,154 @@ +"""Cached loader for the wiki entity schema YAML. + +Both validation hooks (``frontmatter_schema.py``, ``body_sections.py``) and +the ``get_schema`` build tool share ``templates/default_schema.yaml`` as the +single source of truth. This module loads it once at first access and +exposes typed accessors so hooks never hardcode field/section lists that +can drift from the YAML. + +If the YAML is missing or unreadable, every accessor returns an empty +result so the hooks fall through to their built-in fallbacks. +""" + +from __future__ import annotations + +import copy +from functools import lru_cache +from pathlib import Path +from typing import Any + +import yaml + + +_SCHEMA_PATH = Path(__file__).resolve().parent / "templates" / "default_schema.yaml" + + +@lru_cache(maxsize=1) +def _load_raw() -> dict[str, Any]: + """Load and cache the raw YAML schema dict. + + Returns an empty dict when the file is missing so callers can + degrade gracefully. + """ + if not _SCHEMA_PATH.exists(): + return {} + try: + data = yaml.safe_load(_SCHEMA_PATH.read_text(encoding="utf-8")) + except yaml.YAMLError: + return {} + return data if isinstance(data, dict) else {} + + +def _concepts() -> dict[str, Any]: + """Return the ``concepts:`` mapping from the schema YAML.""" + raw = _load_raw() + concepts = raw.get("concepts") + return concepts if isinstance(concepts, dict) else {} + + +def get_schema_version() -> str: + """Return the materialization schema version as a stable string.""" + version = _load_raw().get("version") + if isinstance(version, (str, int)) and str(version).strip(): + return str(version).strip() + return "unknown" + + +def get_concept_schema(concept: str) -> dict[str, Any]: + """Return one concept's authoritative materialization schema. + + The returned mapping is detached from the process cache so callers may + serialize or annotate it without mutating validation state. + """ + entry = _concepts().get(concept) + if not isinstance(entry, dict): + supported = ", ".join(sorted(_concepts())) + raise KeyError(f"Unknown concept {concept!r}; supported concepts: {supported}") + return copy.deepcopy(entry) + + +def _shared_frontmatter_fields() -> frozenset[str]: + """Return orthogonal build/review fields shared by every page type.""" + shared = _load_raw().get("shared_frontmatter") + if not isinstance(shared, list): + return frozenset() + return frozenset( + item["name"] + for item in shared + if isinstance(item, dict) and isinstance(item.get("name"), str) + ) + + +def get_concept_frontmatter_fields(concept: str) -> frozenset[str]: + """Return the set of allowed frontmatter field names for *concept*. + + Returns an empty set when the concept or its ``frontmatter`` key is + absent from the YAML. + """ + entry = _concepts().get(concept) + if not isinstance(entry, dict): + return frozenset() + fm = entry.get("frontmatter") + if not isinstance(fm, list): + return frozenset() + fields = frozenset(item["name"] for item in fm if isinstance(item, dict) and "name" in item) + return fields | _shared_frontmatter_fields() + + +def get_all_concept_frontmatter_fields() -> dict[str, frozenset[str]]: + """Return ``{concept: frozenset(field_names)}`` for all concepts.""" + return {name: get_concept_frontmatter_fields(name) for name in _concepts()} + + +def get_profile_frontmatter_fields() -> frozenset[str]: + """Return frontmatter fields for Symptom Profile sub-resources. + + Looks up ``concepts.Symptom.sub_resources.profiles.frontmatter``. + """ + symptom = _concepts().get("Symptom") + if not isinstance(symptom, dict): + return frozenset() + sub = symptom.get("sub_resources") + if not isinstance(sub, dict): + return frozenset() + profiles = sub.get("profiles") + if not isinstance(profiles, dict): + return frozenset() + fm = profiles.get("frontmatter") + if not isinstance(fm, list): + return frozenset() + fields = frozenset(item["name"] for item in fm if isinstance(item, dict) and "name" in item) + return fields | _shared_frontmatter_fields() + + +def get_concept_body_sections(concept: str) -> frozenset[str]: + """Return the set of required body section titles for *concept*.""" + entry = _concepts().get(concept) + if not isinstance(entry, dict): + return frozenset() + sections = entry.get("body_sections") + if not isinstance(sections, list): + return frozenset() + return frozenset(str(s) for s in sections) + + +def get_all_concept_body_sections() -> dict[str, frozenset[str]]: + """Return ``{concept: frozenset(section_titles)}`` for all concepts.""" + return {name: get_concept_body_sections(name) for name in _concepts()} + + +def get_profile_body_sections() -> frozenset[str]: + """Return body sections for Symptom Profile sub-resources.""" + symptom = _concepts().get("Symptom") + if not isinstance(symptom, dict): + return frozenset() + sub = symptom.get("sub_resources") + if not isinstance(sub, dict): + return frozenset() + profiles = sub.get("profiles") + if not isinstance(profiles, dict): + return frozenset() + sections = profiles.get("body_sections") + if not isinstance(sections, list): + return frozenset() + return frozenset(str(s) for s in sections) diff --git a/src/wolfharness/capabilities/wiki/section_constants.py b/src/wolfharness/capabilities/wiki/section_constants.py new file mode 100644 index 000000000..95d5a5904 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/section_constants.py @@ -0,0 +1,86 @@ +"""Centralized section name constants for validation hooks. + +Single source of truth for section headings used across the wiki build pipeline. +Currently populated from default_schema.yaml section definitions. +To adapt for a different manual/schema, update these constants. +""" + +import re + + +# --- Model detection patterns --- +# Generic pattern: uppercase letters followed by digits + optional alphanumeric suffix +# Covers SANY (SY/SE/SK), Komatsu (PC), Hitachi (ZX/EX), Caterpillar (CAT), +# Kobelco (SK), Doosan (DX), Volvo (EC), Hyundai (HD), JCB, Liebherr (R), etc. +MODEL_TOKEN_RE = re.compile(r"^[A-Z]{2,4}\d+[A-Z0-9-]*$", re.IGNORECASE) + +# Known model prefixes for stricter validation (extendable) +MODEL_PREFIXES: tuple[str, ...] = ( + "SY", + "SE", + "SK", + "PC", + "EX", + "ZX", + "CAT", + "HD", + "DX", + "EC", + "JCB", + "R", +) + +# Engine/controller codes that indicate config-specific content +CONFIG_SPECIFIC_PATTERNS: tuple[str, ...] = ( + "QSB", + "4M50", + "SC9D", + "CM2150", + "MCU", + "ECU", + "K3V", + "K5V", + "KMX", + "SAA", + "S6K", + "4TNV", + "J05", + "D1146", +) + +# --- Section heading constants (from default_schema.yaml) --- +SECTION_MECHANISM = "工作机理" +SECTION_OVERVIEW = "总成概览" +SECTION_SOURCE = "来源" +SECTION_FAILURE_MECHANISM = "失效机理" +SECTION_COMMON_FAULTS = "常见故障及故障机理" +SECTION_IMPACT_SCOPE = "影响范围" +SECTION_VERIFICATION = "验证方法" +SECTION_REPAIR_METHOD = "修复方式" +SECTION_ASSOCIATED_SYMPTOMS = "关联故障现象" +SECTION_POSSIBLE_FAILURE = "可能失效机理" +SECTION_DIAGNOSTIC_FLOW = "推荐诊断流程" +SECTION_DIAGNOSIS_FLOW = "诊断流程" +SECTION_CONTROLLER_IDENTITY = "控制器身份" +SECTION_SYSTEM_CHAPTERS = "系统章节引用" +SECTION_OPERATION_STEPS = "操作步骤" +SECTION_PREREQUISITES = "执行前提" +SECTION_REQUIRED_TOOLS = "所需工具" +SECTION_JUDGMENT_CRITERIA = "判定标准" +SECTION_COMMON_FAILURE_MODES = "常见失效模式" +SECTION_DISASSEMBLY_STEPS = "拆装步骤" + +# Admin sections (low-value chapters to skip) +ADMIN_SECTION_KEYWORDS: tuple[str, ...] = ( + "安全", + "前言", + "cover", + "intro", + "safety", + "foreword", + "preface", +) + +# Placeholder/gap text patterns (regex-escaped) +GAP_RE = r"open_gap|来源未说明|待补充|来源缺失|未物化|未提供|未确认|未说明|未知" +PLACEHOLDER_TEXT_RE = r"见来源|未提供|待补充|暂无|无资料|N/A|TODO|TBD" diff --git a/src/wolfharness/capabilities/wiki/storage/__init__.py b/src/wolfharness/capabilities/wiki/storage/__init__.py new file mode 100644 index 000000000..9407f3da8 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/storage/__init__.py @@ -0,0 +1,222 @@ +"""Storage backends for :class:`~wiki.storage.storage.WikiStore`. + +The same knowledge-graph store can live on a local directory (``LocalFS``, +read-only fallback for historical libs) or on a remote OpenViking instance +(``VikingFS``, the primary read/write path). The active backend is chosen +per-process from the environment: + +- ``WIKI_STORAGE_BACKEND``: ``viking`` (default) or ``local`` +- ``VIKING_BASE_URL``: OpenViking server root (default ``http://viking.ai.rootcloud.info/``) +- ``VIKING_API_KEY``: API key / JWT for the OpenViking server +- ``VIKING_NAMESPACE``: ``viking://resources/<namespace>`` root to store the wiki under + +``create_wiki_store`` is the single entry point used by ``WikiBuildTools``. +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +from openviking_sdk.errors import NotFoundError, OpenVikingError # type: ignore[import-untyped] +from wolfharness.capabilities.wiki.namespaces import raw_namespace, wiki_namespace + +from .backend import FSBackend, _strip_control_chars +from .dual_fs import DualFS +from .local_fs import LocalFS +from .local_viking_fs import LocalVikingFS +from .storage import WikiStore +from .viking_fs import VikingClient, VikingFS + +logger = logging.getLogger(__name__) + +__all__ = [ + "DualFS", + "FSBackend", + "LocalFS", + "LocalVikingFS", + "VikingFS", + "WikiStore", + "create_raw_reader", + "create_wiki_store", +] + +_VIKING_DEFAULT_URL = "http://viking.ai.rootcloud.info/" + +_client_cache: VikingClient | None = None + + +def _viking_client() -> VikingClient: + """Return a memoized OpenViking HTTP client (shared by wiki + raw backends).""" + global _client_cache # noqa: PLW0603 - process-wide memoized singleton + if _client_cache is None: + from openviking_sdk import SyncHTTPClient + + _client_cache = SyncHTTPClient( + url=os.environ.get("VIKING_BASE_URL", _VIKING_DEFAULT_URL), + api_key=os.environ.get("VIKING_API_KEY"), + ) + _client_cache.initialize() + return _client_cache + + +def viking_read(uri: str, *, propagate_unavailable: bool = False) -> str | None: + """Read an arbitrary ``viking://resources/<ns>/...`` URI via the shared client. + + Cross-namespace direct read: the caller passes the full URI (e.g. the + ``730`` raw case library) rather than a namespace-relative key. Returns + ``None`` when the node does not exist. + """ + try: + content = _viking_client().read(uri) + except OpenVikingError as exc: + if isinstance(exc, NotFoundError) or str(getattr(exc, "code", "")).upper() == "NOT_FOUND": + return None + logger.warning("viking read failed: %s (%s)", uri, type(exc).__name__) + if propagate_unavailable: + raise + return None + return _strip_control_chars(content) if content is not None else None + + +def viking_list_children(uri: str, *, recursive: bool = False) -> dict[str, object]: + """Enumerate direct children of a ``viking://resources/<ns>/...`` URI. + + Uses the SDK's ``ls`` (non-recursive) or ``tree`` (recursive) so the + conductor can drill down into an arbitrary remote tree (raw manuals, cases, + repairmenus) by URI instead of a fixed local ``chapters/`` layout. + + Returns: + A browse payload with a ``children`` list of ``{"name", "uri", + "is_dir"}`` entries, or an error payload on unknown URIs. + """ + client = _viking_client() + target = uri.rstrip("/") + try: + nodes = ( + client.tree(target) if recursive else client.ls(target, recursive=False, simple=False) + ) + except Exception as exc: + if isinstance(exc, NotFoundError) or str(getattr(exc, "code", "")).upper() == "NOT_FOUND": + return {"uri": uri, "type": "error", "error": "not found"} + logger.warning("viking list failed: %s (%s)", uri, type(exc).__name__) + return {"uri": uri, "type": "error", "error": str(exc)[:200]} + + children: list[dict[str, object]] = [] + seen: set[str] = set() + for node in nodes or []: + if not isinstance(node, dict): + continue + node_uri = str(node.get("uri") or node.get("path") or node.get("name") or "") + if not node_uri.startswith(target + "/"): + continue + # For recursive tree() the immediate children are at the first path + # segment after the target; skip deeper nodes. + remainder = node_uri[len(target) + 1 :] + segment = remainder.split("/", 1)[0] + if segment in seen: + continue + seen.add(segment) + # A node nested deeper than one level contributes its parent + # directory as the immediate child; keep the collapsed dir URI. + child_uri = target + "/" + segment if "/" in remainder else node_uri + is_dir = ( + "/" in remainder + or bool(node.get("isDir")) + or str( + node.get("type") or node.get("kind") or "", + ) + in {"directory", "dir", "folder"} + ) + children.append({"name": segment, "uri": child_uri, "is_dir": is_dir}) + return { + "uri": uri, + "type": "viking", + "child_count": len(children), + "children": children, + } + + +def create_wiki_store(wiki_root: str | Path) -> WikiStore: + """Build a :class:`WikiStore` over the backend selected by env config. + + ``WIKI_STORAGE_BACKEND`` selects the storage mode: + + - ``viking`` (default): store remotely under + ``viking://resources/{VIKING_NAMESPACE}``. + - ``local``: store under ``wiki_root`` on the local filesystem. + - ``local_viking``: store locally with ``viking://`` URI identity, then + upload to OpenViking at finalize time. + - ``dual``: mirror every write to the local ``wiki_root`` **and** the + remote OpenViking namespace immediately (local is authoritative for + reads; remote mirror is best-effort, failures are logged not raised). + ``VIKING_NAMESPACE`` / ``VIKING_BASE_URL`` / ``VIKING_API_KEY`` are + then required to resolve the remote root. + """ + from wolfharness.capabilities.wiki.quality import set_wiki_root_uri + + backend = os.environ.get("WIKI_STORAGE_BACKEND", "viking") + if backend == "local_viking": + namespace = wiki_namespace() + local_root = Path(wiki_root) + local_root.mkdir(parents=True, exist_ok=True) + store = WikiStore(LocalVikingFS(namespace, local_root)) + logger.info("WikiStore over LocalVikingFS, namespace=%s, root=%s", namespace, local_root) + elif backend == "dual": + namespace = wiki_namespace() + local_root = Path(wiki_root) + local_root.mkdir(parents=True, exist_ok=True) + store = WikiStore( + DualFS( + LocalFS(local_root), + VikingFS(namespace, _viking_client()), + ), + ) + logger.info("WikiStore over DualFS (local %s + viking %s)", local_root, namespace) + elif backend != "viking": + store = WikiStore(Path(wiki_root)) + else: + namespace = wiki_namespace() + logger.info("WikiStore over OpenViking backend, namespace=%s", namespace) + store = WikiStore(VikingFS(namespace, _viking_client())) + set_wiki_root_uri(store.root_uri) + return store + + +def create_raw_reader(local_root: str | Path) -> FSBackend: + """Build an :class:`FSBackend` for reading raw manual chapter markdown. + + Only the pure ``viking`` backend reads chapters remotely from + ``viking://resources/{VIKING_RAW_NAMESPACE}``; ``local`` and + ``local_viking`` read from the local ``local_root`` directory so the + build phase never blocks on remote OpenViking HTTP. An explicit + ``viking://`` URI as ``local_root`` always opts back into the remote + backend. All backends expose path-keys like + ``sy215c/chapters/01_1 前言/chapter.md``. + """ + configured_root = str(local_root).strip().rstrip("/") + explicit_prefix = "viking://resources/" + # An explicit viking:// URI forces the remote backend regardless of the + # storage backend setting. + if configured_root.startswith(explicit_prefix): + namespace = configured_root.removeprefix(explicit_prefix).strip("/") + if not namespace: + raise ValueError("Raw Viking URI must include a resource namespace") + logger.info("Raw reader over OpenViking backend, namespace=%s", namespace) + return VikingFS(namespace, _viking_client()) + # local / local_viking builds read raw chapters from the local filesystem; + # only the pure "viking" backend fetches them remotely. + backend = os.environ.get("WIKI_STORAGE_BACKEND", "viking") + if backend == "local_viking": + namespace = raw_namespace() + logger.info( + "Raw reader over local filesystem with viking URI, namespace=%s, root=%s", + namespace, + local_root, + ) + return LocalFS(Path(local_root), root_uri=f"viking://resources/{namespace}") + return LocalFS(Path(local_root)) + namespace = raw_namespace() + logger.info("Raw reader over OpenViking backend, namespace=%s", namespace) + return VikingFS(namespace, _viking_client()) diff --git a/src/wolfharness/capabilities/wiki/storage/backend.py b/src/wolfharness/capabilities/wiki/storage/backend.py new file mode 100644 index 000000000..8b8e9ea1b --- /dev/null +++ b/src/wolfharness/capabilities/wiki/storage/backend.py @@ -0,0 +1,186 @@ +"""Storage backends for :class:`~wiki.storage.storage.WikiStore`. + +A backend abstracts every filesystem primitive that ``WikiStore`` / ``WikiIndex`` +depend on behind one interface, so the same knowledge-graph store can live on a +local directory (``LocalFS``, read-only fallback for historical libs) or on a +remote OpenViking instance (``VikingFS``, the primary read/write path). + +Path keys are backend-relative and use ``/`` separators only. The entity layout +is identical in both backends (``Concept/Class/Object.md``); operational +metadata may use independent JSON records, but entity identity does not. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +import logging +import re + + +logger = logging.getLogger(__name__) + +_CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") + + +def _strip_control_chars(text: str) -> str: + r"""Remove JSON-illegal control characters (preserving \\n, \\r, \\t).""" + return _CONTROL_CHAR_RE.sub("", text) + + +class FSBackend(ABC): + """Minimal filesystem surface used by ``WikiStore`` / ``WikiIndex``. + + All methods take path-keys (relative, POSIX separators, no leading ``/``). + Write semantics are atomic/replace: a write leaves the content fully visible + to a subsequent read, even to another process. + """ + + @property + @abstractmethod + def root_uri(self) -> str: + """Canonical URI prefix for this backend (e.g. ``viking://resources/ns``).""" + + @abstractmethod + def read_text(self, key: str) -> str | None: + """Return file content as text, or ``None`` if absent.""" + + def read_uri(self, uri: str) -> str | None: + """Read a canonical URI without converting it through a local Path.""" + prefix = self.root_uri.rstrip("/") + "/" + if not uri.startswith(prefix): + return None + return self.read_text(uri.removeprefix(prefix)) + + @abstractmethod + def write_text(self, key: str, content: str, *, overwrite: bool = True) -> None: + """Atomically write text. ``overwrite=False`` does not clobber an + existing file (first-write-wins). + """ + + def write_text_durable(self, key: str, content: str) -> None: + """Write text with a durability guarantee (fsync or remote ack). + + Default implementation delegates to :meth:`write_text`; backends with + a stronger durability primitive (e.g. remote Viking with server-side + ack) should override. + """ + self.write_text(key, content) + + def write_many(self, writes: list[tuple[str, str]]) -> None: + """Write a small related set; remote backends may commit it as a batch.""" + for key, content in writes: + self.write_text(key, content) + + def commit_many(self, writes: list[tuple[str, str]]) -> None: + """Commit a publication set without exposing a partially written set. + + Backends must override this method with an all-or-nothing primitive. + Publication must never silently degrade to sequential ``write_text`` + calls because readers could then observe a mixed build. + """ + raise RuntimeError(f"{type(self).__name__} does not support atomic publication commits") + + @abstractmethod + def exists(self, key: str) -> bool: + """Return whether the path-key exists as a file.""" + + @abstractmethod + def is_dir(self, key: str) -> bool: + """Return whether the path-key exists as a directory.""" + + @abstractmethod + def list_dir(self, key: str, *, recursive: bool = False) -> list[str]: + """Return descendant file path-keys beneath ``key`` (files only).""" + + def list_entries(self, key: str, *, recursive: bool = False) -> list[str]: + """Return immediate descendant path-keys beneath ``key``, **including + directories**. + + Unlike :meth:`list_dir` (files only), this exposes directory nodes so a + caller can do layered, per-level drill-down over an arbitrary in-root + path without a full recursive walk. Returned keys are relative to the + backend root. Default behaviour reuses :meth:`list_dir`. + """ + return self.list_dir(key, recursive=recursive) + + def list_entries_with_meta(self, key: str) -> list[tuple[str, bool]]: + """Return ``(path_key, is_dir)`` pairs for immediate children. + + Single-call alternative to :meth:`list_entries` + per-entry + :meth:`is_dir` — avoids N+1 network round-trips on remote backends. + Default impl falls back to the two-call pattern (fine for local FS). + """ + entries = self.list_entries(key, recursive=False) + return [(e, self.is_dir(e)) for e in entries] + + @abstractmethod + def mtime_ns(self, key: str) -> int | None: + """Return a monotonic file modification timestamp (ns), ``None`` if absent.""" + + @abstractmethod + def size(self, key: str) -> int | None: + """Return the file size in bytes, ``None`` if absent or unknown.""" + + def fingerprint(self, key: str) -> tuple[int | None, int | None]: + """Return modification time and size for cache validation.""" + return self.mtime_ns(key), self.size(key) + + @abstractmethod + def mkdir_p(self, key: str) -> None: + """Ensure a directory path-key exists (idempotent).""" + + @abstractmethod + def delete(self, key: str) -> None: + """Remove a file path-key (no-op if absent).""" + + def remove_empty_dir(self, key: str) -> bool: + """Remove an empty directory path-key; return ``True`` if removed. + + Default returns ``False``; backends with real filesystems override. + """ + return False + + @abstractmethod + def move(self, src: str, dst: str) -> None: + """Move/rename a file path-key.""" + + # ── semantic retrieval (optional, VikingFS only) ───────────────────── + + def search(self, query: str, *, limit: int = 10) -> list[dict[str, object]]: + """Semantic search within this backend's root. Default: unsupported.""" + return [] + + def find( + self, + query: str, + *, + target_uri: str = "", + limit: int = 10, + deep: bool = False, + ) -> list[dict[str, object]]: + """Retrieve ranked resources through the backend's native search API. + + ``find`` is the OpenViking fast semantic primitive. Local storage has + no semantic index, so the default remains an empty result rather than + pretending that a filesystem scan is equivalent retrieval. + """ + return [] + + def grep( + self, pattern: str, *, limit: int = 256, target_uri: str = "" + ) -> list[dict[str, object]]: + """Regex text search within this backend's root. Default: unsupported.""" + return [] + + def relations(self, uri: str) -> list[dict[str, object]]: + """Return backend-native relations for one resource, if supported.""" + return [] + + def sync_native_relations(self, pairs: list[tuple[str, list[str]]]) -> dict[str, object]: + """Sync entity relations to the backend's native graph API. + + Local backends have no remote graph — relations live in entity + frontmatter only, so this is a no-op. ``pairs`` is + ``(entity_uri, [linked_uris])``. + """ + return {"linked": 0, "unlinked": 0, "errors": []} diff --git a/src/wolfharness/capabilities/wiki/storage/dual_fs.py b/src/wolfharness/capabilities/wiki/storage/dual_fs.py new file mode 100644 index 000000000..b48c5e9e9 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/storage/dual_fs.py @@ -0,0 +1,130 @@ +"""Dual-backend: mirror every write to local disk *and* remote OpenViking. + +``DualFS`` wraps a :class:`LocalFS` (authoritative reads, zero-HTTP during a +build) and a :class:`VikingFS` (remote mirror). Every mutation is applied to +both backends so the wiki is immediately available both locally (synchronous, +never blocked by network) and remotely (best-effort, failures are logged, never +raise). Reading is served from the local mirror only — the local copy is the +source of truth for the current process. + +This is the storage mode an operator wants when a capability (e.g. the wiki +ticket tools) must produce state that outsiders can consume from OpenViking +right away while the running service keeps a local ``output/`` copy for +debugging and offline work. +""" + +from __future__ import annotations + +import logging + +from .backend import FSBackend + + +logger = logging.getLogger(__name__) + + +class DualFS(FSBackend): + """Mirror writes to a local backend and a remote Viking backend.""" + + def __init__(self, local: FSBackend, remote: FSBackend) -> None: + self.local = local + self.remote = remote + # WikiStore inspects ``backend.namespace`` to derive its viking root. + self.namespace = str(getattr(remote, "namespace", "")) + + @property + def root_uri(self) -> str: + return self.remote.root_uri + + # ── reads: local mirror is authoritative ──────────────────────────── + + def read_text(self, key: str) -> str | None: + return self.local.read_text(key) + + def exists(self, key: str) -> bool: + return self.local.exists(key) + + def is_dir(self, key: str) -> bool: + return self.local.is_dir(key) + + def list_dir(self, key: str, *, recursive: bool = False) -> list[str]: + return self.local.list_dir(key, recursive=recursive) + + def list_entries(self, key: str, *, recursive: bool = False) -> list[str]: + return self.local.list_entries(key, recursive=recursive) + + def list_entries_with_meta(self, key: str) -> list[tuple[str, bool]]: + return self.local.list_entries_with_meta(key) + + def mtime_ns(self, key: str) -> int | None: + return self.local.mtime_ns(key) + + def size(self, key: str) -> int | None: + return self.local.size(key) + + def fingerprint(self, key: str) -> tuple[int | None, int | None]: + return self.local.fingerprint(key) + + def find( + self, query: str, *, target_uri: str = "", limit: int = 10, deep: bool = False + ) -> list[dict[str, object]]: + """Semantic retrieval only exists on the remote backend — delegate.""" + return self.remote.find(query, target_uri=target_uri, limit=limit, deep=deep) + + def search(self, query: str, *, limit: int = 10) -> list[dict[str, object]]: + return self.remote.search(query, limit=limit) + + def grep( + self, pattern: str, *, limit: int = 256, target_uri: str = "" + ) -> list[dict[str, object]]: + return self.remote.grep(pattern, limit=limit, target_uri=target_uri) + + def relations(self, uri: str) -> list[dict[str, object]]: + return self.remote.relations(uri) + + # ── writes: mirror to both backends, remote is best-effort ────────── + + def write_text(self, key: str, content: str, *, overwrite: bool = True) -> None: + self.local.write_text(key, content, overwrite=overwrite) + self._mirror("write_text", key, content=content, overwrite=overwrite) + + def write_text_durable(self, key: str, content: str) -> None: + self.local.write_text_durable(key, content) + self._mirror("write_text_durable", key, content=content) + + def write_many(self, writes: list[tuple[str, str]]) -> None: + self.local.write_many(writes) + self._mirror("write_many", None, writes=writes) + + def commit_many(self, writes: list[tuple[str, str]]) -> None: + self.local.commit_many(writes) + self._mirror("write_many", None, writes=writes) + + def mkdir_p(self, key: str) -> None: + self.local.mkdir_p(key) + self._mirror("mkdir_p", key) + + def delete(self, key: str) -> None: + self.local.delete(key) + self._mirror("delete", key) + + def move(self, src: str, dst: str) -> None: + self.local.move(src, dst) + self._mirror("move", None, src=src, dst=dst) + + # ── internals ──────────────────────────────────────────────────────── + + def _mirror(self, method: str, key: str | None, **kwargs: object) -> None: + """Apply one mutation to the remote backend, swallowing failures. + + The local write already happened; the remote mirror must never take the + caller down with it. Failures are logged so an operator can detect a + silently-desynced remote. + """ + try: + if key is not None: + getattr(self.remote, method)(key, **kwargs) + else: + getattr(self.remote, method)(**kwargs) + except Exception as exc: + logger.warning("DualFS remote mirror %s failed (key=%s): %s", method, key, exc) diff --git a/src/wolfharness/capabilities/wiki/storage/indexes.py b/src/wolfharness/capabilities/wiki/storage/indexes.py new file mode 100644 index 000000000..1aad79203 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/storage/indexes.py @@ -0,0 +1,79 @@ +"""Read-only compatibility for identity maps written by older wiki builds. + +New entities and classes use readable, self-describing paths. This module is +deliberately not a write path: it only keeps legacy hash URIs and historical +case aliases readable during rolling migration. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + + +if TYPE_CHECKING: + from .backend import FSBackend + + +WikiIdentity = tuple[str, str | None, str] + + +class LegacyWikiIndex: + """Resolve historical JSON mappings without extending them.""" + + def __init__(self, backend: FSBackend) -> None: + self._backend = backend + + def _read_mapping(self, name: str) -> dict[str, str]: + key = f"index/{name}" + raw = self._backend.read_text(key) + if raw is None: + return {} + data = json.loads(raw) + if not isinstance(data, dict) or not all( + isinstance(key, str) and isinstance(value, str) for key, value in data.items() + ): + raise ValueError(f"Invalid legacy wiki mapping: {key}") + return data + + @staticmethod + def _parse_natural_key(key: str) -> WikiIdentity | None: + parts = key.split(":", 2) + if len(parts) != 3: + return None + concept, class_name, object_name = parts + return concept, class_name or None, object_name + + def lookup_uri(self, uri: str) -> WikiIdentity | None: + """Resolve an old entity hash URI through read-only legacy mappings.""" + reverse = self._read_mapping("uri_keys.json") + natural_key = reverse.get(uri) + if natural_key is None: + natural_keys = self._read_mapping("natural_keys.json") + natural_key = next( + (key for key, candidate_uri in natural_keys.items() if candidate_uri == uri), + None, + ) + return self._parse_natural_key(natural_key) if natural_key is not None else None + + def lookup_class_uri(self, uri: str) -> tuple[str, str] | None: + """Resolve an old class hash URI through its read-only mapping.""" + prefix = self._backend.root_uri + "/" + if not uri.startswith(prefix): + return None + parts = uri[len(prefix) :].strip("/").split("/") + if len(parts) < 2: + return None + entry = self._read_mapping("class_keys.json").get(parts[-1]) + if entry is None or ":" not in entry: + return None + concept, class_name = entry.split(":", 1) + return concept, class_name + + def resolve_case_uri(self, uri: str) -> str | None: + """Resolve either side of a historical case URI alias.""" + return self._read_mapping("case_uri_map.json").get(uri) + + +# Import compatibility for integrations that referenced the old class name. +WikiIndex = LegacyWikiIndex diff --git a/src/wolfharness/capabilities/wiki/storage/local_fs.py b/src/wolfharness/capabilities/wiki/storage/local_fs.py new file mode 100644 index 000000000..5e67f93e6 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/storage/local_fs.py @@ -0,0 +1,167 @@ +"""Local-directory backend. Read side doubles as the historical-lib fallback.""" + +from __future__ import annotations + +from pathlib import Path +from tempfile import NamedTemporaryFile + +from .backend import FSBackend, _strip_control_chars + + +class LocalFS(FSBackend): + """Filesystem backend rooted at a local directory.""" + + def __init__(self, root: Path, *, root_uri: str | None = None) -> None: + self.root = Path(root).resolve() + self._root_uri_override = root_uri + + @property + def root_uri(self) -> str: + if self._root_uri_override is not None: + return self._root_uri_override + return f"file://{self.root}" + + def _p(self, key: str) -> Path: + path = self.root.joinpath(*key.split("/")) + resolved = path.resolve() + if not resolved.is_relative_to(self.root): + raise ValueError(f"path escapes backend root: {key!r}") + return resolved + + def read_text(self, key: str) -> str | None: + try: + p = self._p(key) + except ValueError: + return None + if not p.is_file(): + return None + return _strip_control_chars(p.read_text(encoding="utf-8")) + + def write_text(self, key: str, content: str, *, overwrite: bool = True) -> None: + p = self._p(key) + if not overwrite and p.is_file(): + return + p.parent.mkdir(parents=True, exist_ok=True) + with NamedTemporaryFile( + "w", + encoding="utf-8", + dir=p.parent, + prefix=f".{p.name}.", + suffix=".tmp", + delete=False, + ) as tmp: + tmp.write(content) + tmp.flush() + tmp_name = tmp.name + Path(tmp_name).replace(p) + + def write_text_durable(self, key: str, content: str) -> None: + self.write_text(key, content) + + def commit_many(self, writes: list[tuple[str, str]]) -> None: + """Commit local publication writes with rollback on process errors. + + LocalFS is a development backend without a server-side transaction. + Staging every replacement before the first rename prevents preparation + failures from changing visible files; retained in-memory originals + allow rollback if a rename fails. + """ + if not writes: + return + staged: list[tuple[Path, Path]] = [] + originals: dict[Path, bytes | None] = {} + try: + for key, content in writes: + target = self._p(key) + target.parent.mkdir(parents=True, exist_ok=True) + originals[target] = target.read_bytes() if target.is_file() else None + with NamedTemporaryFile( + "w", + encoding="utf-8", + dir=target.parent, + prefix=f".{target.name}.", + suffix=".tmp", + delete=False, + ) as tmp: + tmp.write(content) + tmp.flush() + staged.append((Path(tmp.name), target)) + for temporary, target in staged: + temporary.replace(target) + except OSError: + for target, original in originals.items(): + if original is None: + if target.is_file(): + target.unlink() + else: + target.write_bytes(original) + raise + finally: + for temporary, _target in staged: + if temporary.is_file(): + temporary.unlink() + + def exists(self, key: str) -> bool: + try: + return self._p(key).is_file() + except ValueError: + return False + + def is_dir(self, key: str) -> bool: + try: + return self._p(key).is_dir() + except ValueError: + return False + + def list_dir(self, key: str, *, recursive: bool = False) -> list[str]: + base = self._p(key) + if not base.is_dir(): + return [] + pattern = "**/*" if recursive else "*" + out: list[str] = [] + for p in sorted(base.glob(pattern)): + if not p.is_file(): + continue + out.append(str(p.relative_to(self.root))) + return out + + def list_entries(self, key: str, *, recursive: bool = False) -> list[str]: + """List immediate child path-keys (files *and* directories) in one level.""" + base = self._p(key) + if not base.is_dir(): + return [] + out: list[str] = [] + for p in sorted(base.glob("*")): + out.append(str(p.relative_to(self.root))) + return out + + def mtime_ns(self, key: str) -> int | None: + p = self._p(key) + try: + st = p.stat() + except OSError: + return None + return st.st_mtime_ns + + def size(self, key: str) -> int | None: + p = self._p(key) + try: + st = p.stat() + except OSError: + return None + return st.st_size + + def mkdir_p(self, key: str) -> None: + self._p(key).mkdir(parents=True, exist_ok=True) + + def delete(self, key: str) -> None: + p = self._p(key) + if p.is_file(): + p.unlink() + + def move(self, src: str, dst: str) -> None: + sp = self._p(src) + dp = self._p(dst) + dp.parent.mkdir(parents=True, exist_ok=True) + if sp.is_file(): + sp.replace(dp) diff --git a/src/wolfharness/capabilities/wiki/storage/local_viking_fs.py b/src/wolfharness/capabilities/wiki/storage/local_viking_fs.py new file mode 100644 index 000000000..88e51b706 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/storage/local_viking_fs.py @@ -0,0 +1,214 @@ +"""Local filesystem backend that preserves the viking:// URI scheme. + +Stores files on local disk (zero HTTP during build) but reports +``root_uri`` as ``viking://resources/{namespace}``. At finalize, +``finalize_upload()`` uploads each file individually via ``client.write`` +with ``processing_mode='semantic_and_vectors'`` so entities become both +vector-embedded and semantically searchable while preserving the exact +canonical URI — no server-side splitting. Each file upload runs in a +worker thread with a hard timeout so a slow Viking write never hangs the +finalize tool call (the wiki is durable locally regardless). +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from openviking_sdk.errors import AlreadyExistsError # type: ignore[import-untyped] + +from .backend import FSBackend +from .local_fs import LocalFS + + +if TYPE_CHECKING: + from pathlib import Path + + from .viking_fs import VikingClient + +logger = logging.getLogger(__name__) + +# Long entity/OP filenames exceed URI length limits on the Viking server. +# Filenames must already be short at GENERATION time (id builders clip UTF-8 +# bytes), because renaming here would silently break every URI reference that +# points at the original filename. This constant is a hard validation gate. +_MAX_FILENAME_BYTES = 200 +# client.write(semantic_and_vectors) writes the file then waits for the +# server to process it; on a busy server that can take 1-3min per file. +# Longer than this and we give up on this file (the write likely still +# landed server-side). +_UPLOAD_TIMEOUT_S = 180 + + +class LocalVikingFS(FSBackend): + """Local storage with viking:// URI identity. + + Delegates all I/O to an inner :class:`LocalFS` but overrides + ``root_uri`` to return ``viking://resources/{namespace}``. + """ + + def __init__(self, namespace: str, local_root: Path) -> None: + self.namespace = namespace + self._local = LocalFS(local_root / namespace) + + @property + def root_uri(self) -> str: + return f"viking://resources/{self.namespace}" + + def read_text(self, key: str) -> str | None: + return self._local.read_text(key) + + def write_text(self, key: str, content: str, *, overwrite: bool = True) -> None: + self._local.write_text(key, content, overwrite=overwrite) + + def write_text_durable(self, key: str, content: str) -> None: + self._local.write_text_durable(key, content) + + def write_many(self, writes: list[tuple[str, str]]) -> None: + self._local.write_many(writes) + + def commit_many(self, writes: list[tuple[str, str]]) -> None: + self._local.commit_many(writes) + + def exists(self, key: str) -> bool: + return self._local.exists(key) + + def is_dir(self, key: str) -> bool: + return self._local.is_dir(key) + + def list_dir(self, key: str, *, recursive: bool = False) -> list[str]: + return self._local.list_dir(key, recursive=recursive) + + def list_entries(self, key: str, *, recursive: bool = False) -> list[str]: + return self._local.list_entries(key, recursive=recursive) + + def mtime_ns(self, key: str) -> int | None: + return self._local.mtime_ns(key) + + def size(self, key: str) -> int | None: + return self._local.size(key) + + def mkdir_p(self, key: str) -> None: + self._local.mkdir_p(key) + + def delete(self, key: str) -> None: + self._local.delete(key) + + def move(self, src: str, dst: str) -> None: + self._local.move(src, dst) + + def sync_native_relations(self, pairs: list[tuple[str, list[str]]]) -> dict[str, object]: + """No-op: local backends have no remote graph API to sync. + + Relations are stored in entity frontmatter only; the native + OpenViking link/unlink graph is irrelevant for local builds. + """ + return {"linked": 0, "unlinked": 0, "errors": []} + + def finalize_upload(self, client: VikingClient) -> dict[str, object]: + """Upload each file under the wiki root to Viking individually. + + Uses ``client.write`` per file (not ``add_resource`` per directory) + because ``add_resource`` with ``parse_mode: no_split`` is broken + server-side — the MarkdownParser rejects every file. Per-file + ``write(processing_mode='semantic_and_vectors')`` preserves the + exact canonical URI, creates embeddings, and avoids splitting. + + Each file upload runs in a worker thread with a hard timeout so a + slow Viking write never hangs the finalize tool call. + + Returns: + ``{"status": ..., "uploads": [{"path": str, "status": str}]}`` + where status is ``completed``, ``partial``, or ``failed``. + """ + uploads: list[dict[str, object]] = [] + try: + root = self._local.root + all_files = self._collect_files(root) + self._ensure_dirs(client, all_files, root) + + # --- upload each file sequentially --- + for fp in all_files: + rel = fp.relative_to(root).as_posix() + uri = f"{self.root_uri}/{rel}" + content = fp.read_text(encoding="utf-8") + try: + client.write( + uri, + content, + mode="create", + wait=False, + processing_mode="semantic_and_vectors", + timeout=_UPLOAD_TIMEOUT_S, + ) + uploads.append({"path": rel, "status": "ok"}) + except AlreadyExistsError: + try: + client.write( + uri, + content, + mode="replace", + wait=False, + processing_mode="semantic_and_vectors", + timeout=_UPLOAD_TIMEOUT_S, + ) + uploads.append({"path": rel, "status": "ok"}) + except Exception: + logger.exception("replace write %s failed", rel) + uploads.append({"path": rel, "status": "failed"}) + except Exception: + logger.exception("write %s failed", rel) + uploads.append({"path": rel, "status": "failed"}) + + ok = [u for u in uploads if u["status"] == "ok"] + failed = [u for u in uploads if u["status"] == "failed"] + if not ok: + status = "failed" + elif failed: + status = "partial" + else: + status = "completed" + except Exception: + logger.exception("finalize_upload failed; local finalize stands") + return {"status": "failed", "uploads": uploads} + return {"status": status, "uploads": uploads} + + def _collect_files(self, root: Path) -> list[Path]: + """Walk ``root`` recursively, returning uploadable files. + + Skips ``index/``, ``source_packets/``, and hidden files. Validates + filename byte length against ``_MAX_FILENAME_BYTES``. + """ + all_files: list[Path] = [] + for fp in sorted(root.rglob("*")): + if not fp.is_file(): + continue + rel = fp.relative_to(root) + if any(p in {"index", "source_packets"} or p.startswith(".") for p in rel.parts): + continue + if len(fp.name.encode("utf-8")) > _MAX_FILENAME_BYTES: + raise ValueError( + f"filename {fp.name!r} is {len(fp.name.encode('utf-8'))} bytes " + f"(limit {_MAX_FILENAME_BYTES}); id builders must clip at generation time", + ) + all_files.append(fp) + return all_files + + def _ensure_dirs(self, client: VikingClient, files: list[Path], root: Path) -> None: + """Create the root namespace dir and all parent dirs on Viking.""" + try: + client.mkdir(self.root_uri) + except Exception: + logger.debug("mkdir %s failed (may already exist)", self.root_uri, exc_info=True) + + parent_dirs: set[str] = set() + for fp in files: + rel = fp.relative_to(root) + for i in range(1, len(rel.parts)): + parent_dirs.add("/".join(rel.parts[:i])) + for d in sorted(parent_dirs, key=lambda p: p.count("/")): + uri = f"{self.root_uri}/{d}" + try: + client.mkdir(uri) + except Exception: + logger.debug("mkdir %s failed (may already exist)", uri, exc_info=True) diff --git a/src/wolfharness/capabilities/wiki/storage/storage.py b/src/wolfharness/capabilities/wiki/storage/storage.py new file mode 100644 index 000000000..895c8780f --- /dev/null +++ b/src/wolfharness/capabilities/wiki/storage/storage.py @@ -0,0 +1,1308 @@ +"""design_717 filesystem storage for wiki entities. + +Backend-neutral knowledge graph store: +- Concept → Class → Object directory layout under ``wiki/`` +- Atomic markdown writes via temp-file rename +- ``index/backlinks_index.json`` for reverse-link queries +- Chinese filesystem paths (``Concept/Class/Object.md``) for readability +- Readable, self-describing URIs for new entities; legacy hash URIs remain readable +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +from pathlib import Path +import re +import time +from typing import TYPE_CHECKING + +from wolfharness.capabilities.wiki.quality import extract_source_uris, is_source_uri_scheme + +from .backend import FSBackend +from .indexes import WikiIndex +from .local_fs import LocalFS + + +if TYPE_CHECKING: + from collections.abc import Callable + + +logger = logging.getLogger(__name__) + + +class WikiStore: + """Filesystem store for design_717 wiki entities. + + One instance owns one ``root`` directory. All paths are resolved against it. + """ + + CONCEPT_DIRS: dict[str, str] = { + "Device": "Device", + "Component": "Component", + "DTC": "DTC", + "Symptom": "Symptom", + "Fault": "Fault", + "Procedure": "Procedure", + "OPA": "OP/OpA", + "OPS": "OP/OpS", + "OPL": "OP/OpL", + } + + # Concepts that use a directory + index.md layout instead of a flat .md file. + # Symptom uses Symptom/<object>/index.md with a profile/ subdirectory + # per design_717.md §2.1. + DIRECTORY_CONCEPTS: frozenset[str] = frozenset({"Symptom"}) + + # Legacy 关重件/普通件 prefixes, accepted-and-stripped for backward compat + # only (never produced on write). Component folders now follow the logical + # class_name path alone. + _LEGACY_TIERS: tuple[str, ...] = ("关重件/", "普通件/") + + # Parenthetical annotations (full-width /half-width) stripped from + # class_name before path/hash so the same controller always merges + # into one directory regardless of LLM uncertainty notes. + _PAREN_RE: re.Pattern[str] = re.compile(r"[((][^))]*[))]") + + _MAX_OBJECT_NAME_BYTES = 180 + + @staticmethod + def canonical_object_name(object_name: str) -> str: + """Keep model-internal slashes out of filesystem path components. + + Manual model names often contain a slash (for example + ``HP3V80/AV10RC``). A slash is data in that name, not a nested wiki + directory. Store it as a full-width solidus so the display remains + recognizable while path traversal protection stays strict. + + Also clips the name to a safe byte length so the resulting filename + stays within OS limits (macOS ``NAME_MAX`` = 255 bytes, but + ``LocalVikingFS`` enforces 200 bytes at upload). The 180-byte cap + leaves room for the ``.md`` extension and path components. + """ + trans_table: dict[str, str | int | None] = {"/": "/", "\\": "\"} + sanitized = object_name.translate(str.maketrans(trans_table)) + encoded = sanitized.encode("utf-8") + if len(encoded) <= WikiStore._MAX_OBJECT_NAME_BYTES: + return sanitized + # ponytail: byte-clip from the end, trim incomplete UTF-8 tail + return encoded[: WikiStore._MAX_OBJECT_NAME_BYTES].decode("utf-8", errors="ignore") + + def __init__(self, root: Path | FSBackend) -> None: + """Create a store over a local directory or an ``FSBackend``. + + ``root`` may be a local ``Path`` (wrapped in :class:`LocalFS`, the + historical-lib fallback) or an ``FSBackend`` instance directly + (typically :class:`~wiki.storage.viking_fs.VikingFS` for remote + OpenViking storage). + """ + self._fs: FSBackend = root if isinstance(root, FSBackend) else LocalFS(Path(root)) + if isinstance(self._fs, LocalFS): + self.root = self._fs.root + else: + # Synthetic root for URI/path computation only; real I/O routes + # through the backend keyed by root-relative paths. + self.root = Path(f"viking://resources/{self._fs.namespace}") # type: ignore[attr-defined] + self._indexes = WikiIndex(self._fs) + self._ensure_layout() + self._hash_scan_cache: dict[str, tuple[str, str | None, str]] | None = None + self._physical_entity_cache: dict[ + str, + list[tuple[str, str | None, str, str]], + ] = {} + self._physical_entity_cache_at: dict[str, float] = {} + # Audit and graph traversal repeatedly resolve the same readable URI. + # Cache the immutable content for the lifetime of this service + # instance; every write invalidates it below. The fingerprint also + # detects atomic writes made by another MCP/service process, so a + # long-lived reader cannot serve stale forward relations. + self._entity_content_cache: dict[str, str | None] = {} + self._entity_content_cache_fingerprints: dict[str, tuple[int, int] | None] = {} + self._redirects_cache_content: str | None = None + self._native_relation_sync_result: dict[str, object] | None = None + + @property + def root_uri(self) -> str: + """Canonical URI prefix for this store's backend.""" + return self._fs.root_uri + + def is_wiki_uri(self, uri: str) -> bool: + """Return ``True`` if *uri* belongs to this store's backend.""" + return uri.startswith(self.root_uri + "/") + + def _invalidate_entity_content_cache(self) -> None: + """Drop cached entity bodies after a write or migration.""" + self._entity_content_cache.clear() + self._entity_content_cache_fingerprints.clear() + + def _invalidate_entity_discovery_cache(self) -> None: + """Drop cached path listings after local mutations.""" + self._physical_entity_cache.clear() + self._physical_entity_cache_at.clear() + + @staticmethod + def _entity_discovery_cache_seconds() -> float: + """Return the bounded cross-worker discovery-cache lifetime.""" + try: + return max( + 0.0, + min(60.0, float(os.environ.get("WIKI_ENTITY_DISCOVERY_CACHE_SECONDS", "1"))), + ) + except ValueError: + return 1.0 + + def _ensure_layout(self) -> None: + """Create required top-level directories (idempotent).""" + dirs = [ + "Device", + "Component", + "DTC", + "Symptom", + "Fault", + "Procedure", + "OP/OpA", + "OP/OpS", + "OP/OpL", + "index", + ".staging", + ] + for d in dirs: + self._fs.mkdir_p(d) + + # ── Entity hash (deterministic, replaces Chinese chars in URI paths) ─────── + + @classmethod + def normalize_class_name(cls, concept: str, class_name: str | None) -> str | None: + """Return the canonical (untiered) Component class name. + + The 关重件/普通件 physical-tier routing was removed; a Component's folder + is its logical class_name path. Legacy prefixed inputs are stripped so + old references still resolve to the flat identity. + """ + if not class_name: + return class_name + # Strip parenthetical annotations for ALL concepts so the same + # entity always merges into one directory/URI. + cleaned = cls._PAREN_RE.sub("", class_name).strip() + if cleaned != class_name: + class_name = cleaned + if concept != "Component": + return class_name + for prefix in cls._LEGACY_TIERS: + if class_name.startswith(prefix): + return class_name.removeprefix(prefix) + return class_name + + @classmethod + def logical_class_name(cls, concept: str, class_name: str | None) -> str | None: + """Return the logical Component class name for display. + + Strips a legacy ``关重件/``/``普通件/`` prefix so historical + tiered index entries still display as their bare logical class. + New entries are already flat (``normalize_class_name`` strips on + write), so this is a no-op for them. + """ + if concept == "Component" and class_name is not None: + for prefix in cls._LEGACY_TIERS: + if class_name.startswith(prefix): + return class_name.removeprefix(prefix) + return class_name + + @classmethod + def entity_hash(cls, concept: str, class_name: str | None, object_name: str) -> str: + """Deterministic SHA256 hash for entity URIs. + + Always hashes ``concept + class + object`` into a 24-char hex string. + Used in URIs (``{root_uri}/<Concept>/<hash>``) to avoid Chinese + characters in references. Filesystem paths remain Chinese + (``<Concept>/<Class>/<Object>.md``) for readability and dedup. + """ + normalized_class = cls.normalize_class_name(concept, class_name) + canonical_object = cls.canonical_object_name(object_name) + raw = "\x1f".join([concept, normalized_class or "", canonical_object]).encode("utf-8") + return hashlib.sha256(raw).hexdigest()[:24] + + # ── Path resolution ──────────────────────────────────────────────────── + + def entity_path(self, concept: str, class_name: str | None, object_name: str) -> Path: + """Return the filesystem path for an entity markdown file. + + Human-readable layout: ``<root>/<Concept>/<Class>/<Object>.md``. + When ``class_name`` is None: ``<root>/<Concept>/<Object>.md``. + + Symptom uses a directory layout per design_717.md: + ``<root>/Symptom/<Class>/<Object>/index.md``. + """ + class_name = self.normalize_class_name(concept, class_name) + object_name = self.canonical_object_name(object_name) + if concept not in self.CONCEPT_DIRS: + raise ValueError(f"Unknown Wiki concept: {concept}") + self._safe_component(object_name, "object_name") + if class_name is not None: + self._safe_class(class_name) + base = self.root / self.CONCEPT_DIRS[concept] + if concept in self.DIRECTORY_CONCEPTS: + if class_name: + path = base / class_name / object_name / "index.md" + else: + path = base / object_name / "index.md" + elif class_name: + path = base / class_name / f"{object_name}.md" + else: + path = base / f"{object_name}.md" + if isinstance(self._fs, LocalFS): + resolved = path.resolve() + if not resolved.is_relative_to(self.root): + raise ValueError("Wiki entity path escapes the configured root") + return resolved + return path + + def legacy_entity_path( + self, concept: str, class_name: str | None, object_name: str + ) -> Path | None: + """Return the pre-directory-layout .md path for backward compat. + + Only DIRECTORY_CONCEPTS had a legacy flat .md layout. Other concepts + always used .md files, so their legacy path is the same as entity_path. + """ + class_name = self.normalize_class_name(concept, class_name) + object_name = self.canonical_object_name(object_name) + if concept not in self.DIRECTORY_CONCEPTS: + return None + base = self.root / self.CONCEPT_DIRS[concept] + return base / class_name / f"{object_name}.md" if class_name else base / f"{object_name}.md" + + @staticmethod + def _safe_component(value: str, label: str) -> None: + if ( + not value + or value in {".", ".."} + or Path(value).name != value + or any(separator in value for separator in ("/", "\\")) + ): + raise ValueError(f"Invalid {label}: {value!r}") + + @classmethod + def _safe_profile_id(cls, value: str) -> None: + """Reject encoded path semantics while leaving entity names intact.""" + cls._safe_component(value, "profile_id") + segments = re.split(r"[/\]", value) + reserved = {"profile", "index.md", ".", ".."} + if ( + any(not segment for segment in segments) + or any(segment.casefold() in reserved for segment in segments) + or len(segments) > 1 + ): + raise ValueError(f"Invalid profile_id path shape: {value!r}") + + @staticmethod + def _safe_class(value: str) -> None: + """Validate class_name — allows ``/`` for nested directories (e.g. ``发动机/电控发动机/EGR``).""" + if not value or "\\" in value: + raise ValueError(f"Invalid class_name: {value!r}") + for seg in value.split("/"): + if not seg or seg in {".", ".."}: + raise ValueError(f"Invalid class_name segment: {seg!r} in {value!r}") + + def entity_uri(self, concept: str, class_name: str | None, object_name: str) -> str: + """Build the canonical readable ``{root_uri}/<Concept>/<Class>/<Object>`` URI. + + Readable URIs encode the entity's own class/object identity, so a + reference is self-describing — it cannot be a "phantom hash" that + fails to resolve. Resolution back to a file path is direct (see + ``_parse_readable_uri`` + ``entity_path``); no reverse index needed. + + The class segment is the LOGICAL class name (Component storage tiers + are hidden); ``entity_path`` re-applies tier routing on resolution. + """ + clz = self.normalize_class_name(concept, class_name) + clz = self.logical_class_name(concept, clz) if clz else None + object_name = self.canonical_object_name(object_name) + self._safe_component(object_name, "object_name") + if clz is not None: + self._safe_class(clz) + if clz: + return f"{self.root_uri}/{concept}/{clz}/{object_name}" + return f"{self.root_uri}/{concept}/{object_name}" + + def symptom_profile_uri(self, symptom_uri: str, profile_id: str) -> str: + """Build a stable URI for a profile belonging to a Symptom entity.""" + self.symptom_profile_path(symptom_uri, profile_id) + return f"{symptom_uri}/profile/{profile_id}" + + def symptom_profile_path(self, symptom_uri: str, profile_id: str) -> Path: + """Return the profile path below an existing canonical Symptom.""" + self._safe_profile_id(profile_id) + info = self.lookup_by_uri(symptom_uri) + if info is None or info[0] != "Symptom": + raise ValueError(f"Unknown canonical Symptom URI: {symptom_uri}") + concept, class_name, object_name = info + parent = self.entity_path(concept, class_name, object_name).parent + path = parent / "profile" / f"{profile_id}.md" + if isinstance(self._fs, LocalFS): + resolved = path.resolve() + if not resolved.is_relative_to(self.root): + raise ValueError("Symptom profile path escapes the configured root") + return resolved + return path + + def split_symptom_profile_uri(self, uri: str) -> tuple[str, str] | None: + """Split ``<symptom_uri>/profile/<profile_id>`` into its parts. + + Supports both legacy hash parents and readable parents. + """ + marker = "/profile/" + if marker not in uri: + return None + symptom_uri, profile_id = uri.rsplit(marker, 1) + if not symptom_uri.startswith(f"{self.root_uri}/Symptom/"): + return None + try: + self._safe_profile_id(profile_id) + except ValueError: + return None + parent = self.lookup_by_uri(symptom_uri) + if parent is None or parent[0] != "Symptom": + return None + return symptom_uri, profile_id + + # ── Atomic read / write ──────────────────────────────────────────────── + + def _key_of(self, path: Path) -> str: + """Convert a root-anchored path (or key) to a backend-relative key.""" + try: + rel = path.relative_to(self.root) + except ValueError: + rel = Path(str(path)) + parts = [p for p in rel.parts if p not in {"", "."}] + return "/".join(parts) + + def read_text(self, key: str) -> str | None: + """Read a root-relative key through the active backend.""" + return self._fs.read_text(key) + + def write_text(self, key: str, content: str, *, overwrite: bool = True) -> None: + """Write a root-relative key through the active backend.""" + self._fs.write_text(key, content, overwrite=overwrite) + + def write_text_durable(self, key: str, content: str) -> None: + """Write and poll until read-after-write is visible (Viking eventual consistency).""" + self._fs.write_text_durable(key, content) + + def commit_many(self, writes: list[tuple[str, str]]) -> None: + """Atomically publish related root-relative text records.""" + self._fs.commit_many(writes) + self._invalidate_entity_discovery_cache() + self._invalidate_entity_content_cache() + + def exists(self, key: str) -> bool: + """Return whether a root-relative key exists as a file.""" + return self._fs.exists(key) + + def is_dir(self, key: str) -> bool: + """Return whether a root-relative key exists as a directory.""" + return self._fs.is_dir(key) + + def list_dir(self, key: str, *, recursive: bool = False) -> list[str]: + """List root-relative file keys under a directory key.""" + return self._fs.list_dir(key, recursive=recursive) + + def scan_frontmatter( + self, + key: str, + *, + keys: tuple[str, ...] | None = None, + node_limit: int = 20000, + ) -> list[tuple[str, str]]: + """Scan single-line frontmatter fields under ``key`` in one round-trip. + + Delegates to the backend; remote (Viking) backends use an SDK ``grep`` + fast path, local backends return ``[]`` (callers fall back to the + sequential read path). + """ + scan = getattr(self._fs, "scan_frontmatter", None) + if scan is None: + return [] + result = scan(key, keys=keys, node_limit=node_limit) + return list(result) if isinstance(result, list) else [] + + def fingerprint(self, key: str) -> tuple[int | None, int | None]: + """Return a cheap backend-native freshness marker for one file key.""" + return self._fs.fingerprint(key) + + def mkdir_p(self, key: str) -> None: + """Ensure a root-relative directory key exists.""" + self._fs.mkdir_p(key) + + def delete(self, key: str) -> None: + """Delete a root-relative file key.""" + self._fs.delete(key) + self._invalidate_entity_discovery_cache() + self._invalidate_entity_content_cache() + + def move(self, src: str, dst: str) -> None: + """Move a root-relative file key.""" + self._fs.move(src, dst) + self._invalidate_entity_discovery_cache() + self._invalidate_entity_content_cache() + + def remove_empty_dir(self, key: str) -> bool: + """Remove an empty directory key; returns ``True`` if removed.""" + removed = bool(self._fs.remove_empty_dir(key)) + if removed: + self._invalidate_entity_discovery_cache() + self._invalidate_entity_content_cache() + return removed + + def read_json(self, key: str) -> dict[str, object] | None: + """Read and parse a root-relative JSON key; ``None`` if absent/broken.""" + raw = self._fs.read_text(key) + if raw is None: + return None + try: + value = json.loads(raw) + except (OSError, UnicodeError, json.JSONDecodeError): + logger.warning("Ignoring malformed wiki JSON: %s", key) + return None + return value if isinstance(value, dict) else None + + def write_json( + self, + key: str, + data: dict[str, object], + *, + durable: bool = False, + ) -> None: + """Serialize and write a root-relative JSON key atomically. + + ``durable=True`` is reserved for control-plane receipts whose caller + immediately makes a scheduling decision from a separate read. + """ + content = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + if durable: + self._fs.write_text_durable(key, content) + else: + self._fs.write_text(key, content) + + def write_text_atomic(self, path: Path, content: str) -> Path: + """Write UTF-8 text atomically through the active backend.""" + self._fs.write_text(self._key_of(path), content) + return path + + def write_entity( + self, concept: str, class_name: str | None, object_name: str, content: str + ) -> Path: + """Atomically write an entity markdown file. + + For DIRECTORY_CONCEPTS, also removes the legacy flat .md file if it + exists (migration from old layout to index.md layout). + """ + path = self.entity_path(concept, class_name, object_name) + self.write_text_atomic(path, content) + self._invalidate_entity_content_cache() + self._hash_scan_cache = None + self._remember_entity(concept, class_name, object_name) + legacy = self.legacy_entity_path(concept, class_name, object_name) + if legacy is not None and self._fs.exists(self._key_of(legacy)): + self._fs.delete(self._key_of(legacy)) + return path + + def write_entities(self, entities: list[tuple[str, str | None, str, str]]) -> list[Path]: + """Write validated entity bodies through one backend batch. + + Identity and validation remain the service layer's responsibility; + this method only turns readable entity identities into independent + file writes and maintains local discovery caches. + """ + if not entities: + return [] + paths = [ + self.entity_path(concept, class_name, object_name) + for concept, class_name, object_name, _content in entities + ] + self._fs.write_many( + [ + (self._key_of(path), content) + for path, (_concept, _class_name, _object_name, content) in zip( + paths, entities, strict=True + ) + ], + ) + self._invalidate_entity_content_cache() + self._hash_scan_cache = None + for concept, class_name, object_name, _content in entities: + self._remember_entity(concept, class_name, object_name) + legacy = self.legacy_entity_path(concept, class_name, object_name) + if legacy is not None and self._fs.exists(self._key_of(legacy)): + self._fs.delete(self._key_of(legacy)) + return paths + + def _remember_entity(self, concept: str, class_name: str | None, object_name: str) -> None: + """Update an already-populated discovery cache without a tree rescan.""" + if not self._physical_entity_cache: + return + normalized_class = self.normalize_class_name(concept, class_name) + canonical_object = self.canonical_object_name(object_name) + identity = (concept, normalized_class, canonical_object) + entity = (*identity, self.entity_uri(concept, normalized_class, canonical_object)) + for cache_key in ("*", concept): + cached = self._physical_entity_cache.get(cache_key) + if cached is None: + continue + self._physical_entity_cache[cache_key] = [ + item for item in cached if item[:3] != identity + ] + self._physical_entity_cache[cache_key].append(entity) + self._physical_entity_cache_at[cache_key] = time.monotonic() + + def read_entity(self, concept: str, class_name: str | None, object_name: str) -> str | None: + """Read an entity's content; return ``None`` if absent. + + For DIRECTORY_CONCEPTS, falls back to the legacy flat .md path if + the new index.md path does not exist (backward compat). For Component, + falls back to legacy 关重件/普通件 storage tiers if the flat path + doesn't exist (removed tier routing, transient pre-migration data). + """ + path = self.entity_path(concept, class_name, object_name) + key = self._key_of(path) + content = self._fs.read_text(key) + if content is not None: + return content + legacy = self.legacy_entity_path(concept, class_name, object_name) + if legacy is not None: + content = self._fs.read_text(self._key_of(legacy)) + if content is not None: + return content + if concept == "Component" and class_name: + for prefix in self._LEGACY_TIERS: + tiered = ( + self.root + / self.CONCEPT_DIRS["Component"] + / f"{prefix}{class_name}" + / f"{object_name}.md" + ) + content = self._fs.read_text(self._key_of(tiered)) + if content is not None: + return content + return None + + def read_entity_by_uri(self, uri: str) -> str | None: + """Read a readable entity URI directly or resolve a legacy hash URI.""" + profile = self.split_symptom_profile_uri(uri) + if profile is not None: + symptom_uri, profile_id = profile + try: + path = self.symptom_profile_path(symptom_uri, profile_id) + except ValueError: + return None + else: + info = self.lookup_by_uri(uri) + if info is None: + self._entity_content_cache[uri] = None + self._entity_content_cache_fingerprints[uri] = None + return None + concept, class_name, object_name = info + path = self.entity_path(concept, class_name, object_name) + key = self._key_of(path) + if self._fs.read_text(key) is None: + legacy = self.legacy_entity_path(concept, class_name, object_name) + if legacy is not None and self._fs.read_text(self._key_of(legacy)) is not None: + path = legacy + elif concept == "Component" and class_name: + for prefix in self._LEGACY_TIERS: + tiered = ( + self.root + / self.CONCEPT_DIRS["Component"] + / f"{prefix}{class_name}" + / f"{object_name}.md" + ) + if self._fs.read_text(self._key_of(tiered)) is not None: + path = tiered + break + + key = self._key_of(path) + fingerprint: tuple[int, int] | None + mtime = self._fs.mtime_ns(key) + size = self._fs.size(key) + fingerprint = (mtime, size) if mtime is not None and size is not None else None + if ( + uri in self._entity_content_cache + and self._entity_content_cache_fingerprints.get(uri) == fingerprint + ): + return self._entity_content_cache[uri] + content = self._fs.read_text(key) + self._entity_content_cache[uri] = content + self._entity_content_cache_fingerprints[uri] = fingerprint + return content + + def write_symptom_profile(self, symptom_uri: str, profile_id: str, content: str) -> Path: + """Atomically write a Symptom Profile below its canonical Symptom.""" + path = self.symptom_profile_path(symptom_uri, profile_id) + written = self.write_text_atomic(path, content) + self._invalidate_entity_content_cache() + return written + + def list_symptom_profiles(self, symptom_uri: str) -> list[tuple[str, str]]: + """Return ``[(profile_id, profile_uri), ...]`` for a canonical Symptom.""" + info = self.lookup_by_uri(symptom_uri) + if info is None or info[0] != "Symptom": + return [] + concept, class_name, object_name = info + profile_dir = self.entity_path(concept, class_name, object_name).parent / "profile" + keys = self._fs.list_dir(self._key_of(profile_dir)) + return [ + (Path(key).stem, f"{symptom_uri}/profile/{Path(key).stem}") + for key in keys + if key.endswith(".md") and Path(key).name != "index.md" + ] + + def lookup_by_uri(self, uri: str) -> tuple[str, str | None, str] | None: + """Resolve a wiki URI to ``(concept, class_name, object_name)``. + + Accepts two forms: + - Readable (canonical): ``{root_uri}/<Concept>/<Class>/<Object>`` — + parsed positionally, no index involved. + - Legacy hash form: ``{root_uri}/<Concept>/<hash>`` — resolved through + a read-only historical mapping or a one-time page scan. + """ + uri = self.resolve_redirect(uri) + readable = self._parse_readable_uri(uri) + if readable is not None: + return readable + info = self._indexes.lookup_uri(uri) + if info is not None: + return info + return self._scan_for_hash_uri(uri) + + def _parse_readable_uri( + self, + uri: str, + ) -> tuple[str, str | None, str] | None: + """Parse a readable ``{root_uri}/<Concept>/<Class>/<Object>`` URI. + + Returns ``None`` when the URI is not a readable-form wiki URI + (e.g. a hash URI, a malformed path, or a non-wiki scheme). + + Args: + uri: The URI string to parse. + + Returns: + ``(concept, class_name, object_name)`` when parseable. + """ + prefix = self.root_uri + "/" + if not uri.startswith(prefix): + return None + parts = uri[len(prefix) :].split("/") + if not parts or parts[0] not in self.CONCEPT_DIRS: + return None + concept = parts[0] + object_name = parts[-1].removesuffix(".md") if parts[-1].endswith(".md") else parts[-1] + class_name = "/".join(parts[1:-1]) or None + # A hash-form tail (single 24-hex segment) is legacy, not readable. + if class_name is None and re.fullmatch(r"[0-9a-f]{24}", object_name): + return None + # Component class is normalized (untiered) — its folder is the logical + # class_name path; legacy 关重件/普通件 prefixes are stripped. + return concept, self.normalize_class_name(concept, class_name), object_name + + def _scan_for_hash_uri(self, uri: str) -> tuple[str, str | None, str] | None: + """File-system fallback: scan .md files for a matching first-line hash URI. + + Reads only the first line of each ``.md`` file (``# {root_uri}/...``) + and compares against *uri*. Triggered only for a legacy hash URI that + was absent from the historical mapping. + + The scan is executed at most once per process; the resulting + ``hash -> identity`` map is cached so repeated misses are O(1) + instead of re-walking every ``.md`` file (which made full-library + audits quadratic in the number of phantom references). + """ + if not self.is_wiki_uri(uri): + return None + if self._hash_scan_cache is None: + cache: dict[str, tuple[str, str | None, str]] = {} + for concept, subdir in self.CONCEPT_DIRS.items(): + keys = self._fs.list_dir(subdir, recursive=True) + for key in keys: + if not key.endswith(".md"): + continue + try: + content = self._fs.read_text(key) + except (OSError, UnicodeDecodeError): + continue + if content is None: + continue + first_line = content.splitlines()[0].strip() if content else "" + if not self.is_wiki_uri(first_line): + continue + rel = Path(key).relative_to(Path(subdir)) + parts = rel.parts + if Path(key).name == "index.md" and concept in self.DIRECTORY_CONCEPTS: + object_name = parts[-2] + class_name = "/".join(parts[:-2]) or None + else: + object_name = Path(key).stem + class_name = "/".join(parts[:-1]) or None + cache[first_line] = (concept, class_name, object_name) + self._hash_scan_cache = cache + return self._hash_scan_cache.get(uri) + + # ── Natural-key registry ─────────────────────────────────────────────── + + def ensure_natural_key_index(self) -> None: + """Compatibility no-op; readable entity paths are the identity map.""" + + def register_case_uri(self, display_uri: str) -> str: + """Return the validated readable case URI without a mutable alias map.""" + return display_uri + + def resolve_case_uri(self, uri: str) -> str | None: + """Return a readable case URI or resolve a historical hash alias.""" + prefix = self.root_uri + "/case/" + if uri.startswith(prefix): + return uri + return self._indexes.resolve_case_uri(uri) + + def register_natural_key( + self, concept: str, class_name: str | None, natural_key: str, uri: str + ) -> None: + """Validate identity without writing a global natural-key mapping.""" + class_name = self.normalize_class_name(concept, class_name) + natural_key = self.canonical_object_name(natural_key) + expected = self.entity_uri(concept, class_name, natural_key) + if uri != expected: + raise ValueError(f"Entity URI does not match its readable identity: {uri}") + + def update_natural_key( + self, concept: str, class_name: str | None, natural_key: str, uri: str + ) -> None: + """Update (overwrite) a natural-key → URI mapping. + + New readable URIs need no mutable reverse index. Moves remain + addressable through the redirect layer. + """ + class_name = self.normalize_class_name(concept, class_name) + natural_key = self.canonical_object_name(natural_key) + expected = self.entity_uri(concept, class_name, natural_key) + if uri != expected: + raise ValueError(f"Entity URI does not match its readable identity: {uri}") + + def unregister_uri(self, uri: str) -> bool: + """Invalidate discovery state; readable identities have no registry row.""" + existed = self.lookup_by_uri(uri) is not None + self._invalidate_entity_discovery_cache() + return existed + + def lookup_natural_key( + self, concept: str, class_name: str | None, natural_key: str + ) -> str | None: + """Resolve a natural key directly through the entity's readable path.""" + class_name = self.normalize_class_name(concept, class_name) + natural_key = self.canonical_object_name(natural_key) + if self.read_entity(concept, class_name, natural_key) is None: + return None + return self.entity_uri(concept, class_name, natural_key) + + def fuzzy_lookup_natural_key( + self, concept: str, class_name: str | None, object_name: str + ) -> str | None: + """Fuzzy-match discovered entities without a global JSON map.""" + class_name = self.normalize_class_name(concept, class_name) + object_name = self.canonical_object_name(object_name) + candidates = self.list_entities(concept) + if concept == "Component": + for _concept, candidate_class, candidate_name, uri in candidates: + if ( + class_name + and candidate_class + and not ( + candidate_class == class_name or candidate_class.startswith(class_name) + ) + ): + continue + if ( + (candidate_name.endswith(object_name) or object_name.endswith(candidate_name)) + and len(candidate_name) >= 3 + and len(object_name) >= 3 + ): + return uri + elif concept == "Device": + for _concept, _candidate_class, candidate_name, uri in candidates: + if candidate_name.startswith(object_name) or object_name.startswith(candidate_name): + return uri + elif concept == "DTC": + + def normalize_code(code: str) -> str: + match = re.match(r"^([A-Z])-?(\d+)", code) + return f"{match.group(1)}{int(match.group(2)):03d}" if match else code + + normalized = normalize_code(object_name) + for _concept, candidate_class, candidate_name, uri in candidates: + if class_name and candidate_class != class_name: + continue + candidate_normalized = normalize_code(candidate_name) + if candidate_normalized.startswith(normalized) or normalized.startswith( + candidate_normalized + ): + return uri + return None + + def list_entities( + self, + concept: str | None = None, + ) -> list[tuple[str, str | None, str, str]]: + """Enumerate entities from their physical self-describing paths.""" + return self.physical_entities(concept) + + def physical_entities( + self, + concept: str | None = None, + ) -> list[tuple[str, str | None, str, str]]: + """Enumerate the self-describing entity files from backend storage.""" + cache_key = concept or "*" + cached = self._physical_entity_cache.get(cache_key) + cached_at = self._physical_entity_cache_at.get(cache_key, 0.0) + if ( + cached is not None + and time.monotonic() - cached_at <= self._entity_discovery_cache_seconds() + ): + return list(cached) + concepts = ( + [concept] + if concept + else [name for name in self.CONCEPT_DIRS if name not in {"OPA", "OPS", "OPL"}] + ) + found: list[tuple[str, str | None, str, str]] = [] + for name in concepts: + if name not in self.CONCEPT_DIRS: + continue + subdir = self.CONCEPT_DIRS[name] + keys = self._fs.list_dir(subdir, recursive=True) + for key in keys: + if not key.endswith(".md"): + continue + rel = Path(key).relative_to(Path(subdir)) + parts = rel.parts + if name == "Symptom": + if Path(key).name != "index.md" or len(parts) < 2: + continue + object_name = parts[-2] + class_name = "/".join(parts[:-2]) or None + else: + object_name = Path(key).stem + class_name = "/".join(parts[:-1]) or None + uri = self.entity_uri(name, class_name, object_name) + found.append((name, class_name, object_name, uri)) + self._physical_entity_cache[cache_key] = found + self._physical_entity_cache_at[cache_key] = time.monotonic() + if concept is None: + for concept_name in concepts: + self._physical_entity_cache[concept_name] = [ + entity for entity in found if entity[0] == concept_name + ] + self._physical_entity_cache_at[concept_name] = time.monotonic() + return found + + # ── Class-level hash URI (browse without Chinese in URI) ────────────── + + def class_uri(self, concept: str, class_name: str) -> str: + """Build a readable class URI directly from the directory identity.""" + class_name = self.normalize_class_name(concept, class_name) or class_name + if class_name != "(flat)": + self._safe_class(class_name) + suffix = "" if class_name == "(flat)" else f"/{class_name}" + return f"{self.root_uri}/{concept}/@class{suffix}" + + def lookup_class_by_uri(self, uri: str) -> tuple[str, str] | None: + """Resolve a readable class URI or a historical class hash URI. + + Returns ``None`` if no entity currently establishes that class. + """ + prefix = self.root_uri + "/" + if not uri.startswith(prefix): + return None + parts = uri[len(prefix) :].strip("/").split("/") + if len(parts) < 2: + return None + concept = parts[0] + classes = { + class_name or "(flat)" + for entity_concept, class_name, _object_name, _entity_uri in self.list_entities(concept) + if entity_concept == concept + } + if parts[1] == "@class": + readable_class = "/".join(parts[2:]) or "(flat)" + if readable_class in classes: + return concept, readable_class + return None + # Rolling compatibility for old two-segment class hash URIs. + return self._indexes.lookup_class_uri(uri) + + # ── Body reference resolution ────────────────────────────────────────── + + def resolve_body_refs( + self, + content: str, + is_existing_external_uri: Callable[[str], bool] | None = None, + ) -> str: + """Convert human-readable ``{root_uri}/...`` URIs to hash URIs when possible. + + LLM-generated content contains references like + ``{root_uri}/Component/散热器/散热器总成``. When the target entity is + already registered, the reference is converted to its canonical hash + URI. When the target is **not yet registered**, the natural-key URI is + preserved as a deliberate build-time dangling forward reference. + """ + + def _resolve(uri: str) -> str: + if is_existing_external_uri is not None and is_existing_external_uri(uri): + return uri + prefix = self.root_uri + "/" + if not uri.startswith(prefix): + return uri + parts = uri.removeprefix(prefix).split("/") + if len(parts) < 2: + return uri # malformed + concept = parts[0] + # OP sidecars (OPA/OPS/OPL) are review artifacts, not entity + # concepts. Their URIs are already canonical and must not be sent + # through the entity natural-key resolver. + if concept not in {"Device", "Component", "DTC", "Symptom", "Fault", "Procedure"}: + return uri + object_or_hash = parts[-1] + if ( + len(parts) == 2 + and len(object_or_hash) == 24 + and all(character in "0123456789abcdef" for character in object_or_hash) + ): + return uri + object_name = parts[-1].removesuffix(".md") if parts[-1].endswith(".md") else parts[-1] + class_name = "/".join(parts[1:-1]) or None + resolved = self.lookup_natural_key(concept, class_name, object_name) + return resolved if resolved else uri + + for uri in sorted( + ( + candidate + for candidate in extract_source_uris(content) + if self.is_wiki_uri(candidate) + ), + key=len, + reverse=True, + ): + content = content.replace(uri, _resolve(uri)) + return content + + _INLINE_CITATION_RE = re.compile(r"\[([a-z][a-z0-9+.\-]*://[^\]\s]+)\]") + _SECTION_HEADER_RE = re.compile(r"^(#{2,3}\s+.+)$", re.MULTILINE) + + # YAML frontmatter repair patterns + # Detect a source URI concatenated with the next YAML field: + # " - viking://.../ch0285title: ..." → " - viking://.../ch0285\ntitle: ..." + _FM_CONCAT_RE = re.compile( + r"^(\s+- [a-z][a-z0-9+.\-]*://\S+?/ch\d+)([a-z_]+:.*)$", + re.MULTILINE, + ) + # Detect [简述][source-uri] format in source lines — extract the URI + _FM_DESC_SOURCE_RE = re.compile( + r"^ - \[[^\]]+\]\[([a-z][a-z0-9+.\-]*://[^\]]+)\]$", re.MULTILINE + ) + # YAML list fields that contain source URIs needing hash resolution + _FM_URI_FIELDS = frozenset( + { + "forward_links", + "related_devices", + "related_faults", + "related_symptoms", + "related_dtc", + "related_components", + "affected_components", + "critical_components", + "symptom_refs", + "device_refs", + "direct_component_uri", + "applicable_models", + "assembly_parts", + "verification_procedures", + "repair_procedures", + "target_components", + "specification_refs", + "controller_component", + "possible_faults", + }, + ) + + def dedup_citations(self, content: str) -> str: + """Remove duplicate ``[source-uri]`` citations within each section. + + Keeps only the first occurrence of each URI inside each ``##`` or + ``###`` section. Frontmatter (between ``---`` fences) is untouched. + """ + fm_end = content.find( + "\n---\n", content.find("---\n") + 4 if content.startswith("---\n") else 0 + ) + if fm_end == -1: + fm_part = "" + body = content + else: + fm_end += 4 + fm_part = content[:fm_end] + body = content[fm_end:] + + sections: list[str] = [] + current_header = "" + current_lines: list[str] = [] + + for line in body.splitlines(keepends=True): + if self._SECTION_HEADER_RE.match(line): + if current_header or current_lines: + sections.append(current_header + "".join(current_lines)) + current_header = line + current_lines = [] + else: + current_lines.append(line) + if current_header or current_lines: + sections.append(current_header + "".join(current_lines)) + + result = fm_part + for section in sections: + seen: set[str] = set() + + def _dedup(m: re.Match[str], seen_uris: set[str] = seen) -> str: + uri = m.group(1) + if uri in seen_uris: + return "" + seen_uris.add(uri) + return m.group(0) + + result += self._INLINE_CITATION_RE.sub(_dedup, section) + + return result + + def repair_frontmatter( + self, + content: str, + is_existing_external_uri: Callable[[str], bool] | None = None, + ) -> str: + r"""Repair LLM-generated YAML frontmatter. + + Fixes three systematic issues: + 1. **Concatenation bug**: last source URI merged with next YAML field + (``ch0285title: ...`` → ``ch0285\\ntitle: ...``) + 2. **[简述] prefix**: ``[描述][source-uri]`` → bare ``source-uri`` + 3. **Duplicate sources**: same URI appears multiple times + 4. **Path-based URIs**: resolves ``{root_uri}/Component/...`` to hash URIs + in frontmatter list fields (``forward_links``, ``related_*``). + """ + fm_start = content.find("---\n") + if fm_start == -1: + return content + fm_end = content.find("\n---\n", fm_start + 4) + if fm_end == -1: + return content + + fm_end += 4 + fm_part = content[:fm_end] + body = content[fm_end:] + + # 1. Fix concatenation: split source URI from next YAML field + fm_part = self._FM_CONCAT_RE.sub(r"\1\n\2", fm_part) + + # 2. Strip [简述] prefix: [描述][source-uri] → source-uri + fm_part = self._FM_DESC_SOURCE_RE.sub(r" - \1", fm_part) + + # 3. Deduplicate sources + fm_part = self._dedup_sources(fm_part) + + # 4. Resolve path-based URIs in frontmatter list fields + fm_part = self._resolve_fm_uris(fm_part, is_existing_external_uri) + + return fm_part + body + + def _dedup_sources(self, fm: str) -> str: + """Remove duplicate URIs from the ``sources:`` list in frontmatter.""" + lines = fm.splitlines(keepends=True) + result: list[str] = [] + in_sources = False + seen_uris: set[str] = set() + + for line in lines: + stripped = line.rstrip("\n") + if stripped == "sources:": + in_sources = True + result.append(line) + seen_uris = set() + continue + if in_sources: + if stripped.startswith(" - ") and is_source_uri_scheme(stripped[4:].strip()): + uri = stripped[4:].strip() + if uri in seen_uris: + continue + seen_uris.add(uri) + result.append(line) + continue + in_sources = False + result.append(line) + + return "".join(result) + + def _resolve_fm_uris( + self, + fm: str, + is_existing_external_uri: Callable[[str], bool] | None, + ) -> str: + """Resolve path-based wiki URIs to canonical URIs in frontmatter.""" + + def _resolve_uri(uri: str) -> str: + if is_existing_external_uri is not None and is_existing_external_uri(uri): + return uri + prefix = self.root_uri + "/" + if not uri.startswith(prefix): + return uri + parts = uri.removeprefix(prefix).split("/") + if len(parts) < 2: + return uri + object_or_hash = parts[-1] + if ( + len(parts) == 2 + and len(object_or_hash) == 24 + and all(c in "0123456789abcdef" for c in object_or_hash) + ): + return uri + concept = parts[0] + object_name = parts[-1].removesuffix(".md") if parts[-1].endswith(".md") else parts[-1] + class_name = "/".join(parts[1:-1]) or None + resolved = self.lookup_natural_key(concept, class_name, object_name) + if resolved is None: + resolved = self.fuzzy_lookup_natural_key(concept, class_name, object_name) + return resolved if resolved else uri + + lines = fm.splitlines(keepends=True) + result: list[str] = [] + current_field = "" + + for source_line in lines: + stripped = source_line.rstrip("\n") + field_match = re.match(r"^(\w+):", stripped) + if field_match: + current_field = field_match.group(1) + + if current_field in self._FM_URI_FIELDS: + rewritten_line = source_line + for uri in sorted( + ( + candidate + for candidate in extract_source_uris(source_line) + if self.is_wiki_uri(candidate) + ), + key=len, + reverse=True, + ): + rewritten_line = rewritten_line.replace(uri, _resolve_uri(uri)) + result.append(rewritten_line) + else: + result.append(source_line) + + return "".join(result) + + # ── Backlinks index ──────────────────────────────────────────────────── + + def _backlinks_key(self) -> str: + return "index/backlinks_index.json" + + def _redirects_key(self) -> str: + return "index/redirects.json" + + def register_redirect(self, old_uri: str, new_uri: str) -> None: + """Persist an old URI redirect so moves do not break callers.""" + if not old_uri or not new_uri or old_uri == new_uri: + return + key = self._redirects_key() + redirects: dict[str, str] = {} + raw = self._fs.read_text(key) + if raw is not None: + data = json.loads(raw) + if isinstance(data, dict): + redirects = {str(k): str(v) for k, v in data.items()} + redirects[old_uri] = new_uri + self._redirects_cached = False + self.write_text_atomic( + Path(key), json.dumps(redirects, ensure_ascii=False, indent=2) + "\n" + ) + + def _redirects_cache(self) -> str | None: + """Read the redirects table once and cache it for the instance. + + Redirect writes are rare (URI moves) and invalidate the cache + explicitly; a per-instance cache turns the O(OPA-count) remote + reads inside ``resolve_redirect`` into one read. + """ + if not getattr(self, "_redirects_cached", False): + self._redirects_cache_content = self._fs.read_text(self._redirects_key()) + self._redirects_cached = True + return self._redirects_cache_content + + def resolve_redirect(self, uri: str) -> str: + """Follow persisted redirects with a cycle limit.""" + raw = self._redirects_cache() + if raw is None: + return uri + data = json.loads(raw) + redirects = data if isinstance(data, dict) else {} + current = uri + seen: set[str] = set() + for _ in range(16): + if current in seen: + raise ValueError(f"URI redirect cycle detected at {current}") + seen.add(current) + target = redirects.get(current) + if not isinstance(target, str) or not target: + return current + current = target + raise ValueError(f"URI redirect chain is too long for {uri}") + + def rebuild_backlinks(self, entity_uris_with_links: list[tuple[str, list[str]]]) -> None: + """Rebuild ``backlinks_index.json`` from scratch per build pass. + + Also reconciles backend-native relation edges (e.g. OpenViking + ``link``) to the same authoritative mapping. Returns nothing; + native sync outcomes are reported via backend logs. + """ + backlinks: dict[str, list[str]] = {} + for entity_uri, linked_uris in entity_uris_with_links: + backlinks.setdefault(entity_uri, []) + for linked_uri in linked_uris: + refs = backlinks.setdefault(linked_uri, []) + if entity_uri not in refs: + refs.append(entity_uri) + self._fs.write_text_durable( + self._backlinks_key(), + json.dumps(backlinks, ensure_ascii=False, indent=2) + "\n", + ) + self._native_relation_sync_result = self._fs.sync_native_relations(entity_uris_with_links) + + def sync_native_relations( + self, entity_uris_with_links: list[tuple[str, list[str]]] + ) -> dict[str, object]: + """Reconcile backend-native edges (OpenViking ``link``/``unlink``) only. + + Unlike ``rebuild_backlinks`` this skips the ``backlinks_index.json`` + artifact and syncs just the native graph for the given pages. + """ + self._native_relation_sync_result = self._fs.sync_native_relations(entity_uris_with_links) + return self._native_relation_sync_result + + @property + def native_relation_sync_result(self) -> dict[str, object] | None: + """Return the latest native relation sync result for finalize gating.""" + return self._native_relation_sync_result + + def get_backlinks(self, uri: str) -> list[str]: + """Return URIs that link to *uri* (empty if none / no index).""" + raw = self._fs.read_text(self._backlinks_key()) + if raw is None: + return [] + index: dict[str, list[str]] = json.loads(raw) + return index.get(uri, []) diff --git a/src/wolfharness/capabilities/wiki/storage/viking_fs.py b/src/wolfharness/capabilities/wiki/storage/viking_fs.py new file mode 100644 index 000000000..5af53ca3b --- /dev/null +++ b/src/wolfharness/capabilities/wiki/storage/viking_fs.py @@ -0,0 +1,686 @@ +"""Remote OpenViking backend for :class:`~wiki.storage.storage.WikiStore`. + +Maps the wiki path-key layout (Concept/Class/Object.md + index/*.json) onto an +OpenViking resource tree rooted at ``viking://resources/{namespace}``. Writes +are atomic server-side (``write(mode="replace")``), which lets us drop the local +temp-file+rename machinery. + +The same namespace also doubles as the primary retrieval surface: because the +whole wiki lives in Viking, the consumer can go straight at ``client.find`` / +``client.search`` / ``client.grep`` / ``client.relations`` instead of rebuilding +a local index. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from contextlib import ExitStack +from datetime import datetime +import hashlib +import logging +import os +import threading +import time +from typing import Protocol + +from httpx import HTTPError +from openviking_sdk.errors import ( # type: ignore[import-untyped] + AlreadyExistsError, + NotFoundError, + OpenVikingError, +) + +from .backend import FSBackend, _strip_control_chars + + +logger = logging.getLogger(__name__) + + +class VikingClient(Protocol): + """Structural interface for the OpenViking SDK client duck-type.""" + + def read(self, uri: str) -> str: ... + def write( + self, + uri: str, + content: str, + *, + mode: str, + wait: bool, + timeout: int = ..., + processing_mode: str = "semantic_and_vectors", + ) -> None: ... + def stat(self, uri: str) -> dict[str, object]: ... + def ls(self, uri: str, *, recursive: bool, simple: bool) -> list[dict[str, object]]: ... + def rm(self, uri: str) -> None: ... + def mkdir(self, uri: str) -> None: ... + def mv(self, src: str, dst: str) -> None: ... + def find(self, query: str, *, target_uri: str, limit: int) -> dict[str, object]: ... + def search(self, query: str, *, target_uri: str, limit: int) -> dict[str, object]: ... + def grep(self, target_uri: str, pattern: str, *, node_limit: int) -> dict[str, object]: ... + def relations(self, uri: str) -> list[dict[str, object]]: ... + def batch_write( + self, root_uri: str, operations: list[dict[str, object]], *, wait: bool + ) -> None: ... + def read_raw(self, uri: str) -> str: ... + def tree(self, uri: str) -> list[dict[str, object]]: ... + def add_resource(self, *, path: str, to: str, processing_mode: str, wait: bool) -> object: ... + def initialize(self) -> None: ... + + +# seconds vs ns threshold: timestamps below are seconds, above already ns +_NS_TIMESTAMP_THRESHOLD = 1e12 + + +class VikingFS(FSBackend): + """OpenViking-backed filesystem, one ``viking://resources/<ns>`` root.""" + + # Coordinate conflicting writes without serializing unrelated entity URIs. + # Stripes are shared by backend instances for the same namespace. + _lock_registry_guard = threading.Lock() + _lock_registry: dict[str, tuple[threading.RLock, ...]] = {} + + def __init__(self, namespace: str, client: VikingClient) -> None: + """Wrap a ``openviking_sdk.SyncHTTPClient`` rooted at ``namespace``. + + ``client`` is duck-typed to keep this module import-light and + testable without the SDK installed. + """ + self.namespace = namespace + self._root_uri = f"viking://resources/{namespace}" + self._client = client + with self._lock_registry_guard: + self._write_locks = self._lock_registry.setdefault( + namespace, + tuple(threading.RLock() for _ in range(self._write_lock_stripes())), + ) + + @property + def root_uri(self) -> str: + return self._root_uri + + # ── key ↔ uri ──────────────────────────────────────────────────────── + + def _uri(self, key: str) -> str: + return f"{self.root_uri}/{key}" + + @staticmethod + def _write_lock_stripes() -> int: + """Return configurable bounded lock-striping width.""" + try: + return max(8, min(1024, int(os.environ.get("VIKING_WRITE_LOCK_STRIPES", "64")))) + except ValueError: + return 64 + + def _lock_index(self, uri: str) -> int: + digest = hashlib.sha256(uri.encode("utf-8")).digest() + return int.from_bytes(digest[:8], "big") % len(self._write_locks) + + def _write_lock_for(self, uri: str) -> threading.RLock: + return self._write_locks[self._lock_index(uri)] + + # ── FSBackend ───────────────────────────────────────────────────────── + + def read_text(self, key: str) -> str | None: + result = self._read_remote(self._uri(key)) + if result is not None: + result = _strip_control_chars(result) + return result + + def write_text(self, key: str, content: str, *, overwrite: bool = True) -> None: + """Write with bounded busy-lock retries and no create/replace amplification. + + OpenViking's ``replace`` mode is the update primitive. A failed + ``create`` is not evidence that replacement is safe: a 409 may mean a + live path lock. Reading first also makes a retry idempotent when the + server committed before a response was lost. + """ + uri = self._uri(key) + retry_limit = self._write_retry_limit() + with self._write_lock_for(uri): + mode = "create" + for attempt in range(retry_limit + 1): + existing = self._read_for_write(uri) + if existing is not None: + # ``read`` strips a stored trailing newline; accept either + # form so an identical write is a genuine no-op. + if existing == content or existing == content.rstrip("\n") or not overwrite: + return + mode = "replace" + try: + self._client.write(uri, content, mode=mode, wait=False) + except AlreadyExistsError: + if not overwrite: + return + # A different writer won the create race. Re-read before + # replacing it on the next iteration. + mode = "replace" + except NotFoundError: + # The target disappeared between read and replace. + mode = "create" + except OpenVikingError as exc: + if not self._is_busy(exc): + raise + if attempt >= retry_limit: + logger.exception("OpenViking write remained busy: %s", uri) + raise + delay = self._write_backoff(attempt) + logger.warning( + "OpenViking write busy; retrying uri=%s attempt=%d delay=%.2fs", + uri, + attempt + 1, + delay, + ) + time.sleep(delay) + else: + return + + def write_text_durable(self, key: str, content: str) -> None: + """Write text and poll until read-after-write is visible. + + Remote Viking is eventually consistent: a write is accepted before + it's visible to reads. Control-plane receipts (prefilter markers, + backlinks) must be visible before the caller makes a scheduling + decision, so this method polls ``read_text`` until the content + matches or the timeout expires. + """ + self.write_text(key, content) + timeout = float(os.environ.get("VIKING_DURABLE_WRITE_TIMEOUT_SECONDS", "30")) + poll_interval = float(os.environ.get("VIKING_DURABLE_WRITE_POLL_SECONDS", "0.5")) + deadline = time.monotonic() + timeout + expected = content.rstrip("\n") + while time.monotonic() < deadline: + time.sleep(poll_interval) + result = self.read_text(key) + if result is not None and result.rstrip("\n") == expected: + return + logger.warning("Durable write to %s not visible after %.1fs", key, timeout) + + def write_many(self, writes: list[tuple[str, str]]) -> None: + """Commit related files with OpenViking's preconditioned batch API. + + The preconditions are captured once. A busy lock may retry the exact + same request safely; a content conflict is returned to the caller so it + can re-read and merge instead of silently overwriting another worker. + """ + if not writes: + return + batch_write = getattr(self._client, "batch_write", None) + if not callable(batch_write): + for key, content in writes: + self.write_text(key, content) + return + + lock_indexes = sorted({self._lock_index(self._uri(key)) for key, _content in writes}) + with ExitStack() as stack: + for lock_index in lock_indexes: + stack.enter_context(self._write_locks[lock_index]) + operations: list[dict[str, object]] = [] + for key, content in writes: + uri = self._uri(key) + current = self._read_for_write(uri) + if current is not None and (current == content or current == content.rstrip("\n")): + continue + if current is None: + precondition: dict[str, str] = {"kind": "create_if_absent"} + else: + raw_reader = getattr(self._client, "read_raw", None) + if not callable(raw_reader): + raise TypeError( + "OpenViking read_raw is required for exact batch preconditions", + ) + stored_content = raw_reader(uri) + if not isinstance(stored_content, str): + raise TypeError("OpenViking read_raw must return text content") + digest = hashlib.sha256(stored_content.encode("utf-8")).hexdigest() + precondition = { + "kind": "replace_if_hash", + "base_hash": f"sha256:{digest}", + } + operations.append( + { + "uri": uri, + "content": content, + "precondition": precondition, + }, + ) + if not operations: + return + + retry_limit = self._write_retry_limit() + for attempt in range(retry_limit + 1): + try: + batch_write(self.root_uri, operations, wait=False) + except OpenVikingError as exc: + if not self._is_busy(exc): + raise + if attempt >= retry_limit: + raise + delay = self._write_backoff(attempt) + logger.warning( + "OpenViking batch write busy; retrying root=%s attempt=%d delay=%.2fs", + self.root_uri, + attempt + 1, + delay, + ) + time.sleep(delay) + else: + return + + def commit_many(self, writes: list[tuple[str, str]]) -> None: + """Atomically expose one finalized build through OpenViking batch write.""" + if not writes: + return + if not callable(getattr(self._client, "batch_write", None)): + raise TypeError("OpenViking batch_write is required for atomic finalize") + self.write_many(writes) + + def _read_for_write(self, uri: str) -> str | None: + """Read a target while preserving non-not-found failures.""" + return self._read_remote(uri) + + def _read_remote(self, uri: str) -> str | None: + """Read with bounded transport retries, preserving auth/data errors.""" + retry_limit = self._read_retry_limit() + for attempt in range(retry_limit + 1): + try: + return str(self._client.read(uri)) + except OpenVikingError as exc: + if self._is_not_found(exc): + return None + if not self._is_transient(exc) or attempt >= retry_limit: + raise + except HTTPError: + if attempt >= retry_limit: + raise + time.sleep(self._read_backoff(attempt)) + return None + + @staticmethod + def _is_transient(exc: OpenVikingError) -> bool: + """Return whether a server response is safe to retry as a read.""" + code = str(getattr(exc, "code", "")).upper() + text = str(exc).lower() + return code in {"408", "429", "500", "502", "503", "504"} or any( + marker in text + for marker in ("http 408", "http 429", "http 5", "temporarily unavailable", "timeout") + ) + + @staticmethod + def _read_retry_limit() -> int: + try: + return max(0, min(5, int(os.environ.get("VIKING_READ_RETRIES", "3")))) + except ValueError: + return 3 + + @staticmethod + def _read_backoff(attempt: int) -> float: + try: + base = max(0.05, min(5.0, float(os.environ.get("VIKING_READ_BACKOFF_SECONDS", "0.25")))) + except ValueError: + base = 0.25 + return float(min(5.0, base * (2**attempt))) + + @staticmethod + def _is_not_found(exc: OpenVikingError) -> bool: + return str(getattr(exc, "code", "")).upper() == "NOT_FOUND" or isinstance( + exc, NotFoundError + ) + + @staticmethod + def _is_busy(exc: OpenVikingError) -> bool: + code = str(getattr(exc, "code", "")).upper() + details = getattr(exc, "details", {}) + detail_text = ( + " ".join(str(value) for value in details.values()) if isinstance(details, dict) else "" + ) + text = f"{type(exc).__name__} {exc} {detail_text}".lower() + return code in {"CONFLICT", "ABORTED"} and any( + marker in text + for marker in ("resourcebusy", "resource busy", "lock", "busy", "acquisition") + ) + + @staticmethod + def _write_retry_limit() -> int: + try: + return max(0, min(8, int(os.environ.get("VIKING_WRITE_RETRIES", "4")))) + except ValueError: + return 4 + + @staticmethod + def _write_backoff(attempt: int) -> float: + try: + base = max( + 0.05, + min(10.0, float(os.environ.get("VIKING_WRITE_BACKOFF_SECONDS", "0.75"))), + ) + except ValueError: + base = 0.75 + return float(min(30.0, base * (2**attempt))) + + def exists(self, key: str) -> bool: + try: + info = self._client.stat(self._uri(key)) + except OpenVikingError as exc: + if self._is_not_found(exc): + return False + raise + return bool(info and not info.get("isDir")) + + def is_dir(self, key: str) -> bool: + if not key: + # The empty key is the namespace root, which always exists. + return True + try: + info = self._client.stat(self._uri(key)) + except OpenVikingError as exc: + if self._is_not_found(exc): + return False + raise + return bool(info and info.get("isDir")) + + def list_dir(self, key: str, *, recursive: bool = False) -> list[str]: + prefix = self._uri(key) + if recursive: + # Parallel BFS descent using non-recursive ``ls`` per directory. + # Avoids server-side ``glob("**")`` whose result requires an O(n²) + # directory-prefix filter on the client. Each wave of sibling + # directories is expanded concurrently. + return self._walk_files_parallel(prefix) + try: + nodes = self._client.ls(prefix, recursive=False, simple=False) + except OpenVikingError as exc: + if self._is_not_found(exc): + return [] + raise + out: list[str] = [] + for node in nodes or []: + uri = self._node_uri(node) + if not uri or not uri.startswith(self.root_uri): + continue + rel = uri[len(self.root_uri) + 1 :] + if self._node_is_dir(node): + continue + out.append(rel) + return sorted(out) + + def _expand_dir(self, uri: str) -> tuple[list[str], list[str]]: + """One ``ls`` call; returns (file_rels, subdir_uris).""" + files: list[str] = [] + subdirs: list[str] = [] + try: + nodes = self._client.ls(uri, recursive=False, simple=False) + except OpenVikingError as exc: + if self._is_not_found(exc): + return files, subdirs + raise + for node in nodes or []: + node_uri = self._node_uri(node) + if not node_uri or not node_uri.startswith(self.root_uri): + continue + rel = node_uri[len(self.root_uri) + 1 :] + if not rel: + continue + if self._node_is_dir(node): + subdirs.append(node_uri) + else: + files.append(rel) + return files, subdirs + + def _walk_files_parallel(self, prefix: str, max_workers: int = 16) -> list[str]: + """BFS descent expanding each wave of sibling dirs concurrently.""" + out: list[str] = [] + seen: set[str] = set() + pending = [prefix] + with ThreadPoolExecutor(max_workers=max_workers) as pool: + while pending: + wave = [d for d in pending if d not in seen] + for d in wave: + seen.add(d) + pending = [] + if not wave: + break + results = pool.map(self._expand_dir, wave) + for files, subdirs in results: + out.extend(files) + pending.extend(subdirs) + return sorted(out) + + def list_entries(self, key: str, *, recursive: bool = False) -> list[str]: + """List immediate child path-keys (files *and* directories) in one level. + + Uses a non-recursive ``ls`` so browsing an arbitrary in-root path is a + single lightweight request — no deep-tree ``glob``. Directory nodes are + retained (unlike :meth:`list_dir` which filters them out). + """ + return [path for path, _ in self.list_entries_with_meta(key)] + + def list_entries_with_meta(self, key: str) -> list[tuple[str, bool]]: + """Single ``ls`` returning ``(path, is_dir)`` — no per-entry ``stat``.""" + prefix = self._uri(key) + try: + nodes = self._client.ls(prefix, recursive=False, simple=False) + except OpenVikingError as exc: + if self._is_not_found(exc): + return [] + raise + out: list[tuple[str, bool]] = [] + for node in nodes or []: + uri = self._node_uri(node) + if not uri or not uri.startswith(self.root_uri): + continue + rel = uri[len(self.root_uri) + 1 :] + if rel: + out.append((rel, self._node_is_dir(node))) + return sorted(out) + + def list_subdirs(self, key: str) -> list[str]: + """Immediate subdirectory relative keys via one ``ls`` (no per-entry stat).""" + prefix = self._uri(key) + try: + nodes = self._client.ls(prefix, recursive=False, simple=False) + except OpenVikingError as exc: + if self._is_not_found(exc): + return [] + raise + out: list[str] = [] + for node in nodes or []: + uri = self._node_uri(node) + if not uri or not uri.startswith(self.root_uri): + continue + if self._node_is_dir(node): + rel = uri[len(self.root_uri) + 1 :] + if rel: + out.append(rel) + return sorted(out) + + def mtime_ns(self, key: str) -> int | None: + try: + info = self._client.stat(self._uri(key)) + except OpenVikingError as exc: + if self._is_not_found(exc): + return None + raise + if not info or info.get("isDir"): + return None + # Prefer an explicit timestamp; fall back to a version counter so the + # cache-fingerprint comparison still invalidates on write. + updated = ( + info.get("modTime") + or info.get("updated_at") + or info.get("version") + or info.get("mtime") + ) + return self._timestamp_to_ns(updated) + + def size(self, key: str) -> int | None: + try: + info = self._client.stat(self._uri(key)) + except OpenVikingError as exc: + if self._is_not_found(exc): + return None + raise + if not info or info.get("isDir"): + return None + size = info.get("size") or info.get("byte_size") or info.get("content_length") + return int(size) if isinstance(size, (int, float, str)) and str(size).isdigit() else None + + def fingerprint(self, key: str) -> tuple[int | None, int | None]: + """Return Viking modification time and size with one remote stat.""" + try: + info = self._client.stat(self._uri(key)) + except OpenVikingError as exc: + if self._is_not_found(exc): + return None, None + raise + if not info or info.get("isDir"): + return None, None + updated = ( + info.get("modTime") + or info.get("updated_at") + or info.get("version") + or info.get("mtime") + ) + mtime_ns = self._timestamp_to_ns(updated) + size = info.get("size") or info.get("byte_size") or info.get("content_length") + size_value = ( + int(size) if isinstance(size, (int, float, str)) and str(size).isdigit() else None + ) + return mtime_ns, size_value + + def mkdir_p(self, key: str) -> None: + # Viking creates intermediate nodes implicitly on write; nothing to do. + return None + + def delete(self, key: str) -> None: + try: + self._client.rm(self._uri(key)) + except OpenVikingError as exc: + if not self._is_not_found(exc): + raise + + def move(self, src: str, dst: str) -> None: + self._client.mv(self._uri(src), self._uri(dst)) + + # ── semantic retrieval ─────────────────────────────────────────────── + + def find( + self, + query: str, + *, + target_uri: str = "", + limit: int = 10, + deep: bool = False, + ) -> list[dict[str, object]]: + """Use OpenViking's native ``find``/``search`` retrieval primitive. + + ``find`` is deliberately the default because it is bounded, stateless, + and does not accumulate a session context during a long build. The + optional deep mode is reserved for workers such as OPS that need the + server's intent-aware search. URI scoping is delegated to OpenViking; + the caller must still pass a configured wiki or raw namespace. + + Viking semantic processing generates ``.overview.md`` / ``.abstract.md`` + navigation files that dominate vector rankings. We over-fetch by 3× + and strip those metadata files so callers only see real entity pages. + """ + target = target_uri.rstrip("/") or self.root_uri + method = self._client.search if deep else self._client.find + fetch_limit = max(limit * 3, limit + 20) + try: + result = method(query, target_uri=target, limit=fetch_limit) + except OpenVikingError: # pragma: no cover - optional retrieval surface + logger.warning("viking %s failed", "search" if deep else "find", exc_info=True) + return [] + hits = self._extract_hits(result, target_root=target) + return hits[:limit] + + def search(self, query: str, *, limit: int = 10) -> list[dict[str, object]]: + return self.find(query, limit=limit, deep=True) + + def grep( + self, pattern: str, *, limit: int = 256, target_uri: str = "" + ) -> list[dict[str, object]]: + try: + result = self._client.grep( + target_uri.rstrip("/") or self.root_uri, + pattern, + node_limit=limit, + ) + except OpenVikingError: # pragma: no cover - optional retrieval surface + logger.warning("viking grep failed", exc_info=True) + return [] + return self._extract_hits(result, target_root=target_uri.rstrip("/") or self.root_uri) + + def relations(self, uri: str) -> list[dict[str, object]]: + """Read OpenViking's native relation edges for *uri*.""" + try: + result = self._client.relations(uri) + except OpenVikingError: # pragma: no cover - optional graph surface + logger.warning("viking relations failed: %s", uri, exc_info=True) + return [] + return [item for item in result or [] if isinstance(item, dict)] + + _METADATA_FILENAMES = frozenset({".overview.md", ".abstract.md", "entities.json"}) + + def _extract_hits( + self, result: dict[str, object], *, target_root: str = "" + ) -> list[dict[str, object]]: + hits: list[dict[str, object]] = [] + scope = target_root.rstrip("/") or self.root_uri + for bucket in ("resources", "memories", "skills"): + bucket_items = result.get(bucket, []) + if not isinstance(bucket_items, list): + continue + for item in bucket_items: + if not isinstance(item, dict): + continue + uri = str(item.get("uri", "")) + if not (uri == scope or uri.startswith(scope + "/")): + continue + leaf = uri.rsplit("/", 1)[-1] if "/" in uri else uri + if leaf in self._METADATA_FILENAMES: + continue + key = uri[len(scope) + 1 :] if len(uri) > len(scope) else uri + hits.append( + { + "key": key, + "uri": uri, + "score": item.get("score"), + "abstract": item.get("abstract"), + "context_type": item.get("context_type"), + }, + ) + return hits + + # ── node inspection (structure-coupling isolated here) ─────────────── + + @staticmethod + def _node_uri(node: object) -> str | None: + if isinstance(node, dict): + return str(node.get("uri") or node.get("path") or node.get("name") or "") + return str(node) if node else "" + + @staticmethod + def _node_is_dir(node: object) -> bool: + if isinstance(node, dict): + if node.get("isDir") is True: + return True + ntype = str(node.get("type") or node.get("kind") or "") + return ntype in {"directory", "dir", "folder"} + return False + + @staticmethod + def _timestamp_to_ns(value: object) -> int | None: + if value is None: + return None + if isinstance(value, (int, float)): + # seconds → ns; tolerate already-ns values. + return int(value * 1e9) if abs(value) < 1e12 else int(value) + if isinstance(value, str): + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + return int(dt.timestamp() * 1e9) + except ValueError: + return None + return None diff --git a/src/wolfharness/capabilities/wiki/templates/default_schema.yaml b/src/wolfharness/capabilities/wiki/templates/default_schema.yaml new file mode 100755 index 000000000..6361ef8e6 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/templates/default_schema.yaml @@ -0,0 +1,904 @@ +# 工程机械维修 Wiki — 实体 Schema (v9) +# +# 售后故障诊断场景:从故障现象出发,经 Symptom → Profile → Fault → Component → 工作机理, +# 到 Device 参数和 raw 原文,形成完整诊断闭环。 +# +# 7 种 Concept + Symptom Profile 子资源 + OP(OPA/OPS/OPL) +# URI 格式:viking://resources/<namespace>/Concept/Class/Object(由 OpenViking 直接读取) +# 存储路径:Concept/Class/Object.md;natural key/index 只承担构建期去重与审计,不替代 URI +# 状态:candidate → published → deprecated;status 仅保留兼容,不表示人工审核 +# +# 此文件是实体结构的权威定义,供: +# - extraction_worker 作为 few-shot 模板参考 +# - hooks 作为校验依据(运行时直接读取本文件,禁止双份硬编码) +# - 开发者理解实体结构 + +version: 9 + +# All formal pages share these orthogonal build/review fields. ``status`` is +# retained below for backward compatibility, but it must not be interpreted +# as human approval: automated builds publish with review_state=unreviewed. +shared_frontmatter: + - name: publication_state + type: string + enum: [candidate, published, deprecated] + description: "是否进入正式 Wiki 发布面" + - name: validation_state + type: string + enum: [pending, machine_validated, failed] + description: "harness/audit 的机器校验状态" + - name: review_state + type: string + enum: [unreviewed, human_reviewed] + description: "人工复核状态;自动构建默认为 unreviewed" + +concepts: + # ── Component — 物理组件(可替换/可维修的总成)── + Component: + description: "物理组件 = 可导航的关键总成(assembly)。通用 Wiki 保存身份、工作机理、诊断关系和 raw 入口,不复制机型参数表、判定阈值、线号、线色或针脚图。文件夹即其逻辑装配路径(class_name),按实际 BOM 数据动态创建/合并/重命名。" + class_name: "逻辑装配路径(总成分解树),允许子类路径:发动机/电控发动机/EGR、主泵/变量柱塞泵/双联泵、履带总成/前后导轮。文件夹据此动态生成。" + object_name: "品牌+型号(如 川崎K3V112)或 机械类型+规格。禁止机型代号开头" + frontmatter: + - name: id + type: string + required: true + - name: title + type: string + required: true + - name: description + type: string + required: true + - name: class_name + type: string + required: true + - name: object_name + type: string + required: true + - name: bom_path + type: string + description: "BOM-derived logical assembly path; optional until a BOM baseline is available" + - name: bom_parent_uri + type: ref + ref_type: Component + description: "Optional immediate BOM parent Component" + - name: bom_evidence + type: list + description: "Raw BOM/assembly evidence for bom_path; not a specification field" + - name: status + type: string + required: true + enum: [draft, confirmed, deprecated] + - name: sources + type: list + required: true + description: "raw 章节 URI 列表({raw_root}/<doc_id>/chapters/<subdir>/chapter.md)" + - name: applicable_models + type: list + - name: aliases + type: list + body_sections: + - "总成概览" + - "工作机理" + optional_body_sections: + - "组成零件" + example: | + --- + id: COMPONENT-K3V112 + title: 川崎K3V112主泵 + description: SY215C 主液压泵。 + class_name: 主泵/变量柱塞泵/双联泵 + object_name: 川崎K3V112 + status: draft + applicable_models: + - SY215C + sources: + - viking://resources/<raw_namespace>/manual-sy215/chapters/05_液压系统/05_2 主泵/chapter.md + --- + ## 总成概览 + 川崎K3V112 是 SY215C 的主液压泵,变量柱塞式双联泵。 + ## 工作机理 + 柱塞往复运动完成吸油和压油,斜盘角度调节排量。 + > 具体流量、压力、转速和调节值不在通用 Wiki 复制;按适用机型读取下列原始章节。 + ## 组成零件 + - 回油滤芯 P-7534:见原始章节 `05_液压系统/05_2 主泵`。 + + # ── Fault — 故障机理 ── + Fault: + description: "回答'哪个部件以什么方式失效,怎样传播并造成什么后果'。与 Symptom 分别物化但共享同一 causal packet。" + class_name_enum: [HydraulicFailure, MechanicalFailure, ElectricalFailure, ControlFailure, FluidFailure, ThermalFailure, StructuralFailure] + frontmatter: + - name: id + type: string + required: true + - name: title + type: string + required: true + - name: description + type: string + required: true + - name: class_name + type: string + required: true + enum: [HydraulicFailure, MechanicalFailure, ElectricalFailure, ControlFailure, FluidFailure, ThermalFailure, StructuralFailure] + - name: object_name + type: string + required: true + - name: status + type: string + required: true + - name: affected_components + type: ref_list + ref_type: Component + required: true + - name: verification_procedures + type: ref_list + ref_type: Procedure + required: true + - name: repair_procedures + type: ref_list + ref_type: Procedure + required: true + - name: applicable_models + type: list + - name: sources + type: list + required: true + body_sections: + - "失效描述" + - "失效机理" + - "常见原因" + - "影响范围" + - "关联故障现象" + - "验证方法" + - "修复方式" + example: | + --- + id: FAULT-PUMP-WEAR-LEAK + title: 主泵磨损导致内泄增大 + description: 主泵磨损后内泄增大并使供油能力下降。 + class_name: HydraulicFailure + object_name: 主泵磨损导致内泄增大 + status: draft + affected_components: + - viking://resources/<namespace>/Component/abc123def456789012345678 + verification_procedures: + - viking://resources/<namespace>/Procedure/def456abc789012345678901 + repair_procedures: + - viking://resources/<namespace>/Procedure/ghi789abc012345678901234 + applicable_models: + - SY215C + sources: + - viking://resources/<raw_namespace>/manual-sy215/chapters/05_液压系统/05_2 主泵/chapter.md + --- + ## 失效描述 + 主泵磨损后内泄增大,导致液压系统供油不足。 + ## 失效机理 + ### 正常工作状态 + 柱塞往复运动完成吸油和压油,输出压力稳定。 + ### 失效演变过程 + 1. 柱塞和缸体磨损间隙增大 + 2. 高压油从间隙泄漏回低压侧 + 3. 容积效率下降,输出流量减少 + ### 失效后的系统表现 + - 主泵供油能力下降 + - 整机动作变慢 + ## 常见原因 + - 液压油污染导致柱塞磨损 + - 长期高负荷运转 + ## 影响范围 + 影响 [川崎K3V112主泵](viking://resources/<namespace>/Component/abc123def456789012345678) 的供油能力。 + ## 关联故障现象 + - [整机动作慢](viking://resources/<namespace>/Symptom/jkl012mno345678901234567) + ## 验证方法 + - [检查主泵内泄](viking://resources/<namespace>/Procedure/def456abc789012345678901) + ## 修复方式 + - [修复主泵内泄](viking://resources/<namespace>/Procedure/ghi789abc012345678901234) + + # ── Symptom — 故障现象 ── + Symptom: + description: "回答'观察到什么',不解释为什么。含 Profile 子资源。" + frontmatter: + - name: id + type: string + required: true + - name: title + type: string + required: true + - name: description + type: string + required: true + - name: class_name + type: string + required: false + description: "功能域,不是机型(如 液压系统、动力与发动机)" + - name: object_name + type: string + required: true + - name: status + type: string + required: true + - name: profile_resource + type: string + description: "固定值 profile/" + - name: sources + type: list + required: true + body_sections: + - "现象定义" + - "典型观察特征" + - "相似现象区分" + - "产品配置差异" + - "Profile 索引" + sub_resources: + profiles: + description: "配置特化的诊断上下文" + frontmatter: + - name: profile_id + type: string + required: true + description: "由设备(机型+配置版本) × 直接关联 Component(总成,含具体型号)归一化生成,如 sy215c柴油_主泵川崎K3V112;只含与该故障现象直接关联的组件" + - name: parent_symptom + type: ref + ref_type: Symptom + required: true + - name: status + type: string + required: true + - name: applicable_models + type: list + required: true + - name: direct_component + type: string + description: "兼容旧库的显示名称;新页面必须同时提供 direct_component_uri" + - name: direct_component_uri + type: ref + ref_type: Component + description: "该 Profile 直接关联的具体 Component URI;用于确定性下探" + - name: device_refs + type: ref_list + ref_type: Device + description: "该 Profile 适用的 Device URI;applicable_models 仅作为显示文本" + - name: possible_faults + type: ref_list + ref_type: Fault + required: true + - name: sources + type: list + required: true + - name: engine + type: string + - name: controller + type: string + - name: emission + type: string + - name: fuel_system + type: string + body_sections: + - "适用配置" + - "本配置下的表现特征" + - "与其他配置的差异" + - "可能失效机理" + - "推荐诊断流程" + example: | + --- + profile_id: sy215c柴油_主泵川崎K3V112 + parent_symptom: viking://resources/<namespace>/Symptom/jkl012mno345678901234567 + status: draft + applicable_models: + - SY215C (柴油) + direct_component: 主泵 川崎K3V112 + possible_faults: + - viking://resources/<namespace>/Fault/ghi789def012345678901234 + sources: + - viking://resources/<raw_namespace>/manual-sy215/chapters/05_液压系统/05_2 主泵/chapter.md + --- + ## 适用配置 + SY215C 柴油版 + 主泵川崎K3V112 组合下的诊断上下文。 + ## 本配置下的表现特征 + 多个液压动作均变慢,且表现集中在主液压回路。 + ## 与其他配置的差异 + 来源未给出其他配置差异。 + ## 可能失效机理 + - [主泵磨损导致内泄增大](viking://resources/<namespace>/Fault/ghi789def012345678901234) + ## 推荐诊断流程 + 1. [检查主泵内泄](viking://resources/<namespace>/Procedure/def456abc789012345678901) + example: | + --- + id: SYMPTOM-MACHINE-SLOW + title: 整机动作慢 + description: 整机液压动作速度下降。 + class_name: 液压系统 + object_name: 整机动作慢 + status: draft + profile_resource: profile/ + sources: + - viking://resources/<raw_namespace>/manual-sy215/chapters/05_液压系统/05_2 主泵/chapter.md + --- + ## 现象定义 + 整机液压动作速度下降,多个动作同时变慢。 + ## 典型观察特征 + - 多个液压动作均变慢 + - 多个动作同时受到影响 + ## 相似现象区分 + | 相似现象 | 区分要点 | + |---------|---------| + | [单动作慢](viking://resources/<namespace>/Symptom/0123456789abcdef01234567) | 仅单个动作受影响,整机不掉速 | + > open_gap: 若相似现象实体尚未创建,先写现象名称并在行首标注 `open_gap:`,待 Phase 2 关联或 Phase 4 修复循环补链 URI。 + ## 产品配置差异 + | 配置 | 差异要点 | 对应 Profile | + |------|---------|-------------| + | SY215C 柴油 + 川崎泵 | 诊断路径按该配置 Profile 区分;具体阈值读取原始章节 | [sy215c柴油_主泵川崎K3V112](viking://resources/<namespace>/Symptom/0123456789abcdef01234567/profile/sy215c柴油_主泵川崎K3V112) | + > 此节必须列出源章节中提到的不同设备配置及其在该现象下的具体差异(排放标准/液压配置/控制策略等),禁止仅写"见 Profile"。 + ## Profile 索引 + - [sy215c柴油_主泵川崎K3V112](viking://resources/<namespace>/Symptom/0123456789abcdef01234567/profile/sy215c柴油_主泵川崎K3V112) — SY215C 柴油版 + 主泵川崎K3V112 + > 此节必须列出每个已创建 Profile 的 URI + 一句话描述,禁止仅写"详见 profile/"。 + + # ── DTC — 故障码 ── + DTC: + description: "由系列代号+控制器功能角色+原始故障码共同决定身份。" + class_name_format: "<系列代号>_<控制器功能角色>(如 SY215_主控制器)" + frontmatter: + - name: id + type: string + required: true + - name: title + type: string + required: true + - name: description + type: string + required: true + - name: class_name + type: string + required: true + - name: object_name + type: string + required: true + - name: status + type: string + required: true + - name: code + type: string + required: true + - name: controller_component + type: ref + ref_type: Component + description: "可选。仅当来源能确认具体控制器总成/型号时填写;否则在控制器身份 section 保留 source-honest open_gap。" + - name: controller_role + type: string + required: true + description: "来源确认的控制器功能角色,不等同于硬件型号。" + - name: software_effectivity + type: string + - name: related_faults + type: ref_list + ref_type: Fault + required: true + - name: sources + type: list + required: true + body_sections: + - "故障码定义" + - "控制器身份" + - "触发条件" + - "控制器反应" + - "设备表现" + - "可能失效机理" + - "诊断流程" + example: | + --- + id: DTC-SY215-168-02 + title: 168-02 主控制器电源异常 + description: 主控制器电源电压异常。 + class_name: SY215_主控制器 + object_name: 168-02_主控制器电源异常 + status: draft + code: "168-02" + controller_role: 主控制器 + controller_component: viking://resources/<namespace>/Component/mno456pqr789012345678901 + related_faults: + - viking://resources/<namespace>/Fault/pqr901stu234567890123456 + sources: + - viking://resources/<raw_namespace>/manual-sy215/chapters/09_故障诊断/09_4 排故流程/chapter.md + --- + ## 故障码定义 + 168-02:主控制器电源电压异常。 + ## 触发条件 + 控制器判定电源输入异常;机型相关阈值和持续时间读取引用的原始章节。 + ## 控制器反应 + 控制器进入保护模式,限制输出。 + ## 设备表现 + 整机动作受限或停机。 + ## 可能失效机理 + - [电源电路故障](viking://resources/<namespace>/Fault/pqr901stu234567890123456) + ## 诊断流程 + 1. [检查电源电压](viking://resources/<namespace>/Procedure/stu345vwx678901234567890) + + # ── Procedure — 操作规程 ── + Procedure: + description: "一项与真实 Component 关联、可独立复用的通用操作。保存动作顺序、分支和定性结果;诊断判定基准值(压力标准/间隙/扭矩/磨损极限等阈值)写入 ## 判定标准 附 raw 引用;裸工况测量值、线号、线色和针脚映射只保留 raw 引用,不复制进通用 Wiki。" + class_name_enum: [diagnosis, inspection, measurement, maintenance, removal, installation, replacement, adjustment, repair, test] + frontmatter: + - name: id + type: string + required: true + - name: title + type: string + required: true + - name: description + type: string + required: true + - name: class_name + type: string + required: true + enum: [diagnosis, inspection, measurement, maintenance, removal, installation, replacement, adjustment, repair, test] + - name: object_name + type: string + required: true + - name: status + type: string + required: true + - name: target_components + type: ref_list + ref_type: Component + required: true + - name: procedure_scope + type: string + enum: [atomic, composite] + description: "复合诊断流程必须声明 composite;可复用单项操作声明 atomic" + - name: specification_refs + type: list + description: "Component#spec_id 格式的 URI" + - name: applicable_models + type: list + - name: sources + type: list + required: true + body_sections: + - "操作目的" + - "操作步骤" + - "判定标准" + - "后续处理" + example: | + --- + id: PROCEDURE-DIAGNOSE-PUMP-LEAK + title: 检查主泵内泄 + description: 检查主泵是否存在内泄。 + class_name: diagnosis + object_name: 检查主泵内泄 + status: draft + target_components: + - viking://resources/<namespace>/Component/abc123def456789012345678 + specification_refs: [] + sources: + - viking://resources/<raw_namespace>/manual-sy215/chapters/05_液压系统/05_2 主泵/chapter.md + --- + ## 操作目的 + 判断主泵磨损后是否发生内泄增大。 + ## 操作步骤 + 1. 按原始章节确认适用机型、测点和安全前提。viking://resources/<raw_namespace>/manual-sy215/chapters/05_液压系统/05_2 主泵/chapter.md + 2. 在主泵测试口连接适用的测量工具。viking://resources/<raw_namespace>/manual-sy215/chapters/05_液压系统/05_2 主泵/chapter.md + 3. 建立章节规定的测试工况并记录结果。viking://resources/<raw_namespace>/manual-sy215/chapters/05_液压系统/05_2 主泵/chapter.md + 4. 按引用章节中的本机型标准判断结果,不在通用 Wiki 复制阈值。viking://resources/<raw_namespace>/manual-sy215/chapters/05_液压系统/05_2 主泵/chapter.md + ## 判定标准 + 测量结果不满足适用机型原始章节规定时,进入关联 Fault;通用 Wiki 只保存分支语义。 + ## 后续处理 + 结果异常时沿关联 Fault/修复 Procedure 继续;结果正常时返回上一级诊断流程。 + + # ── Device — 机型 ── + Device: + description: "在 Phase 0 从 BOM 创建。Phase 1 增量补充。" + frontmatter: + - name: id + type: string + required: true + - name: title + type: string + required: true + - name: description + type: string + required: true + - name: series + type: string + - name: status + type: string + required: true + - name: critical_components + type: ref_list + ref_type: Component + required: true + description: "Device 的关键总成 Component URI 列表(该设备包含/故障相关的总成)" + - name: symptom_refs + type: ref_list + ref_type: Symptom + description: "该 Device 已物化且适用的 Symptom URI 索引;用于从机型页确定性进入排故" + - name: aliases + type: list + - name: sources + type: list + required: true + - name: system_chapters + type: list + description: "该机型整机/各系统的 raw 章节引用({raw_root}/<doc_id>/chapters/<subdir>/chapter.md)。audit 校验 Device.system_chapters.raw_link 覆盖" + - name: applicable_models + type: list + body_sections: + - "基础信息" + - "包含系统" + optional_body_sections: + - "本型号特点" + - "常见故障及故障机理" + - "控制器与故障码" + example: | + --- + id: DEVICE-SY215C + title: SY215C 挖掘机 + description: 三一重工 SY215C 液压挖掘机。 + series: SY215 + status: draft + critical_components: + - viking://resources/<namespace>/Component/abc123def456789012345678 + sources: + - viking://resources/<raw_namespace>/manual-sy215/chapters/01_前言/01_1 阅读维修手册的方法/chapter.md + --- + ## 基础信息 + SY215C 挖掘机。通用页面保留机型身份和导航关系;重量、压力、容量等参数读取原始章节。 + ## 包含系统 + - 液压系统 + - 电气系统 + ## 本型号特点 + 电控发动机,带 EGR 排放系统。 + ## 常见故障及故障机理 + | Symptom URI | Fault URI | Component URI | + |-------------|-----------|--------------| + | viking://resources/<namespace>/Symptom/jkl012mno345678901234567 | viking://resources/<namespace>/Fault/ghi789def012345678901234 | viking://resources/<namespace>/Component/abc123def456789012345678 | + ## 控制器与故障码 + | DTC URI | 描述 | + |---------|------| + | viking://resources/<namespace>/DTC/vwx234yza567890123456789 | 168-02 主控制器电源异常 | + +# ── 关系定义(严格单向写入)── +# +# 每条关系只在一个实体的 frontmatter 中维护。 +# 反方向通过 get_backlinks(uri) 查询。 +# +# 诊断链路: +# Device → symptom_refs → Symptom → Profile.device_refs +# Symptom Profile → direct_component_uri → Component +# Symptom → Profile.possible_faults → Fault +# Fault → affected_components → Component +# Fault → verification_procedures → Procedure +# Fault → repair_procedures → Procedure +# Device → critical_components → Component +# DTC → related_faults → Fault +# DTC → controller_component → Component +# Procedure → target_components → Component +# +# 反查链路: +# Component → get_backlinks → Fault (谁引用了我) +# Component → get_backlinks → Device (哪个设备包含我) +# Fault → get_backlinks → Profile (哪些现象指向我) +# Fault → get_backlinks → DTC (哪些故障码关联我) + + # ── OP — 构建反馈与专家提案(不进入正式实体索引)── + # OP 写入走 create_opa / create_ops / create_opl 工具(wiki/serve/opa.py), + # 存储路径 OP/OpA|OpS|OpL/。以下 frontmatter 与 models.py 的 + # OPAModel/OPSModel/OPLModel.to_markdown 输出一一对应。 + OPA: + description: "单点冲突、缺失、引用问题或分类歧义;必须绑定目标或证据 URI。由 wiki_opa_worker 沉淀。" + frontmatter: + - name: id + type: string + required: true + description: "可读稳定标识,格式 opa-<category>-<reason>-<target>-<section>-<title>,禁止哈希/UUID" + - name: human_key + type: string + description: "人工可读去重键(标题+类别+section 归一化);自动生成,用于检查是否已有同问题记录" + - name: title + type: string + required: true + description: "短标题,如 'Procedure.target_components 源缺口:诊断预热功能异常'" + - name: description + type: string + required: true + description: "详细描述冲突双方/缺失内容/引用问题及适用条件" + - name: status + type: string + required: true + enum: [pending, resolved, rejected, superseded] + - name: category + type: string + required: true + enum: [conflict, gap] + description: "conflict=不同来源对同一事实/归因/关系给出矛盾结论;gap=来源缺失、引用无法解析、控制器身份不能判别等" + - name: reason_code + type: string + enum: [content_missing, fact_conflict] + required: true + description: "稳定的机器可读原因码;create_opa 自动由 category 推导,可显式覆盖" + - name: scope + type: string + enum: [entity, source_honest] + description: "entity=实体之间/内部的冲突;source_honest=属源数据缺口,不是漏抽" + - name: subtype + type: string + enum: [wiki_error, category_error, case_feedback, user_feedback] + description: "问题来源类型;wiki_error=构建期 wiki 问题" + - name: target_uri + type: ref + description: "受影响的正式实体 URI(viking://);缺失类 OPA 必须引用观察到问题的页面" + - name: target_path + type: string + description: "文件路径(实体不存在时作为回退定位)" + - name: target_section + type: string + description: "目标实体的具体 section 或 frontmatter 字段名,如 target_components" + - name: source_chapter + type: string + description: "触发问题的 raw 章节定位(可选)" + - name: evidence_uris + type: list + required: true + description: "支持证据 URI 列表;冲突必须引用双方,缺失必须引用实体页与相关 raw 章节" + - name: related_uris + type: list + description: "关联 URI(相邻实体/章节),用于后续检索下探" + - name: solution + type: string + description: "已应用的处置描述;status=resolved 时填写,否则留空字符串" + - name: finding + type: string + required: true + description: "详细写冲突双方、字段、取值和适用条件;缺失类写观察到什么" + - name: missing + type: string + required: true + description: "详细写缺什么、缺在哪个 section、为什么阻断后续处理" + - name: recommendation + type: string + required: true + description: "可执行的下一步(对谁、做什么、怎么验证)" + - name: report_count + type: int + description: "同一问题被重复报告的累计次数;重复报告递增而非新建" + example: | + --- + id: "opa-gap-content_missing-wiki_Procedure_诊断预热功能异常-target_components-源缺口" + human_key: "Procedure 诊断预热功能异常 target_components 源缺口" + title: "Procedure.target_components 源缺口:诊断预热功能异常" + description: "Procedure 诊断预热功能异常 的 target_components 为空:目标部件(预热回路/预热塞)在来源手册中无 BOM 型号,按 Component 物化门槛(品牌+型号唯一可识别)不建实体。属源数据缺口,非漏抽。" + status: "pending" + category: "gap" + reason_code: "content_missing" + scope: "source_honest" + subtype: "wiki_error" + target_uri: "viking://resources/<wiki_namespace>/Procedure/diagnosis/诊断预热功能异常" + target_path: "" + target_section: "target_components" + source_chapter: "" + evidence_uris: ["viking://resources/<raw_namespace>/manual-sy215/chapters/14_暖风空调/14_1 预热塞检修/chapter.md"] + related_uris: [] + solution: "" + finding: "目标部件在来源手册中无 BOM 型号,未达 Component 物化门槛,故 target_components 为空。" + missing: "缺预热回路/预热塞的 Component 实体及其 URI,导致 Procedure 悬空无 target。" + recommendation: "保留 open_gap 标记;待 BOM 基线提供预热塞型号后再物化 Component 并回填 target_components。" + report_count: 1 + --- + ## 问题描述 + Procedure 诊断预热功能异常 的 target_components 为空:目标部件(预热回路/预热塞)在来源手册中无 BOM 型号,按 Component 物化门槛(品牌+型号唯一可识别)不建实体。属源数据缺口,非漏抽。 + ## 冲突点 + 未发现来源矛盾;属源数据缺失。 + ## 缺失点 + target_components 缺少可解析 Component URI;预热回路/预热塞未物化。 + ## 建议 + 保留 open_gap,待 BOM 基线提供预热塞型号后再物化 Component 并回填 target_components。 + ## 证据 + - [原手册章节](viking://resources/<raw_namespace>/manual-sy215/chapters/14_暖风空调/14_1 预热塞检修/chapter.md) + ## 关联解决方案 + - [对应 OPS](viking://resources/<namespace>/OP/OpS/ops-gap-content_missing-wiki_Procedure_诊断预热功能异常-bom补充建议) + + OPS: + description: "针对一个 OPA 的专家修改建议;由 wiki_ops_worker 基于 OpenViking 检索形成,状态默认 unconfirmed。" + frontmatter: + - name: id + type: string + required: true + description: "可读稳定标识,格式 ops-<opa可读名>-<建议短标题>,禁止哈希" + - name: parent_opa + type: ref + required: true + description: "父 OPA 的完整 URI" + - name: title + type: string + required: true + - name: status + type: string + required: true + enum: [unconfirmed, confirmed, rejected] + description: "新建必须 unconfirmed;不得直接写成 confirmed" + - name: target_uri + type: ref + description: "同 OPA 的 target;缺省继承 OPA" + - name: solution + type: string + required: true + description: "建议修改的具体字段/section 与改动内容;原数据确实缺失时明确保持 open_gap,不补写猜测" + - name: analysis + type: string + required: true + description: "问题分析:为什么该建议能解决 OPA 中的冲突或缺失;冲突必须并列双方及需裁决条件" + - name: evidence_uris + type: list + required: true + description: "至少一个真实检索返回的 URI(viking://),禁止拼接" + - name: related_uris + type: list + description: "关联 URI" + example: | + --- + id: "ops-gap-content_missing-wiki_Procedure_诊断预热功能异常-bom补充建议" + parent_opa: "viking://resources/<namespace>/OP/OpA/opa-gap-content_missing-wiki_Procedure_诊断预热功能异常-target_components-源缺口" + title: "BOM 到位后补建预热 Component 并回填 target_components" + status: "unconfirmed" + target_uri: "viking://resources/<wiki_namespace>/Procedure/diagnosis/诊断预热功能异常" + solution: "保留 open_gap;待 BOM 基线提供预热塞型号后,物化 Component 预热回路/预热塞,并 patch_entity 回填 target_components。" + analysis: "OPA 显示 target_components 为空源于来源无 BOM 型号(源数据缺口),fork 到 Component 物化门槛(品牌+型号唯一可识别)。已检索 raw 章节确认预热回路归属暖风空调系统,与 OPA 定位一致。冲突无;建议在 BOM 数据可用后闭环。" + evidence_uris: ["viking://resources/<raw_namespace>/manual-sy215/chapters/14_暖风空调/14_1 预热塞检修/chapter.md"] + related_uris: ["viking://resources/<namespace>/Procedure/diagnosis/诊断预热功能异常"] + --- + ## 关联问题 + - [OPA](viking://resources/<namespace>/OP/OpA/opa-gap-content_missing-wiki_Procedure_诊断预热功能异常-target_components-源缺口) + ## 专家分析 + OPA 显示 target_components 为空源于来源无 BOM 型号(源数据缺口),fork 到 Component 物化门槛(品牌+型号唯一可识别)。已检索 raw 章节确认预热回路归属暖风空调系统,与 OPA 定位一致。冲突无;建议在 BOM 数据可用后闭环。 + ## 解决方案描述 + 保留 open_gap;待 BOM 基线提供预热塞型号后,物化 Component 预热回路/预热塞,并 patch_entity 回填 target_components。 + ## 证据引用 + - [原手册章节](viking://resources/<raw_namespace>/manual-sy215/chapters/14_暖风空调/14_1 预热塞检修/chapter.md) + ## 关联资源 + - viking://resources/<namespace>/Procedure/diagnosis/诊断预热功能异常 + + OPL: + description: "OPA+OPS 形成的初版知识提案;由 wiki_opl_worker 组合,状态强制 unconfirmed,不直接发布为正式实体。" + frontmatter: + - name: id + type: string + required: true + description: "可读稳定标识,格式 opl-<opa可读名>-<提案短标题>,禁止哈希" + - name: title + type: string + required: true + - name: parent_opa + type: ref + required: true + description: "父 OPA 的完整 URI" + - name: ops_uris + type: list + required: true + description: "选用的 OPS URI 列表,至少一条;必须真实存在且 attach 到该 OPA 下" + - name: target_uri + type: ref + description: "拟修改的正式实体 URI;缺省继承 OPA" + - name: status + type: string + required: true + enum: [unconfirmed] + description: "新建强制 unconfirmed;确认后才由正式写入 worker 按 diff/merge/patch 落地" + - name: proposal + type: string + required: true + description: "详细描述拟加入/修改哪个实体的哪个 section、关系方向、来源依据和不确定部分" + - name: rationale + type: string + required: true + description: "解释证据如何支持该提案,以及哪些地方仍需确认" + - name: evidence_uris + type: list + required: true + description: "合并自 OPA 与所选 OPS 的证据 URI" + - name: related_uris + type: list + description: "关联 URI" + example: | + --- + id: "opl-gap-content_missing-wiki_Procedure_诊断预热功能异常-bom补充提案" + title: "BOM 到位后补建预热 Component 并回填 target_components" + parent_opa: "viking://resources/<namespace>/OP/OpA/opa-gap-content_missing-wiki_Procedure_诊断预热功能异常-target_components-源缺口" + ops_uris: ["viking://resources/<namespace>/OP/OpS/ops-gap-content_missing-wiki_Procedure_诊断预热功能异常-bom补充建议"] + target_uri: "viking://resources/<wiki_namespace>/Procedure/diagnosis/诊断预热功能异常" + status: "unconfirmed" + proposal: "待 BOM 基线提供预热塞型号后:1) 物化 Component 预热回路/预热塞(品牌+型号唯一可识别);2) patch_entity 为 Procedure 诊断预热功能异常 回填 target_components;3) 将 OPA 标记 resolved。" + rationale: "OPS 确认此缺口源于源数据无 BOM 型号,非漏抽;raw 章节 14_1 定位预热回路归属暖风空调系统,支持该提案。BOM 可用性仍是前置风险,需人工确认后再落地。" + evidence_uris: ["viking://resources/<raw_namespace>/manual-sy215/chapters/14_暖风空调/14_1 预热塞检修/chapter.md"] + related_uris: ["viking://resources/<namespace>/Procedure/diagnosis/诊断预热功能异常"] + --- + ## 关联 OPA + - [OPA](viking://resources/<namespace>/OP/OpA/opa-gap-content_missing-wiki_Procedure_诊断预热功能异常-target_components-源缺口) + ## 关联 OPS + - [OPS](viking://resources/<namespace>/OP/OpS/ops-gap-content_missing-wiki_Procedure_诊断预热功能异常-bom补充建议) + ## 初版知识提案 + 待 BOM 基线提供预热塞型号后:1) 物化 Component 预热回路/预热塞(品牌+型号唯一可识别);2) patch_entity 为 Procedure 诊断预热功能异常 回填 target_components;3) 将 OPA 标记 resolved。 + ## 形成依据 + OPS 确认此缺口源于源数据无 BOM 型号,非漏抽;raw 章节 14_1 定位预热回路归属暖风空调系统,支持该提案。BOM 可用性仍是前置风险,需人工确认后再落地。 + ## 证据引用 + - [原手册章节](viking://resources/<raw_namespace>/manual-sy215/chapters/14_暖风空调/14_1 预热塞检修/chapter.md) + ## 关联资源 + - viking://resources/<namespace>/Procedure/diagnosis/诊断预热功能异常 + > status: unconfirmed。OPL 仅供后续人工/专家确认,不能直接作为正式实体内容发布。 + +relations: + - name: possible_faults + description: "Symptom Profile 指向可能的 Fault" + domain: [SymptomProfile] + range: [Fault] + cardinality: many + + - name: symptom_refs + description: "Device 索引适用的 Symptom" + domain: [Device] + range: [Symptom] + cardinality: many + + - name: device_refs + description: "Symptom Profile 适用的 Device" + domain: [SymptomProfile] + range: [Device] + cardinality: many + + - name: direct_component_uri + description: "Symptom Profile 直接关联的 Component" + domain: [SymptomProfile] + range: [Component] + cardinality: one + + - name: affected_components + description: "Fault 影响的 Component" + domain: [Fault] + range: [Component] + cardinality: many + + - name: verification_procedures + description: "Fault 的验证 Procedure" + domain: [Fault] + range: [Procedure] + cardinality: many + + - name: repair_procedures + description: "Fault 的修复 Procedure" + domain: [Fault] + range: [Procedure] + cardinality: many + + - name: target_components + description: "Procedure 操作的 Component" + domain: [Procedure] + range: [Component] + cardinality: many + + - name: specification_refs + description: "Procedure 引用的 Component 规格(Component#spec_id)" + domain: [Procedure] + range: [Component] + cardinality: many + + - name: critical_components + description: "Device 的关键总成 Component" + domain: [Device] + range: [Component] + cardinality: many + + - name: controller_component + description: "DTC 的定义控制器 Component" + domain: [DTC] + range: [Component] + cardinality: one + + - name: related_faults + description: "DTC 可能关联的 Fault" + domain: [DTC] + range: [Fault] + cardinality: many + + - name: parent_symptom + description: "Profile 的父 Symptom" + domain: [SymptomProfile] + range: [Symptom] + cardinality: one diff --git a/src/wolfharness/capabilities/wiki/tickets/__init__.py b/src/wolfharness/capabilities/wiki/tickets/__init__.py new file mode 100644 index 000000000..81f8e62d8 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/tickets/__init__.py @@ -0,0 +1 @@ +"""wiki capability subpackage: tickets.""" diff --git a/src/wolfharness/capabilities/wiki/tickets/index.py b/src/wolfharness/capabilities/wiki/tickets/index.py new file mode 100644 index 000000000..a51f8fa37 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/tickets/index.py @@ -0,0 +1,60 @@ +"""Build-root index injection helpers for WikiBuildCapability. + +Provides a pure-function helper for formatting the config-resolved wiki +build roots as an ``<openviking-index>`` XML block — used by +``WikiBuildCapability.before_model_request()``. This is B-scheme: roots +come from configuration (wiki/raw/bom), not a live-server enumeration. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from wolfharness.capabilities.viking.utils import truncate_text + + +if TYPE_CHECKING: + from collections.abc import Sequence + + +_INDEX_BLOCK = ( + "<openviking-index>\n" + "Wiki resources are addressed by viking:// URIs. Active build roots " + "for this session:\n" + "{roots}\n" + "Use list_children / get_resource / read_resource to navigate and " + "never guess or splice namespaces.\n" + "</openviking-index>" +) + + +def _format_index_block( + roots: Sequence[tuple[str, str]], + *, + max_tokens: int, + limit: int, +) -> str: + """Format config-resolved build roots as an ``<openviking-index>`` block. + + Renders the first ``limit`` ``(label, uri)`` pairs as aligned + ``- label: uri`` lines, then budget-scales the whole block to + approximately ``max_tokens`` characters (chars-to-tokens 4:1 + heuristic, same as Viking's index/profile blocks). + + Args: + roots: ``(label, uri)`` pairs for each resolved build root. + max_tokens: Maximum token budget — content is truncated to + ``max_tokens * 4`` characters with a truncation indicator. + limit: Maximum number of root pairs to include. + + Returns: + A formatted ``<openviking-index>`` XML block string. Returns an + empty string if there are no roots. + """ + truncated = roots[:limit] + if not truncated: + return "" + + root_lines = "\n".join(f"- {label}: {uri}" for label, uri in truncated) + max_chars = max_tokens * 4 # rough chars-to-tokens heuristic + return truncate_text(_INDEX_BLOCK.format(roots=root_lines), max_chars) diff --git a/src/wolfharness/capabilities/wiki/tickets/opa.py b/src/wolfharness/capabilities/wiki/tickets/opa.py new file mode 100644 index 000000000..a898759ea --- /dev/null +++ b/src/wolfharness/capabilities/wiki/tickets/opa.py @@ -0,0 +1,2979 @@ +"""OPAMixin — OPA conflict/problem record handling for WikiBuildTools. + +Extracted from ``mcp_server.py``. OPA records live under ``OP/OpA/``, +reference at least one KB URI, and do not register a natural key (so they +never enter the finalize/audit entity index). +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from hashlib import sha256 +import json +import logging +from pathlib import Path +import re +from uuid import uuid4 + +from wolfharness.capabilities.wiki._helpers import _entity_batch_limit +from wolfharness.capabilities.wiki.models import ( + OPA_CLOSURE_STATUSES, + OPA_REASON_CODES, + OPAModel, + OPLModel, + OPSModel, + infer_opa_reason_code, +) +from wolfharness.capabilities.wiki.quality import ( + BuildProfile, + IssueDisposition, + SourceReadResult, + SourceReadStatus, + audit_issue_policy, + classify_raw_source_uri, + extract_sections, + extract_source_uris, + is_optional_relation_issue, + parse_frontmatter, +) +from wolfharness.capabilities.wiki.section_constants import ( + SECTION_IMPACT_SCOPE, + SECTION_OVERVIEW, + SECTION_POSSIBLE_FAILURE, + SECTION_REPAIR_METHOD, + SECTION_VERIFICATION, +) +from wolfharness.capabilities.wiki.wiki_build_deps import WikiBuildDeps + + +logger = logging.getLogger(__name__) + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + +_OPA_CATEGORY_DIRS: dict[str, str] = { + "gap": "内容缺失", + "conflict": "知识冲突", + "feedback": "专家反馈", +} + +# Relation/link gap codes that should NOT generate OPAs. These are +# relationship completeness issues (dangling refs, missing links, isolated +# entities) that are best fixed via rebuild_index/backlinks, not via the +# manual OPA→OPS→OPL review pipeline. Content-missing codes +# (source_missing, controller_identity, etc.) stay in OPA. +_RELATION_GAP_CODES: frozenset[str] = frozenset({ + # Policy-level relation codes + "dangling_relation_target", + "dangling_wiki_reference", + "dangling_reference", + "Procedure.specification_ref_unresolvable", + "Profile.parent_symptom_not_indexed_by_device", + "Profile.direct_component_not_in_device_bom", + "relationship_completeness", + # RequirementCheck relation-field codes (field_present with target_concepts) + "SymptomProfile.device_refs", + "SymptomProfile.direct_component_uri", + "SymptomProfile.possible_faults", + "Device.critical_components", + "Device.symptom_refs", + "DTC.related_faults", + "Fault.affected_components", + "Fault.procedures", + "Procedure.target_components", + # Relation-type errors caused by build bugs (broken relation sync), + # not genuine knowledge conflicts — suppress from fact_conflict OPA flooding. + "typed_relation_wrong_concept", +}) + +# Any code ending in ".body_link" is a section-link relation check. +_RELATION_GAP_SUFFIX = ".body_link" + + +def _is_relation_gap_code(code: str) -> bool: + """True when an audit issue code is a relation/link gap, not a content gap.""" + return code in _RELATION_GAP_CODES or code.endswith(_RELATION_GAP_SUFFIX) + + +_SECTION_ANNOTATION_RE = re.compile(r"(?:section|章节)\s*[::]\s*([^\)\]\n,,]+)", re.IGNORECASE) + +# Audit rules use stable machine field names while entity pages use Chinese +# section headings. Keep this mapping in one place so discovery, audit +# downgrading, and finalize all agree on what it means for an OPA to be +# attached to a page section. +_SECTION_ALIASES: dict[str, tuple[str, ...]] = { + "affected_components": (SECTION_IMPACT_SCOPE,), + "verification_procedures": (SECTION_VERIFICATION,), + "repair_procedures": (SECTION_REPAIR_METHOD,), + "related_faults": (SECTION_POSSIBLE_FAILURE,), + "possible_faults": (SECTION_POSSIBLE_FAILURE,), + "assembly_parts": (SECTION_OVERVIEW,), + "target_components": ("操作目的",), + "critical_components": ("关重件清单",), +} + + +def opa_section_names(target_section: str, annotations: str = "") -> set[str]: + """Return machine and human names that can identify one page section.""" + names: set[str] = set() + value = target_section.strip() + if value: + names.add(value) + names.add(value.rsplit(".", 1)[-1]) + names.update(part.strip() for part in value.replace("|", "/").split("/") if part.strip()) + names.update(_SECTION_ALIASES.get(value.rsplit(".", 1)[-1], ())) + names.update( + match.strip() for match in _SECTION_ANNOTATION_RE.findall(annotations) if match.strip() + ) + return names + + +class OPAMixin(WikiBuildDeps): + """OPA conflict/problem record tools mixin for ``WikiBuildTools``.""" + + def _opa_dir(self) -> Path: + """Return the ``OP/OpA`` directory, creating it if absent.""" + path = self.store.root / self.store.CONCEPT_DIRS["OPA"] + self.store.mkdir_p(self.store.CONCEPT_DIRS["OPA"]) + return path + + def _opa_dir_key(self) -> str: + """Return the backend-relative OPA directory key.""" + key = self.store.CONCEPT_DIRS["OPA"] + self.store.mkdir_p(key) + return key + + def _target_hash(self, target_uri: str) -> str: + """Return a stable 16-char directory segment for a target URI.""" + canonical = self.store.resolve_redirect(target_uri.strip()) if target_uri.strip() else "" + return sha256(canonical.encode("utf-8")).hexdigest()[:16] if canonical else "" + + def _opa_key(self, opa_id: str, category: str = "", target_uri: str = "") -> str: + """Return the backend-relative OPA key, optionally under a category and target subdir.""" + base = self._opa_dir_key() + subdir = _OPA_CATEGORY_DIRS.get(category.strip().lower(), "") + th = self._target_hash(target_uri) + parts = [base] + if subdir: + parts.append(subdir) + if th: + parts.append(th) + parts.append(f"{opa_id}.md") + return "/".join(parts) + + def _find_opa_key(self, opa_id: str) -> str | None: + """Locate an OPA by id, searching flat, category subdirs, and target-hash subdirs.""" + base = self._opa_dir_key() + flat = f"{base}/{opa_id}.md" + if self.store.read_text(flat) is not None: + return flat + for subdir in _OPA_CATEGORY_DIRS.values(): + key = f"{base}/{subdir}/{opa_id}.md" + if self.store.read_text(key) is not None: + return key + for key in self.store.list_dir(base, recursive=True): + if key.endswith(f"/{opa_id}.md") and self.store.read_text(key) is not None: + return key + return None + + def _op_dir_key(self, concept: str) -> str: + """Return and create the storage key for one OP record type.""" + key = self.store.CONCEPT_DIRS[concept] + self.store.mkdir_p(key) + return key + + def _op_key(self, concept: str, record_id: str, target_uri: str = "") -> str: + base = self._op_dir_key(concept) + th = self._target_hash(target_uri) + if th: + return f"{base}/{th}/{record_id}.md" + return f"{base}/{record_id}.md" + + def _find_op_key(self, concept: str, record_id: str) -> str | None: + """Locate an OP record by id, trying flat path then target-hash subdirs.""" + base = self._op_dir_key(concept) + flat = f"{base}/{record_id}.md" + if self.store.read_text(flat) is not None: + return flat + for key in self.store.list_dir(base, recursive=True): + if key.endswith(f"/{record_id}.md"): + if self.store.read_text(key) is not None: + return key + return None + + def _op_files(self, concept: str, *, target_uri: str = "") -> list[tuple[str, str]]: + th = self._target_hash(target_uri) + if th: + subdir = f"{self._op_dir_key(concept)}/{th}" + records = self._read_md_dir(subdir) + if records: + return sorted(records) + records: list[tuple[str, str]] = [] + for key in self.store.list_dir(self._op_dir_key(concept), recursive=True): + if not key.endswith(".md"): + continue + content = self.store.read_text(key) + if content is not None: + records.append((key, content)) + return sorted(records) + + def _read_md_dir(self, dir_key: str) -> list[tuple[str, str]]: + records: list[tuple[str, str]] = [] + for key in self.store.list_dir(dir_key, recursive=False): + if not key.endswith(".md"): + continue + content = self.store.read_text(key) + if content is not None: + records.append((key, content)) + return records + + def _opa_files(self, *, target_uri: str = "") -> list[tuple[str, str]]: + """Read OPA Markdown files through local or Viking storage. + + When ``target_uri`` is given, only the matching target-hash + subdir is read (O(1) directory listing); otherwise a full + recursive scan is performed. + """ + th = self._target_hash(target_uri) + base = self._opa_dir_key() + if th: + records: list[tuple[str, str]] = [] + for subdir in _OPA_CATEGORY_DIRS.values(): + records.extend(self._read_md_dir(f"{base}/{subdir}/{th}")) + return sorted(records) + records: list[tuple[str, str]] = [] + for key in self.store.list_dir(base, recursive=True): + if not key.endswith(".md"): + continue + content = self.store.read_text(key) + if content is not None: + records.append((key, content)) + return sorted(records) + + def _opa_uri(self, opa_id: str, category: str = "") -> str: + """Return a URI that can actually be read from the active backend.""" + key = self._find_opa_key(opa_id) + if key is None: + key = self._opa_key(opa_id, category) + return f"{self.store.root_uri}/{key}" + + def _op_uri(self, concept: str, record_id: str) -> str: + if concept == "OPA": + return self._opa_uri(record_id) + key = self._find_op_key(concept, record_id) + if key is not None: + return f"{self.store.root_uri}/{key}" + return f"{self.store.root_uri}/{self.store.CONCEPT_DIRS[concept]}/{record_id}.md" + + def is_valid_op_uri(self, uri: str) -> bool: + """Return whether *uri* is an acceptable OPA/OPS reference URI. + + Mirrors the format rules enforced by ``_validate_opa_uris``: a + ``viking://resources/...`` provider reference (any namespace), a URI + inside the active wiki store's namespace, or a raw-source library + URI all pass. Existence is not checked -- the record may be filed + before the entity is merged, and reference scopes may live outside + the write store's namespace. + """ + return ( + uri.startswith("viking://resources/") + or self.store.is_wiki_uri(uri) + or classify_raw_source_uri(uri, raw_root_uri=self._raw_fs.root_uri) is not None + ) + + def _validate_opa_uris(self, target_uri: str, evidence_uris: list[str]) -> None: + """Require and validate the URI association of an OPA. + + Every OPA must reference at least one KB URI (the wiki entity under + conflict or the source corpus that surfaced the conflict). Formats + are checked; existence is not (the record may be filed before the + entity is merged). + + Accepts provider resource references outside the active write store. + OPA/OPS records are written to the provider-owned ticket scope, while + ``target_uri`` and ``evidence_uris`` are read/citation references and + may point at a different provider-owned resource scope. + """ + uris = [uri for uri in (target_uri, *evidence_uris) if uri] + if not uris: + raise ValueError( + f"OPA must reference at least one URI: set target_uri " + f"({self.store.root_uri}/...) or evidence_uris " + f"(viking://resources/... source chapters).", + ) + for uri in uris: + if not self.is_valid_op_uri(uri): + raise ValueError( + f"OPA URI must be a provider resource URI or local wiki/raw URI, got {uri!r}.", + ) + + @staticmethod + def _list_value(value: object) -> list[str]: + """Normalize a YAML list/scalar field into unique strings.""" + if isinstance(value, list): + return list(dict.fromkeys(str(item).strip() for item in value if str(item).strip())) + if isinstance(value, str) and value.strip(): + return [value.strip()] + return [] + + @staticmethod + def _list_of_dicts(value: object) -> list[dict[str, object]]: + """Read structured patch operations without accepting arbitrary YAML.""" + if not isinstance(value, list): + return [] + return [dict(item) for item in value if isinstance(item, dict)] + + @staticmethod + def _normalize_dedupe_text(value: object) -> str: + """Normalize issue text so title/source wording does not create duplicates.""" + return re.sub(r"\s+", " ", str(value or "").strip()).casefold() + + def _opa_dedupe_key( + self, + *, + category: str, + reason_code: str, + target_uri: str, + target_section: str, + finding: str, + missing: str, + ) -> str: + """Return an evidence-independent identity for one logical OPA issue. + + A target section is the unit of work for audit and repair. Multiple + workers may describe that same section differently, so descriptions + and evidence are deliberately not part of the primary key. When no + concrete target/section exists, the normalized finding remains the + fallback discriminator. + """ + target = self.store.resolve_redirect(target_uri.strip()) if target_uri.strip() else "" + identity = [ + self._normalize_dedupe_text(category), + self._normalize_dedupe_text(reason_code), + target, + self._normalize_dedupe_text(target_section), + ] + if not target or not target_section.strip(): + identity.extend(( + self._normalize_dedupe_text(finding), + self._normalize_dedupe_text(missing), + )) + raw = "\x1f".join(identity) + return "opa-dedupe-" + sha256(raw.encode("utf-8")).hexdigest()[:24] + + def _canonical_uri_identity(self, value: str) -> str: + """Return a redirect-stable identity while preserving URI fragments.""" + base, separator, fragment = value.partition("#") + canonical = self.store.resolve_redirect(base) if self.store.is_wiki_uri(base) else base + return canonical + (separator + fragment if separator else "") + + def _dedupe_uri_values( + self, values: list[str], *, exclude: set[str] | None = None + ) -> list[str]: + excluded = { + self._canonical_uri_identity(value.strip()) + for value in (exclude or set()) + if value.strip() + } + seen: set[str] = set() + result: list[str] = [] + for raw_value in values: + value = raw_value.strip() + if not value: + continue + identity = self._canonical_uri_identity(value) + if identity in excluded or identity in seen: + continue + seen.add(identity) + result.append(value) + return result + + @staticmethod + def _merge_observations(previous: object, incoming: str) -> str: + """Keep distinct observations while avoiding repeated generated prose.""" + separator = "\n\n--- additional observation ---\n\n" + values = [part.strip() for part in str(previous or "").split(separator)] + values.append(incoming.strip()) + seen: set[str] = set() + unique: list[str] = [] + for value in values: + if not value: + continue + identity = re.sub(r"[\s,。;;,.!?!?::]+", "", value).casefold() + if identity in seen: + continue + seen.add(identity) + unique.append(value) + values = unique + return "\n\n--- additional observation ---\n\n".join(values) + + def _find_opa_by_dedupe_key( + self, + dedupe_key: str, + *, + build_id: str = "", + ) -> tuple[str, dict] | None: + """Find an existing OPA by its logical issue identity.""" + if not dedupe_key: + return None + for key, content in self._opa_files(): + frontmatter = self._record_from_content(content) + if str(frontmatter.get("status", "")).strip().lower() in {"superseded", "rejected"}: + continue + if build_id and not self._opa_matches_build(key, frontmatter, build_id): + continue + if str(frontmatter.get("dedupe_key", "")).strip() == dedupe_key: + return key, frontmatter + # Legacy records did not persist a dedupe key. Compute one from + # their stable fields so the first new report consolidates them. + category = str(frontmatter.get("category", "")) + reason_code = str(frontmatter.get("reason_code", infer_opa_reason_code(category))) + computed = self._opa_dedupe_key( + category=category, + reason_code=reason_code, + target_uri=str(frontmatter.get("target_uri", "")), + target_section=str(frontmatter.get("target_section", "")), + finding=str(frontmatter.get("finding", "")), + missing=str(frontmatter.get("missing", "")), + ) + if computed == dedupe_key: + return key, frontmatter + return None + + def _opa_matches_build(self, key: str, frontmatter: dict, build_id: str) -> bool: + """Match a record to a build without reviving unscoped old records.""" + record_build_id = str(frontmatter.get("build_id", "")).strip() + if record_build_id: + return record_build_id == build_id + # Records without build_id were created before the checkpoint was + # initialised (common early in a build). Accept them as part of the + # current build rather than silently dropping current-build OPAs — + # the checkpoint build_id match is sufficient scoping. + checkpoint = self.store.read_json("index/build_checkpoint.json") + return ( + isinstance(checkpoint, dict) and str(checkpoint.get("build_id", "")).strip() == build_id + ) + + def _opa_category_from_key(self, key: str) -> str: + for category, subdir in _OPA_CATEGORY_DIRS.items(): + if f"/{subdir}/" in f"/{key}": + return category + return "" + + @staticmethod + def _record_from_content(content: str) -> dict[str, object]: + """Parse OP record: frontmatter + H1 title + body sections merged. + + Prose fields may live in YAML (legacy) or body (new format); body is + the fallback so both old and new files parse identically. Machine + fields always win from frontmatter. + """ + record: dict[str, object] = dict(parse_frontmatter(content or "")) + if not record.get("title"): + match = re.search(r"^#\s+(.+)$", content or "", re.MULTILINE) + if match is not None: + record["title"] = match.group(1).strip() + sections = extract_sections(content or "") + for field, section in ( + ("description", "问题描述"), + ("finding", "冲突点"), + ("missing", "缺失点"), + ("recommendation", "建议"), + ("analysis", "专家分析"), + ("solution", "解决方案描述"), + ("proposal", "初版知识提案"), + ("rationale", "形成依据"), + ): + if not record.get(field) and section in sections: + record[field] = sections[section] + return record + + @staticmethod + def _human_key(title: str, category: str, target_section: str) -> str: + """Create a compact readable display key for an OPA.""" + value = "__".join( + part.strip() for part in (category, target_section, title) if part.strip() + ) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"[\\/:*?\"<>|]+", "-", value) + return value[:160].strip("-_") or "wiki-issue" + + @staticmethod + def _clip_utf8(value: str, max_bytes: int) -> str: + """Clip a string so its UTF-8 encoding fits in ``max_bytes``. + + Filenames are byte-length-limited (255 on disk, and Viking's unzip + rejects longer paths), but the earlier character-based truncation + let CJK ids reach ~570 bytes. Never split a multibyte codepoint. + """ + encoded = value.encode("utf-8") + if len(encoded) <= max_bytes: + return value + return encoded[:max_bytes].decode("utf-8", "ignore") + + @staticmethod + def _readable_slug(value: str, *, fallback: str) -> str: + """Keep identity names searchable without using opaque hashes.""" + slug = re.sub(r"\s+", "-", value.strip()) + slug = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff._-]+", "-", slug) + slug = re.sub(r"-+", "-", slug).strip("-._") + return OPAMixin._clip_utf8(slug, 60).rstrip("-._") or fallback + + def _readable_opa_id( + self, + *, + category: str, + reason_code: str, + target_uri: str, + target_path: str, + target_section: str, + title: str, + dedupe_key: str = "", + ) -> str: + """Build a short, human-readable, dedupe-stable OPA filename. + + The issue type, the target name, and the title slug go into the + filename so the problem is recognizable at a glance; the dedupe + hash keeps it stable per logical issue. + """ + target = target_uri.rstrip("/").rsplit("/", 1)[-1] if target_uri else (target_path or title) + target_slug = self._readable_slug(target, fallback="target")[:20].rstrip("-._") or "target" + title_slug = self._readable_slug(title, fallback="issue")[:20].rstrip("-._") or "issue" + base = dedupe_key.removeprefix("opa-dedupe-") if dedupe_key else "" + suffix = base[:8] if len(base) >= 6 else "" + if not suffix: + source = f"{category}\x1f{reason_code}\x1f{target_uri}\x1f{target_section}\x1f{title}" + suffix = sha256(source.encode("utf-8")).hexdigest()[:8] + kind = self._readable_slug(category, fallback="issue")[:12].rstrip("-._") or "issue" + return self._clip_utf8(f"opa-{kind}-{target_slug}-{title_slug}-{suffix}", 80).rstrip("-._") + + @staticmethod + def _opa_open_for_gate(record: dict) -> bool: + """Whether an OPA still blocks finalize, keyed on closure_status not status.""" + closure = str(record.get("closure_status", "")).strip().lower() + status = str(record.get("status", "")).strip().lower() + if closure == "closed": + return False + if closure == "deferred": + return not str(record.get("closure_reason", "")).strip() + if closure == "open": + return True + # Legacy record without closure_status: preserve prior gate semantics + # so already-finalized builds are not retroactively blocked. + return status == "pending" + + def _unresolved_opa_records(self, *, build_id: str | None = None) -> list[dict]: + """Return active OPA records for the selected build scope.""" + records: list[dict] = [] + for key, content in self._opa_files(): + frontmatter = self._record_from_content(content) + opa_id = Path(key).stem + if str(frontmatter.get("status", "")).lower() not in { + "resolved", + "rejected", + "superseded", + }: + record_build_id = str(frontmatter.get("build_id", "")).strip() + if build_id is not None and not self._opa_matches_build(key, frontmatter, build_id): + continue + records.append( + { + "opa_id": str(frontmatter.get("id", opa_id)), + "human_key": str( + frontmatter.get("human_key", frontmatter.get("title", opa_id)) + ), + "uri": self._opa_uri( + str(frontmatter.get("id", opa_id)), self._opa_category_from_key(key) + ), + "status": str(frontmatter.get("status", "")), + "category": str(frontmatter.get("category", "")), + "reason_code": str( + frontmatter.get( + "reason_code", + infer_opa_reason_code(str(frontmatter.get("category", ""))), + ), + ), + "target_uri": str(frontmatter.get("target_uri", "")), + "target_section": str(frontmatter.get("target_section", "")), + "build_id": record_build_id, + }, + ) + return records + + def _is_explicit_tracked_record(self, record: dict) -> bool: + """Return whether a pending OPA is bound to a marked page section.""" + target_uri = str(record.get("target_uri", "")).strip() + target_section = str(record.get("target_section", "")).strip() + if not target_uri or not target_section: + return False + content = self.read_resource(target_uri) + if content is None: + return False + sections = extract_sections(content) + # discover_opa keeps a stable machine code in target_section (for + # example ``Component.fault.body_link``), while the page uses a + # human-readable heading. Accept the section annotation emitted in + # the OPA description/finding so an explicit open_gap can downgrade + # the corresponding required relation to a tracked warning. + annotations = " ".join( + str(record.get(field, "")) + for field in ("description", "finding", "missing", "recommendation") + ) + section_names = opa_section_names(target_section, annotations) + matching_sections = [ + value + for heading, value in sections.items() + if any(name == heading or name in heading for name in section_names) + ] + category = str(record.get("category", "")).strip().lower() + markers = ( + ("conflict_pending", "冲突") + if category == "conflict" + else ("open_gap", "待补充", "来源未说明", "来源缺失", "未物化") + ) + if matching_sections: + return any( + any(marker in value.lower() for marker in markers) for value in matching_sections + ) + frontmatter = parse_frontmatter(content) + return target_section.rsplit(".", 1)[-1] in frontmatter and any( + marker in content.lower() for marker in markers + ) + + def _is_explicit_gap_record(self, record: dict) -> bool: + """Backward-compatible alias for source-honest gap callers.""" + return str( + record.get("category", "") + ).strip().lower() == "gap" and self._is_explicit_tracked_record(record) + + def create_opa( + self, + *, + title: str, + description: str, + category: str = "conflict", + reason_code: str = "", + scope: str = "entity", + subtype: str = "wiki_error", + target_uri: str = "", + target_path: str = "", + target_section: str = "", + source_chapter: str = "", + evidence_uris: list[str] | None = None, + status: str = "pending", + solution: str = "", + opa_id: str = "", + finding: str = "", + missing: str = "", + recommendation: str = "", + related_uris: list[str] | None = None, + dedupe_key: str = "", + build_id: str = "", + closure_status: str = "", + closure_reason: str = "", + skip_dedupe_lookup: bool = False, + ) -> dict: + """结构化落盘一条 OPA 冲突/问题记录,替代手写 md。. + + 存入 ``OP/OpA/<opa_id>.md``(YAML frontmatter + 证据 + 可解析 URI), + 不注册 natural key,因此不会进入 finalize/audit 的实体索引。 + + ``opa_id`` 未提供时自动生成(同 target_uri + title 稳定复用)。 + 返回 ``{opa_id, uri, path, title, target_uri}``。 + + ``skip_dedupe_lookup`` 为 True 时跳过全目录去重检索——外部专家 + 路径的 target_uri 是确定性输入,不需要扫全库找重复 OPA。 + ``feedback`` 类别强制按"一次反馈 = 一条记录"处理:不做跨记录 + 去重/合并(同一问题多人反馈应各自成档),自动生成的 id 每次 + 追加随机片段保证记录唯一;仅当调用方显式传入 ``opa_id`` + (如 ``opa_uri`` 修订)时才对既有记录做原地更新。 + """ + effective_category = category.strip().lower() or "conflict" + is_feedback = effective_category == "feedback" + effective_reason_code = reason_code or infer_opa_reason_code(effective_category) + effective_target = target_uri.strip() + if not build_id: + checkpoint = self.store.read_json("index/build_checkpoint.json") + if isinstance(checkpoint, dict): + build_id = str(checkpoint.get("build_id", "")).strip() + effective_section = target_section.strip() + effective_finding = finding.strip() + effective_missing = missing.strip() + supplied_dedupe_key = dedupe_key.strip() + dedupe_key = supplied_dedupe_key or self._opa_dedupe_key( + category=effective_category, + reason_code=effective_reason_code, + target_uri=effective_target, + target_section=effective_section, + finding=effective_finding, + missing=effective_missing, + ) + existing_by_key: tuple[str, dict] | None = None + if is_feedback: + # 一次反馈 = 一条记录:同一问题多人反馈应各自成档,不做跨记录 + # 去重/合并。显式传入 opa_id(如 opa_uri 修订)仍走原地更新。 + skip_dedupe_lookup = True + if not skip_dedupe_lookup: + existing_by_key = self._find_opa_by_dedupe_key(dedupe_key, build_id=build_id) + if existing_by_key is not None: + # Dedupe is authoritative even when a caller supplies a readable + # or legacy hash-like opa_id. Otherwise repeated merge/conflict + # paths create parallel OPA records for one target section. + opa_id = Path(existing_by_key[0]).stem + elif not opa_id: + opa_id = self._readable_opa_id( + category=effective_category, + reason_code=effective_reason_code, + target_uri=effective_target, + target_path=target_path, + target_section=effective_section, + title=title, + dedupe_key=dedupe_key, + ) + if is_feedback: + # 保证每次提交都生成唯一文件名,避免相同 target/章节/标题 + # 的反馈撞到同一文件被追加合并。 + opa_id = self._clip_utf8(f"{opa_id}-{uuid4().hex[:8]}", 80).rstrip("-._") + previous: dict[str, object] = {} + existing_key = self._find_opa_key(opa_id) + if existing_key and build_id: + existing_content = self.store.read_text(existing_key) + existing_frontmatter = parse_frontmatter(existing_content or "") + existing_build_id = str(existing_frontmatter.get("build_id", "")).strip() + if existing_build_id != build_id: + # Readable OPA IDs are intentionally stable within a build. + # Add a build suffix on a cross-build collision so a new run + # never mutates an old run's record. + build_suffix = sha256(build_id.encode("utf-8")).hexdigest()[:10] + opa_id = f"{opa_id}-b{build_suffix}" + existing_key = self._find_opa_key(opa_id) + existing = self.store.read_text(existing_key) if existing_key else None + if existing is not None: + previous = self._record_from_content(existing) + dedupe_key = str(previous.get("dedupe_key", "")).strip() or dedupe_key + allowed_statuses = {"pending", "resolved", "rejected", "superseded"} + if status not in allowed_statuses: + raise ValueError(f"Unsupported OPA status: {status!r}") + if not category.strip(): + raise ValueError("OPA category must not be empty") + previous_evidence = self._list_value(previous.get("evidence_uris")) + previous_related = self._list_value(previous.get("related_uris")) + effective_target = effective_target or str(previous.get("target_uri", "")) + evidence = self._dedupe_uri_values([*previous_evidence, *list(evidence_uris or [])]) + if effective_target: + evidence = [uri for uri in evidence if uri != effective_target] + related = self._dedupe_uri_values( + [*previous_related, *list(related_uris or [])], + exclude=set(evidence) | ({effective_target} if effective_target else set()), + ) + self._validate_opa_uris(effective_target, [*evidence, *related]) + effective_category = effective_category or str(previous.get("category", "conflict")) + key = existing_key or self._opa_key(opa_id, effective_category, target_uri=effective_target) + effective_reason_code = ( + reason_code + or str(previous.get("reason_code", "")) + or infer_opa_reason_code(effective_category) + ) + if effective_reason_code not in OPA_REASON_CODES: + raise ValueError( + f"Unsupported OPA reason_code: {effective_reason_code!r}; expected one of {', '.join(OPA_REASON_CODES)}", + ) + effective_title = title or str(previous.get("title", opa_id)) + effective_description = self._merge_observations( + previous.get("description", ""), description + ) + human_key = str(previous.get("human_key", "")).strip() or self._human_key( + effective_title, + effective_category, + target_section or str(previous.get("target_section", "")), + ) + effective_section = effective_section or str(previous.get("target_section", "")) + effective_finding = self._merge_observations(previous.get("finding", ""), effective_finding) + effective_missing = self._merge_observations(previous.get("missing", ""), effective_missing) + effective_recommendation = self._merge_observations( + previous.get("recommendation", ""), recommendation + ) + if status == "pending": + required_fields = { + "description": effective_description, + "finding": effective_finding, + "missing": effective_missing, + "recommendation": effective_recommendation, + # A readable target is already the OPA binding. Do not copy it + # into evidence_uris just to satisfy the non-empty check. + "evidence_uris": "\n".join(evidence) or effective_target, + } + missing_fields = [name for name, value in required_fields.items() if not value.strip()] + if missing_fields: + raise ValueError( + "pending OPA requires structured fields: " + ", ".join(missing_fields), + ) + count_value: object = previous.get("report_count") + report_count: int = (count_value if isinstance(count_value, int) else 0) + 1 + model = OPAModel( + opa_id=opa_id, + title=effective_title, + description=effective_description, + human_key=human_key, + category=effective_category, + reason_code=effective_reason_code, + scope=scope or str(previous.get("scope", "entity")), + subtype=subtype or str(previous.get("subtype", "wiki_error")), + target_uri=effective_target, + target_path=target_path or str(previous.get("target_path", "")), + target_section=target_section or str(previous.get("target_section", "")), + source_chapter=source_chapter or str(previous.get("source_chapter", "")), + evidence_uris=evidence, + status=status, + solution=solution or str(previous.get("solution", "")), + finding=effective_finding, + missing=effective_missing, + recommendation=effective_recommendation, + related_uris=related, + report_count=report_count, + dedupe_key=dedupe_key or str(previous.get("dedupe_key", "")), + build_id=build_id or str(previous.get("build_id", "")), + closure_status=closure_status or str(previous.get("closure_status", "open")) or "open", + closure_reason=closure_reason or str(previous.get("closure_reason", "")), + ) + self.store.write_text_durable( + key, + model.to_markdown(ops_uri=self._op_uri("OPS", model.opa_id.replace("opa-", "ops-", 1))), + ) + logger.info("OPA created: %s (target=%s)", opa_id, target_uri or target_path or "-") + return { + "opa_id": opa_id, + "uri": self._opa_uri(opa_id, effective_category), + "path": key, + "title": effective_title, + "human_key": human_key, + "target_uri": effective_target, + "reason_code": model.reason_code, + "status": status, + "closure_status": model.closure_status, + "report_count": model.report_count, + "dedupe_key": model.dedupe_key, + "build_id": model.build_id, + } + + @staticmethod + def _record_id(value: str, prefix: str) -> str: + """Accept either a record id or a backend URI and return its id.""" + token = value.rstrip("/").rsplit("/", 1)[-1].removesuffix(".md") + return token if token.startswith(prefix + "-") else value + + def _opa_record(self, value: str) -> tuple[str, dict, str]: + opa_id = self._record_id(value, "opa") + key = self._find_opa_key(opa_id) + if key is None: + raise FileNotFoundError(f"OPA not found: {value}") + content = self.store.read_text(key) + if content is None: + raise FileNotFoundError(f"OPA not found: {value}") + category = "" + for cat, subdir in _OPA_CATEGORY_DIRS.items(): + if f"/{subdir}/" in key: + category = cat + break + return opa_id, parse_frontmatter(content), self._opa_uri(opa_id, category) + + def _validate_op_evidence(self, evidence_uris: list[str], related_uris: list[str]) -> None: + self._validate_opa_uris("", [*evidence_uris, *related_uris]) + unreadable = [ + uri + for uri in dict.fromkeys([*evidence_uris, *related_uris]) + if uri + and not uri.startswith("viking://resources/") + and self.read_resource(uri) is None + and classify_raw_source_uri(uri, raw_root_uri=self._raw_fs.root_uri) is None + ] + if unreadable: + raise ValueError(f"OP records require readable evidence/related URIs: {unreadable}") + + def _op_reference_quality( + self, + *, + target_uri: str, + evidence_uris: list[str], + related_uris: list[str], + ) -> list[str]: + """Return hard quality violations for a generated OP reference set.""" + issues: list[str] = [] + target = target_uri.strip() + evidence = [str(uri).strip() for uri in evidence_uris if str(uri).strip()] + related = [str(uri).strip() for uri in related_uris if str(uri).strip()] + + def canonical(uri): + return self.store.resolve_redirect(uri) if self.store.is_wiki_uri(uri) else uri + + evidence_keys = [canonical(uri) for uri in evidence] + related_keys = [canonical(uri) for uri in related] + target_key = canonical(target) if target else "" + if len(evidence_keys) != len(set(evidence_keys)) or len(related_keys) != len( + set(related_keys) + ): + issues.append("REFERENCE_DUPLICATE") + if target_key and (target_key in evidence_keys or target_key in related_keys): + issues.append("REFERENCE_TARGET_REPEATED") + if set(evidence_keys) & set(related_keys): + issues.append("REFERENCE_EVIDENCE_REPEATED_IN_RELATED") + try: + self._validate_opa_uris(target, [*evidence, *related]) + except ValueError: + issues.append("REFERENCE_URI_INVALID") + unreadable: list[str] = [] + for uri in dict.fromkeys([target, *evidence, *related]): + if not uri: + continue + if self.store.is_wiki_uri(uri): + readable = self.read_resource(uri) is not None + else: + raw_result = self.read_raw_source(uri) + readable = raw_result.status is SourceReadStatus.OK + if not readable: + unreadable.append(uri) + if unreadable: + issues.append("REFERENCE_UNREADABLE") + return list(dict.fromkeys(issues)) + + def _require_parent_target(self, *, parent_target: str, requested_target: str) -> str: + """Keep all records in an OP chain attached to the same target.""" + expected = parent_target.strip() + requested = requested_target.strip() + if requested and ( + not expected + or self.store.resolve_redirect(requested) != self.store.resolve_redirect(expected) + ): + raise ValueError("target_uri must match parent OPA target_uri") + return expected + + def _ops_retrieval_receipt( + self, + query: str, + *, + limit: int, + ) -> tuple[list[str], list[str]]: + """Run bounded retrieval over configured Wiki and raw roots.""" + normalized_query = query.strip() + if not normalized_query: + raise ValueError("OPS requires a non-empty retrieval_query") + if limit < 1 or limit > 50: + raise ValueError("OPS retrieval_limit must be between 1 and 50") + scopes = [self.store.root_uri, self._raw_fs.root_uri] + hit_uris: list[str] = [] + for scope in scopes: + hits = self.find_wiki( + normalized_query, + target_uri=scope, + limit=limit, + deep=True, + ) + hit_uris.extend( + str(hit.get("uri", "")).strip() for hit in hits if str(hit.get("uri", "")).strip() + ) + return scopes, list(dict.fromkeys(hit_uris)) + + def _retrieval_scopes_match(self, scopes: list[str]) -> bool: + """Accept the current raw root and the historical ``<root>/raw`` alias.""" + scope_set = set(scopes) + raw_root = self._raw_fs.root_uri.rstrip("/") + raw_aliases = {raw_root, raw_root + "/raw"} + return self.store.root_uri in scope_set and bool(raw_aliases & scope_set) + + def create_ops( + self, + *, + parent_opa: str, + title: str, + retrieval_query: str, + retrieved_uris: list[str], + analysis: str, + solution: str, + evidence_uris: list[str] | None = None, + related_uris: list[str] | None = None, + target_uri: str = "", + status: str = "unconfirmed", + ops_id: str = "", + retrieval_limit: int = 10, + candidate_content: str = "", + candidate_operations: list[dict[str, object]] | None = None, + expected_sha256: str = "", + source_type: str = "pipeline", + ) -> dict: + """Persist one source-backed expert recommendation for an OPA. + + ``source_type="external_expert"`` submissions always mint a fresh + record (one submission = one OPS) unless an explicit ``ops_id`` is + supplied; the pipeline default consolidates concurrent workers on + the same retrieval task into one record. + """ + opa_id, opa_fm, opa_uri = self._opa_record(parent_opa) + if status != "unconfirmed": + raise ValueError("New OPS records must remain status=unconfirmed") + candidate_operations = list(candidate_operations or []) + if candidate_content.strip() and candidate_operations: + raise ValueError("OPS must provide candidate_content or candidate_operations, not both") + if any(not isinstance(operation, dict) for operation in candidate_operations): + raise ValueError("OPS candidate_operations must contain only objects") + if expected_sha256 and _SHA256_RE.fullmatch(expected_sha256) is None: + raise ValueError("OPS expected_sha256 must be a 64-character lowercase SHA-256 hash") + effective_target = self._require_parent_target( + parent_target=str(opa_fm.get("target_uri", "")), + requested_target=target_uri, + ) + evidence = self._dedupe_uri_values( + [*self._list_value(opa_fm.get("evidence_uris")), *(evidence_uris or [])], + exclude={effective_target} if effective_target else set(), + ) + related = self._dedupe_uri_values( + [*self._list_value(opa_fm.get("related_uris")), *(related_uris or [])], + exclude=set(evidence) | ({effective_target} if effective_target else set()), + ) + self._validate_op_evidence(evidence, [effective_target, *related]) + target_is_readable = bool( + effective_target and self.read_resource(effective_target) is not None + ) + if ( + not analysis.strip() + or not solution.strip() + or (not evidence and not target_is_readable) + ): + raise ValueError( + "OPS requires detailed analysis, solution, and at least one evidence URI" + ) + retrieval_scopes, retrieval_hit_uris = self._ops_retrieval_receipt( + retrieval_query, + limit=retrieval_limit, + ) + used_uris = list(dict.fromkeys(uri.strip() for uri in retrieved_uris if uri.strip())) + missing_hits = [uri for uri in used_uris if uri not in retrieval_hit_uris] + if missing_hits: + raise ValueError( + f"OPS retrieved_uris were not returned by the server-side retrieval receipt: {missing_hits}", + ) + # The formal target is implicitly bound to the OPS; it need not be + # repeated in related_uris just to satisfy the retrieval receipt. + unbound_hits = [ + uri for uri in used_uris if uri not in {*evidence, *related, effective_target} + ] + if unbound_hits: + raise ValueError( + f"OPS retrieved_uris must also appear in evidence_uris or related_uris: {unbound_hits}", + ) + external_submission = source_type == "external_expert" + if not ops_id and not external_submission: + # Pipeline: consolidate concurrent workers scoped to the same OPA + # target + retrieval task into one record. + normalized_query = self._normalize_dedupe_text(retrieval_query) + for existing_key, existing_content in self._op_files("OPS"): + existing_fm = parse_frontmatter(existing_content) + existing_parent = str(existing_fm.get("parent_opa", "")) + existing_target = str(existing_fm.get("target_uri", "")) + if ( + str(existing_fm.get("status", "unconfirmed")).lower() == "unconfirmed" + and existing_parent in {opa_uri, opa_id} + and self.store.resolve_redirect(existing_target) + == self.store.resolve_redirect(effective_target) + and self._normalize_dedupe_text(existing_fm.get("retrieval_query", "")) + == normalized_query + ): + ops_id = Path(existing_key).stem + break + if not ops_id: + title_slug = ( + self._readable_slug(title, fallback="recommendation")[:20].rstrip("-._") + or "recommendation" + ) + ops_id = self._clip_utf8( + "ops-{}-{}-{}".format( + opa_id.removeprefix("opa-")[:20], + title_slug, + ( + # external_expert: one submission = one record, never + # merges into a prior suggestion. + uuid4().hex[:8] + if external_submission + else sha256( + f"{opa_uri}\x1f{effective_target}\x1f{title}".encode() + ).hexdigest()[:8] + ), + ), + 80, + ).rstrip("-._") + key = self._op_key("OPS", ops_id, target_uri=effective_target) + previous = self._record_from_content(self.store.read_text(key) or "") + model = OPSModel( + ops_id=ops_id, + parent_opa=opa_uri, + title=title or str(previous.get("title", ops_id)), + status=status, + target_uri=effective_target, + solution=self._merge_observations(previous.get("solution", ""), solution), + analysis=self._merge_observations(previous.get("analysis", ""), analysis), + retrieval_query=retrieval_query.strip() or str(previous.get("retrieval_query", "")), + retrieval_scopes=retrieval_scopes, + retrieval_hit_uris=list( + dict.fromkeys( + [ + *self._list_value(previous.get("retrieval_hit_uris")), + *retrieval_hit_uris, + ], + ), + ), + # A retrieval receipt describes this invocation. Do not carry + # stale used hits into a later retry where the evidence set may + # have changed. + retrieval_used_uris=used_uris, + evidence_uris=self._dedupe_uri_values( + [*self._list_value(previous.get("evidence_uris")), *evidence], + exclude={effective_target} if effective_target else set(), + ), + related_uris=self._dedupe_uri_values( + [*self._list_value(previous.get("related_uris")), *related], + exclude=set(evidence) | ({effective_target} if effective_target else set()), + ), + candidate_content=candidate_content or str(previous.get("candidate_content", "")), + candidate_operations=candidate_operations + or self._list_of_dicts(previous.get("candidate_operations")), + expected_sha256=expected_sha256 or str(previous.get("expected_sha256", "")), + source_type=source_type or str(previous.get("source_type", "pipeline")), + reviewed_by=str(previous.get("reviewed_by", "")), + review_notes=str(previous.get("review_notes", "")), + apply_status=str(previous.get("apply_status", "not_ready")), + apply_error=str(previous.get("apply_error", "")), + applied_at=str(previous.get("applied_at", "")), + applied_entity_sha256=str(previous.get("applied_entity_sha256", "")), + ) + self.store.write_text_durable(key, model.to_markdown()) + return { + "ops_id": ops_id, + "uri": self._op_uri("OPS", ops_id), + "parent_opa": opa_uri, + "target_uri": effective_target, + "status": "unconfirmed", + "retrieval_query": retrieval_query.strip(), + "retrieval_scopes": retrieval_scopes, + "retrieval_hit_uris": retrieval_hit_uris, + "retrieval_used_uris": used_uris, + "apply_status": model.apply_status, + } + + def get_ops( + self, + *, + parent_opa: str = "", + status: str = "", + target_uri: str = "", + limit: int = 50, + ) -> list[dict]: + """Read OPS recommendations, optionally scoped to one OPA/target.""" + parent_id = self._record_id(parent_opa, "opa") if parent_opa else "" + parent_uri = "" + if parent_id: + try: + _pid, _pfm, parent_uri = self._opa_record(parent_id) + except FileNotFoundError: + parent_uri = self._opa_uri(parent_id) + out: list[dict] = [] + for path, content in self._op_files("OPS", target_uri=target_uri): + fm = self._record_from_content(content) + record_parent = str(fm.get("parent_opa", "")) + if parent_uri and record_parent not in {parent_uri, parent_id}: + continue + if status and str(fm.get("status", "")) != status: + continue + if target_uri and str(fm.get("target_uri", "")) != target_uri: + continue + ops_id = str(fm.get("id", Path(path).stem)) + out.append( + { + "ops_id": ops_id, + "uri": self._op_uri("OPS", ops_id), + "path": path, + "parent_opa": record_parent, + "title": str(fm.get("title", ops_id)), + "analysis": str(fm.get("analysis", "")), + "solution": str(fm.get("solution", "")), + "status": str(fm.get("status", "unconfirmed")), + "target_uri": str(fm.get("target_uri", "")), + "retrieval_query": str(fm.get("retrieval_query", "")), + "retrieval_scopes": self._list_value(fm.get("retrieval_scopes")), + "retrieval_hit_uris": self._list_value(fm.get("retrieval_hit_uris")), + "retrieval_used_uris": self._list_value(fm.get("retrieval_used_uris")), + "evidence_uris": self._list_value(fm.get("evidence_uris")), + "related_uris": self._list_value(fm.get("related_uris")), + "candidate_content": str(fm.get("candidate_content", "")), + "candidate_operations": self._list_of_dicts(fm.get("candidate_operations")), + "expected_sha256": str(fm.get("expected_sha256", "")), + "source_type": str(fm.get("source_type", "pipeline")), + "reviewed_by": str(fm.get("reviewed_by", "")), + "review_notes": str(fm.get("review_notes", "")), + "apply_status": str(fm.get("apply_status", "not_ready")), + "apply_error": str(fm.get("apply_error", "")), + "applied_at": str(fm.get("applied_at", "")), + "applied_entity_sha256": str(fm.get("applied_entity_sha256", "")), + }, + ) + if len(out) >= max(1, limit): + break + return out + + def ingest_external_ops( + self, + *, + parent_opa: str = "", + title: str, + analysis: str, + solution: str, + evidence_uris: list[str], + expert_id: str = "", + expert_name: str = "", + related_uris: list[str] | None = None, + candidate_content: str = "", + candidate_operations: list[dict[str, object]] | None = None, + expected_sha256: str = "", + ops_id: str = "", + ops_uri: str = "", + ) -> dict[str, object]: + """Store external expert knowledge as an OPS draft, not an OPL. + + External input still requires business review. It is therefore kept + in the same editable OPS lifecycle as generated advice and cannot + update formal entities until ``update_ops(status="confirmed")`` and + an explicit ``apply_ops`` call. + + ``ops_uri`` lets an external expert revise an existing OPS record in + place: the URI resolves to its ops_id, and repeated submissions with + the same URI overwrite content (analysis / solution / evidence). + ``parent_opa`` is only required on first creation; revisions inherit + it from the existing record. + + Without ``ops_uri`` every submission mints its own OPS record (the + auto id carries a fresh random fragment), so one expert opinion is + one record — repeated or multi-person suggestions are never merged + into a prior one. + """ + if not title.strip() or not analysis.strip() or not solution.strip(): + raise ValueError("External OPS requires title, analysis, and solution") + if not evidence_uris: + raise ValueError("External OPS requires a non-empty evidence_uris list") + candidate_operations = list(candidate_operations or []) + if candidate_content.strip() and candidate_operations: + raise ValueError( + "External OPS must provide candidate_content or candidate_operations, not both" + ) + if any(not isinstance(operation, dict) for operation in candidate_operations): + raise ValueError("External OPS candidate_operations must contain only objects") + if expected_sha256 and _SHA256_RE.fullmatch(expected_sha256) is None: + raise ValueError( + "External OPS expected_sha256 must be a 64-character lowercase SHA-256 hash" + ) + if not ops_id and ops_uri: + ops_id = self._record_id(ops_uri, "ops") + if ops_id: + existing_key = self._find_op_key("OPS", ops_id) + existing_content = self.store.read_text(existing_key) if existing_key else None + if existing_content is not None: + existing_fm = parse_frontmatter(existing_content) + opa_uri = str(existing_fm.get("parent_opa", "") or parent_opa) + target_uri = str(existing_fm.get("target_uri", "")) + return self._rewrite_external_ops( + ops_id=ops_id, + parent_opa=opa_uri, + target_uri=target_uri, + title=title, + analysis=analysis, + solution=solution, + evidence_uris=evidence_uris, + related_uris=related_uris, + candidate_content=candidate_content, + candidate_operations=candidate_operations, + expected_sha256=expected_sha256, + expert_id=expert_id, + expert_name=expert_name, + ) + if not parent_opa: + raise ValueError("External OPS requires parent_opa when creating a new record") + _opa_id, opa_fm, opa_uri = self._opa_record(parent_opa) + target_uri = str(opa_fm.get("target_uri", "")) + evidence = self._dedupe_uri_values( + evidence_uris, exclude={target_uri} if target_uri else set() + ) + related = self._dedupe_uri_values( + related_uris or [], + exclude=set(evidence) | ({target_uri} if target_uri else set()), + ) + self._validate_op_evidence(evidence, [target_uri, *related]) + if not evidence and not (target_uri and self.read_resource(target_uri) is not None): + raise ValueError("External OPS requires at least one readable evidence URI") + if not ops_id: + payload = "\x1f".join( + ( + opa_uri, + target_uri, + title, + expert_id, + candidate_content, + json.dumps( + candidate_operations, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ), + ), + ) + ops_id = self._clip_utf8( + "ops-external-" + + self._readable_slug(title, fallback="expert")[:20].rstrip("-._") + + "-" + + sha256(payload.encode("utf-8")).hexdigest()[:10] + + "-" + + uuid4().hex[:6], + 100, + ).rstrip("-._") + key = self._find_op_key("OPS", ops_id) + if key and self.store.read_text(key) is not None: + return { + "ops_id": ops_id, + "uri": self._op_uri("OPS", ops_id), + "parent_opa": opa_uri, + "target_uri": target_uri, + "status": "unconfirmed", + "source_type": "external_expert", + "apply_status": "not_ready", + "idempotent": True, + } + model = OPSModel( + ops_id=ops_id, + parent_opa=opa_uri, + title=title, + status="unconfirmed", + target_uri=target_uri, + solution=solution, + analysis=analysis, + evidence_uris=evidence, + related_uris=related, + candidate_content=candidate_content, + candidate_operations=candidate_operations, + expected_sha256=expected_sha256, + source_type="external_expert", + reviewed_by="", + review_notes=f"External expert: {expert_name or expert_id}", + apply_status="not_ready", + ) + result = self._write_ops_model(model) + result.update({ + "source_type": "external_expert", + "expert_id": expert_id, + "expert_name": expert_name, + }) + return result + + def _rewrite_external_ops( + self, + *, + ops_id: str, + parent_opa: str, + target_uri: str, + title: str, + analysis: str, + solution: str, + evidence_uris: list[str], + related_uris: list[str] | None, + candidate_content: str, + candidate_operations: list[dict[str, object]] | None, + expected_sha256: str, + expert_id: str, + expert_name: str, + ) -> dict[str, object]: + """Overwrite content of an existing external OPS record in place.""" + evidence = self._dedupe_uri_values( + evidence_uris, exclude={target_uri} if target_uri else set() + ) + related = self._dedupe_uri_values( + related_uris or [], + exclude=set(evidence) | ({target_uri} if target_uri else set()), + ) + self._validate_op_evidence(evidence, [target_uri, *related]) + model = OPSModel( + ops_id=ops_id, + parent_opa=parent_opa, + title=title, + status="unconfirmed", + target_uri=target_uri, + solution=solution, + analysis=analysis, + evidence_uris=evidence, + related_uris=related, + candidate_content=candidate_content, + candidate_operations=list(candidate_operations or []), + expected_sha256=expected_sha256, + source_type="external_expert", + reviewed_by="", + review_notes=f"External expert: {expert_name or expert_id}", + apply_status="not_ready", + ) + result = self._write_ops_model(model) + result.update({ + "source_type": "external_expert", + "expert_id": expert_id, + "expert_name": expert_name, + "revised": True, + }) + return result + + def _ops_record(self, value: str) -> tuple[str, str, dict[str, object]]: + """Return an OPS id, storage key and parsed record.""" + ops_id = self._record_id(value, "ops") + for path, content in self._op_files("OPS"): + if Path(path).stem == ops_id: + return ops_id, path, self._record_from_content(content) + raise FileNotFoundError(f"OPS not found: {value}") + + def _write_ops_model(self, model: OPSModel) -> dict[str, object]: + """Persist an OPS draft/review state and return its canonical receipt.""" + key = self._op_key("OPS", model.ops_id, target_uri=str(model.target_uri)) + self.store.write_text_durable(key, model.to_markdown()) + return { + "ops_id": model.ops_id, + "uri": self._op_uri("OPS", model.ops_id), + "parent_opa": model.parent_opa, + "target_uri": model.target_uri, + "status": model.status, + "reviewed_by": model.reviewed_by, + "apply_status": model.apply_status, + "apply_error": model.apply_error, + } + + def update_ops( + self, + ops_id: str, + *, + title: str | None = None, + analysis: str | None = None, + solution: str | None = None, + evidence_uris: list[str] | None = None, + related_uris: list[str] | None = None, + candidate_content: str | None = None, + candidate_operations: list[dict[str, object]] | None = None, + expected_sha256: str | None = None, + status: str | None = None, + reviewed_by: str = "", + review_notes: str = "", + ) -> dict[str, object]: + """Iterate or review one OPS draft without creating an OPL snapshot. + + ``unconfirmed`` is the machine-generated draft state. A business + reviewer may revise it and move it to ``confirmed`` or ``rejected``. + Formal Wiki content is not changed by this method; use ``apply_ops`` + explicitly after confirmation. + """ + current_id, _key, frontmatter = self._ops_record(ops_id) + next_status = status or str(frontmatter.get("status", "unconfirmed")) + if next_status not in {"unconfirmed", "confirmed", "rejected"}: + raise ValueError("OPS status must be unconfirmed, confirmed, or rejected") + if next_status in {"confirmed", "rejected"} and not ( + reviewed_by.strip() or str(frontmatter.get("reviewed_by", "")).strip() + ): + raise ValueError("Confirming or rejecting OPS requires reviewed_by") + operations = ( + candidate_operations + if candidate_operations is not None + else self._list_of_dicts(frontmatter.get("candidate_operations")) + ) + candidate = ( + candidate_content + if candidate_content is not None + else str(frontmatter.get("candidate_content", "")) + ) + if candidate.strip() and operations: + raise ValueError("OPS must provide candidate_content or candidate_operations, not both") + next_sha = ( + expected_sha256 + if expected_sha256 is not None + else str(frontmatter.get("expected_sha256", "")) + ) + if next_sha and _SHA256_RE.fullmatch(next_sha) is None: + raise ValueError("OPS expected_sha256 must be a 64-character lowercase SHA-256 hash") + target_uri = str(frontmatter.get("target_uri", "")) + evidence = self._dedupe_uri_values( + evidence_uris + if evidence_uris is not None + else self._list_value(frontmatter.get("evidence_uris")), + exclude={target_uri} if target_uri else set(), + ) + related = self._dedupe_uri_values( + related_uris + if related_uris is not None + else self._list_value(frontmatter.get("related_uris")), + exclude=set(evidence) | ({target_uri} if target_uri else set()), + ) + self._validate_op_evidence(evidence, [target_uri, *related]) + model = OPSModel( + ops_id=current_id, + parent_opa=str(frontmatter.get("parent_opa", "")), + title=title if title is not None else str(frontmatter.get("title", current_id)), + status=next_status, + target_uri=target_uri, + solution=solution if solution is not None else str(frontmatter.get("solution", "")), + analysis=analysis if analysis is not None else str(frontmatter.get("analysis", "")), + retrieval_query=str(frontmatter.get("retrieval_query", "")), + retrieval_scopes=self._list_value(frontmatter.get("retrieval_scopes")), + retrieval_hit_uris=self._list_value(frontmatter.get("retrieval_hit_uris")), + retrieval_used_uris=self._list_value(frontmatter.get("retrieval_used_uris")), + evidence_uris=evidence, + related_uris=related, + candidate_content=candidate, + candidate_operations=list(operations), + expected_sha256=next_sha, + source_type=str(frontmatter.get("source_type", "pipeline")), + reviewed_by=reviewed_by.strip() or str(frontmatter.get("reviewed_by", "")), + review_notes=review_notes.strip() or str(frontmatter.get("review_notes", "")), + apply_status="rejected" + if next_status == "rejected" + else str(frontmatter.get("apply_status", "not_ready")), + apply_error=str(frontmatter.get("apply_error", "")), + applied_at=str(frontmatter.get("applied_at", "")), + applied_entity_sha256=str(frontmatter.get("applied_entity_sha256", "")), + ) + result = self._write_ops_model(model) + result["updated"] = True + return result + + def apply_ops(self, ops_id: str) -> dict[str, object]: + """Apply a confirmed OPS candidate with optimistic locking. + + This is deliberately separate from OPS creation and review. No + automatically generated draft can mutate a formal entity. + """ + current_id, _key, frontmatter = self._ops_record(ops_id) + if str(frontmatter.get("status", "unconfirmed")) != "confirmed": + raise ValueError("Only confirmed OPS records can be applied") + if str(frontmatter.get("apply_status", "")) == "applied": + return { + "ops_id": current_id, + "uri": self._op_uri("OPS", current_id), + "status": "confirmed", + "apply_status": "applied", + "idempotent": True, + } + target_uri = str(frontmatter.get("target_uri", "")) + target_info = self.store.lookup_by_uri(target_uri) + if target_info is None: + raise ValueError("Confirmed OPS target_uri is not a known Wiki entity") + concept, class_name, object_name = target_info[0], target_info[1] or "", target_info[2] + candidate = str(frontmatter.get("candidate_content", "")) + operations = self._list_of_dicts(frontmatter.get("candidate_operations")) + if not candidate and not operations: + return self._write_ops_model( + OPSModel( + ops_id=current_id, + parent_opa=str(frontmatter.get("parent_opa", "")), + title=str(frontmatter.get("title", current_id)), + status="confirmed", + target_uri=target_uri, + solution=str(frontmatter.get("solution", "")), + analysis=str(frontmatter.get("analysis", "")), + retrieval_query=str(frontmatter.get("retrieval_query", "")), + retrieval_scopes=self._list_value(frontmatter.get("retrieval_scopes")), + retrieval_hit_uris=self._list_value(frontmatter.get("retrieval_hit_uris")), + retrieval_used_uris=self._list_value(frontmatter.get("retrieval_used_uris")), + evidence_uris=self._list_value(frontmatter.get("evidence_uris")), + related_uris=self._list_value(frontmatter.get("related_uris")), + apply_status="needs_review", + apply_error="Confirmed OPS has no candidate content or operations.", + ), + ) + current = self.store.read_entity(concept, class_name or None, object_name) + expected_sha256 = str(frontmatter.get("expected_sha256", "")) + if current is not None: + current_sha256 = sha256(current.encode("utf-8")).hexdigest() + if not expected_sha256: + return self._update_ops_apply_status( + current_id, + "needs_review", + "Existing target requires expected_sha256 before application.", + ) + if expected_sha256 != current_sha256: + return self._update_ops_apply_status( + current_id, "failed", "Target changed; refresh OPS with a new expected_sha256." + ) + if not candidate: + candidate = self._apply_operations(current, operations) + elif not candidate: + return self._update_ops_apply_status( + current_id, "failed", "Patch operations cannot create a missing entity." + ) + try: + entity_uri = self.merge_entity( + concept, + class_name, + object_name, + candidate, + expected_sha256=expected_sha256, + conflict_policy="detect", + ) + except (FileNotFoundError, OSError, ValueError) as error: + return self._update_ops_apply_status(current_id, "failed", str(error)) + applied_hash = sha256( + (self.store.read_entity_by_uri(entity_uri) or "").encode("utf-8") + ).hexdigest() + result = self._update_ops_apply_status( + current_id, "applied", "", applied_entity_sha256=applied_hash + ) + result.update({"entity_uri": entity_uri}) + return result + + def _update_ops_apply_status( + self, + ops_id: str, + apply_status: str, + apply_error: str, + *, + applied_entity_sha256: str = "", + ) -> dict[str, object]: + """Update only application metadata while preserving the reviewed OPS.""" + current_id, _key, fm = self._ops_record(ops_id) + model = OPSModel( + ops_id=current_id, + parent_opa=str(fm.get("parent_opa", "")), + title=str(fm.get("title", current_id)), + status=str(fm.get("status", "unconfirmed")), + target_uri=str(fm.get("target_uri", "")), + solution=str(fm.get("solution", "")), + analysis=str(fm.get("analysis", "")), + retrieval_query=str(fm.get("retrieval_query", "")), + retrieval_scopes=self._list_value(fm.get("retrieval_scopes")), + retrieval_hit_uris=self._list_value(fm.get("retrieval_hit_uris")), + retrieval_used_uris=self._list_value(fm.get("retrieval_used_uris")), + evidence_uris=self._list_value(fm.get("evidence_uris")), + related_uris=self._list_value(fm.get("related_uris")), + candidate_content=str(fm.get("candidate_content", "")), + candidate_operations=self._list_of_dicts(fm.get("candidate_operations")), + expected_sha256=str(fm.get("expected_sha256", "")), + source_type=str(fm.get("source_type", "pipeline")), + reviewed_by=str(fm.get("reviewed_by", "")), + review_notes=str(fm.get("review_notes", "")), + apply_status=apply_status, + apply_error=apply_error, + applied_at=datetime.now(UTC).isoformat() + if apply_status == "applied" + else str(fm.get("applied_at", "")), + applied_entity_sha256=applied_entity_sha256 or str(fm.get("applied_entity_sha256", "")), + ) + return self._write_ops_model(model) + + def create_opl( + self, + *, + parent_opa: str, + ops_uris: list[str], + title: str, + proposal: str, + rationale: str, + evidence_uris: list[str] | None = None, + related_uris: list[str] | None = None, + target_uri: str = "", + status: str = "unconfirmed", + opl_id: str = "", + opl_uri: str = "", + source_type: str = "pipeline", + expert_id: str = "", + expert_name: str = "", + candidate_content: str = "", + candidate_operations: list[dict[str, object]] | None = None, + expected_sha256: str = "", + archive_reason: str = "", + ) -> dict: + """Form an unconfirmed knowledge proposal from one OPA and its OPS. + + OPL is an optional derived snapshot, not a fact source. Automatic + builds must not create OPL records: only an explicit manual archive + (``archive_reason``) may persist one, and every referenced OPS must + already be ``confirmed`` — an unconfirmed OPS means the proposal is + still being reviewed and cannot be archived yet. + + ``source_type="external_expert"`` snapshots always mint a fresh + record (one snapshot = one OPL) unless an explicit ``opl_id`` is + supplied; the pipeline default consolidates concurrent workers on + the same OPS set into one record. + """ + opa_id, opa_fm, opa_uri = self._opa_record(parent_opa) + if status != "unconfirmed": + raise ValueError("New OPL records must remain status=unconfirmed") + if not ops_uris: + raise ValueError("OPL requires at least one OPS URI") + ops_rows = self.get_ops(parent_opa=opa_id, limit=200) + ops_by_uri = {str(row["uri"]): row for row in ops_rows} + missing_ops = [uri for uri in ops_uris if uri not in ops_by_uri] + if missing_ops: + raise ValueError(f"OPL references OPS records not attached to {opa_id}: {missing_ops}") + if source_type != "external_expert" and not archive_reason.strip(): + raise ValueError( + "OPL is a derived snapshot, not a fact source: automatic builds must not " + "create OPL records. Provide archive_reason to archive an explicit manual snapshot.", + ) + unconfirmed_ops = [ + uri + for uri in ops_uris + if str(ops_by_uri[uri].get("status", "unconfirmed")) != "confirmed" + ] + if unconfirmed_ops: + raise ValueError( + "OPL may only archive confirmed OPS records; unconfirmed OPS must be " + f"reviewed (update_ops status=confirmed) first: {unconfirmed_ops}", + ) + inherited_evidence = self._list_value(opa_fm.get("evidence_uris")) + for row in ops_rows: + if row["uri"] in ops_uris: + inherited_evidence.extend(row["evidence_uris"]) + effective_target = self._require_parent_target( + parent_target=str(opa_fm.get("target_uri", "")), + requested_target=target_uri, + ) + evidence = self._dedupe_uri_values( + [*inherited_evidence, *(evidence_uris or [])], + exclude={effective_target} if effective_target else set(), + ) + related = self._dedupe_uri_values( + [*self._list_value(opa_fm.get("related_uris")), *(related_uris or [])], + exclude=set(evidence) | ({effective_target} if effective_target else set()), + ) + mismatched_ops = [ + uri + for uri in ops_uris + if self.store.resolve_redirect(str(ops_by_uri[uri].get("target_uri", ""))) + != self.store.resolve_redirect(effective_target) + ] + if mismatched_ops: + raise ValueError( + f"OPL references OPS records with a different target_uri: {mismatched_ops}" + ) + self._validate_op_evidence(evidence, [effective_target, *related, *ops_uris]) + target_is_readable = bool( + effective_target and self.read_resource(effective_target) is not None + ) + if ( + not proposal.strip() + or not rationale.strip() + or (not evidence and not target_is_readable) + ): + raise ValueError("OPL requires detailed proposal, rationale, and evidence URIs") + external_snapshot = source_type == "external_expert" + if not opl_id and opl_uri: + opl_id = self._record_id(opl_uri, "opl") + if not opl_id and not external_snapshot: + # Pipeline: consolidate concurrent workers archiving the same + # OPS set into one snapshot record. + requested_ops = set(dict.fromkeys(uri.strip() for uri in ops_uris if uri.strip())) + for existing_key, existing_content in self._op_files("OPL"): + existing_fm = parse_frontmatter(existing_content) + existing_ops = set(self._list_value(existing_fm.get("ops_uris"))) + if ( + str(existing_fm.get("status", "unconfirmed")).lower() == "unconfirmed" + and existing_ops == requested_ops + and self.store.resolve_redirect(str(existing_fm.get("target_uri", ""))) + == self.store.resolve_redirect(effective_target) + ): + opl_id = Path(existing_key).stem + break + if not opl_id: + title_slug = ( + self._readable_slug(title, fallback="proposal")[:20].rstrip("-._") or "proposal" + ) + opl_id = self._clip_utf8( + "opl-{}-{}-{}".format( + opa_id.removeprefix("opa-")[:20], + title_slug, + ( + # external_expert: one snapshot = one record, never + # merges into a prior proposal. + uuid4().hex[:8] + if external_snapshot + else sha256( + f"{opa_uri}\x1f{effective_target}\x1f{title}".encode() + ).hexdigest()[:8] + ), + ), + 80, + ).rstrip("-._") + key = self._op_key("OPL", opl_id, target_uri=effective_target) + previous = self._record_from_content(self.store.read_text(key) or "") + model = OPLModel( + opl_id=opl_id, + title=title or str(previous.get("title", opl_id)), + parent_opa=opa_uri, + ops_uris=list(dict.fromkeys(uri.strip() for uri in ops_uris if uri.strip())), + target_uri=effective_target, + status="unconfirmed", + proposal=self._merge_observations(previous.get("proposal", ""), proposal), + rationale=self._merge_observations(previous.get("rationale", ""), rationale), + evidence_uris=self._dedupe_uri_values( + [*self._list_value(previous.get("evidence_uris")), *evidence], + exclude={effective_target} if effective_target else set(), + ), + related_uris=self._dedupe_uri_values( + [*self._list_value(previous.get("related_uris")), *related], + exclude=set(evidence) | ({effective_target} if effective_target else set()), + ), + source_type=source_type, + expert_id=expert_id, + expert_name=expert_name, + candidate_content=candidate_content, + candidate_operations=list(candidate_operations or []), + expected_sha256=expected_sha256, + archive_reason=archive_reason.strip(), + ) + self.store.write_text_durable(key, model.to_markdown()) + return { + "opl_id": opl_id, + "uri": self._op_uri("OPL", opl_id), + "parent_opa": opa_uri, + "ops_uris": list(dict.fromkeys(ops_uris)), + "target_uri": effective_target, + "status": "unconfirmed", + "source_type": source_type, + "apply_status": "not_applied" if source_type == "external_expert" else "not_applicable", + } + + def _write_opl_model(self, model: OPLModel) -> dict[str, object]: + """Persist an OPL model and return its canonical receipt.""" + key = self._op_key("OPL", model.opl_id, target_uri=str(model.target_uri)) + self.store.write_text_durable(key, model.to_markdown()) + return { + "opl_id": model.opl_id, + "uri": self._op_uri("OPL", model.opl_id), + "parent_opa": model.parent_opa, + "ops_uris": list(model.ops_uris), + "target_uri": model.target_uri, + "status": model.status, + "source_type": model.source_type, + "apply_status": model.apply_status, + "apply_error": model.apply_error, + } + + def _external_opl_id( + self, + *, + target_uri: str, + title: str, + expert_id: str, + candidate_content: str, + candidate_operations: list[dict[str, object]], + ) -> str: + payload = "\x1f".join( + ( + target_uri, + title, + expert_id, + candidate_content, + json.dumps( + candidate_operations, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ), + ), + ) + return self._clip_utf8( + "opl-external-" + + self._readable_slug(title, fallback="expert")[:20].rstrip("-._") + + "-" + + sha256(payload.encode("utf-8")).hexdigest()[:10], + 100, + ).rstrip("-._") + + def _update_external_opl_status( + self, + opl_id: str, + *, + status: str, + apply_status: str, + apply_error: str = "", + applied_at: str = "", + applied_entity_sha256: str = "", + ) -> dict[str, object]: + key = self._find_op_key("OPL", opl_id) + if not key: + raise FileNotFoundError(f"OPL not found: {opl_id}") + content = self.store.read_text(key) or "" + if not content: + raise FileNotFoundError(f"OPL not found: {opl_id}") + frontmatter = self._record_from_content(content) + model = OPLModel( + opl_id=opl_id, + title=str(frontmatter.get("title", opl_id)), + parent_opa=str(frontmatter.get("parent_opa", "")), + ops_uris=self._list_value(frontmatter.get("ops_uris")), + target_uri=str(frontmatter.get("target_uri", "")), + status=status, + proposal=str(frontmatter.get("proposal", "")), + rationale=str(frontmatter.get("rationale", "")), + evidence_uris=self._list_value(frontmatter.get("evidence_uris")), + related_uris=self._list_value(frontmatter.get("related_uris")), + source_type=str(frontmatter.get("source_type", "pipeline")), + expert_id=str(frontmatter.get("expert_id", "")), + expert_name=str(frontmatter.get("expert_name", "")), + source_uri=str(frontmatter.get("source_uri", "")), + target_concept=str(frontmatter.get("target_concept", "")), + target_class_name=str(frontmatter.get("target_class_name", "")), + target_object_name=str(frontmatter.get("target_object_name", "")), + expected_sha256=str(frontmatter.get("expected_sha256", "")), + candidate_content=str(frontmatter.get("candidate_content", "")), + candidate_operations=frontmatter.get("candidate_operations", []) + if isinstance(frontmatter.get("candidate_operations", []), list) + else [], + apply_status=apply_status, + apply_error=apply_error, + applied_at=applied_at, + applied_entity_sha256=applied_entity_sha256, + ) + return self._write_opl_model(model) + + def _resolve_external_target_conflicts( + self, target_uri: str, evidence_uris: list[str], opl_uri: str + ) -> int: + resolved = 0 + for record in self.get_opas( + target_uri=target_uri, status="pending", category="conflict", limit=200 + ): + self.resolve_opa( + str(record["opa_id"]), + solution=f"Resolved by external expert OPL {opl_uri}.", + evidence_uris=evidence_uris, + ) + resolved += 1 + return resolved + + def apply_opl(self, opl_id: str) -> dict[str, object]: + """Apply a stored external OPL exactly once when it is machine-ready.""" + key = self._find_op_key("OPL", opl_id) + if not key: + raise FileNotFoundError(f"OPL not found: {opl_id}") + content = self.store.read_text(key) or "" + if not content: + raise FileNotFoundError(f"OPL not found: {opl_id}") + frontmatter = parse_frontmatter(content) + if str(frontmatter.get("source_type", "")) != "external_expert": + raise ValueError("Only external_expert OPL records can be auto-applied") + if str(frontmatter.get("apply_status", "")) == "applied": + return { + "opl_id": opl_id, + "uri": self._op_uri("OPL", opl_id), + "status": str(frontmatter.get("status", "applied")), + "apply_status": "applied", + "idempotent": True, + } + target_uri = str(frontmatter.get("target_uri", "")) + target_info = self.store.lookup_by_uri(target_uri) + concept = str(frontmatter.get("target_concept", "")) + class_name = str(frontmatter.get("target_class_name", "")) + object_name = str(frontmatter.get("target_object_name", "")) + if target_info is not None: + concept, class_name, object_name = target_info[0], target_info[1] or "", target_info[2] + candidate = str(frontmatter.get("candidate_content", "")) + operations = frontmatter.get("candidate_operations", []) + if not candidate and not isinstance(operations, list): + raise ValueError("External OPL candidate_operations must be a list") + if not candidate and not operations: + return self._update_external_opl_status( + opl_id, status="unconfirmed", apply_status="needs_review" + ) + current = self.store.read_entity(concept, class_name or None, object_name) + expected_sha256 = str(frontmatter.get("expected_sha256", "")) + if current is not None: + current_sha256 = sha256(current.encode("utf-8")).hexdigest() + if not expected_sha256: + return self._update_external_opl_status( + opl_id, + status="unconfirmed", + apply_status="needs_review", + apply_error="Existing target requires expected_sha256 before automatic application.", + ) + if expected_sha256 != current_sha256: + return self._update_external_opl_status( + opl_id, + status="unconfirmed", + apply_status="failed", + apply_error="Target changed after external OPL was prepared; re-submit with a fresh expected_sha256.", + ) + expected_sha256 = current_sha256 + if not candidate: + candidate = self._apply_operations(current, operations) + elif not candidate: + return self._update_external_opl_status( + opl_id, + status="unconfirmed", + apply_status="failed", + apply_error="candidate_operations cannot create a missing target without candidate_content.", + ) + try: + entity_uri = self.merge_entity( + concept, + class_name, + object_name, + candidate, + expected_sha256=expected_sha256, + conflict_policy="external_authority", + ) + except (FileNotFoundError, OSError, ValueError) as error: + return self._update_external_opl_status( + opl_id, + status="unconfirmed", + apply_status="failed", + apply_error=str(error), + ) + opl_uri = self._op_uri("OPL", opl_id) + resolved_conflicts = self._resolve_external_target_conflicts( + target_uri=entity_uri, + evidence_uris=self._list_value(frontmatter.get("evidence_uris")), + opl_uri=opl_uri, + ) + result = self._update_external_opl_status( + opl_id, + status="applied", + apply_status="applied", + applied_at=datetime.now(UTC).isoformat(), + applied_entity_sha256=sha256( + (self.store.read_entity_by_uri(entity_uri) or "").encode("utf-8") + ).hexdigest(), + ) + result.update({"entity_uri": entity_uri, "resolved_conflict_count": resolved_conflicts}) + return result + + def get_opls( + self, + *, + parent_opa: str = "", + status: str = "", + target_uri: str = "", + limit: int = 50, + ) -> list[dict]: + """Read unconfirmed or historical OPL proposals.""" + parent_id = self._record_id(parent_opa, "opa") if parent_opa else "" + parent_uri = "" + if parent_id: + try: + _pid, _pfm, parent_uri = self._opa_record(parent_id) + except FileNotFoundError: + parent_uri = self._opa_uri(parent_id) + out: list[dict] = [] + for path, content in self._op_files("OPL", target_uri=target_uri): + fm = self._record_from_content(content) + sections = extract_sections(content) + if target_uri and str(fm.get("target_uri", "")) != target_uri: + continue + if parent_uri and str(fm.get("parent_opa", "")) not in {parent_uri, parent_id}: + continue + if status and str(fm.get("status", "")) != status: + continue + opl_id = str(fm.get("id", Path(path).stem)) + out.append( + { + "opl_id": opl_id, + "uri": self._op_uri("OPL", opl_id), + "path": path, + "title": str(fm.get("title", opl_id)), + "parent_opa": str(fm.get("parent_opa", "")), + "ops_uris": self._list_value(fm.get("ops_uris")), + "target_uri": str(fm.get("target_uri", "")), + "status": str(fm.get("status", "unconfirmed")), + "proposal": str(fm.get("proposal", "")) + or sections.get("初版知识提案", "").strip(), + "rationale": str(fm.get("rationale", "")) + or sections.get("形成依据", "").strip(), + "evidence_uris": self._list_value(fm.get("evidence_uris")), + "related_uris": self._list_value(fm.get("related_uris")), + "source_type": str(fm.get("source_type", "pipeline")), + "expert_id": str(fm.get("expert_id", "")), + "expert_name": str(fm.get("expert_name", "")), + "source_uri": str(fm.get("source_uri", "")), + "apply_status": str(fm.get("apply_status", "not_applied")), + "apply_error": str(fm.get("apply_error", "")), + "applied_at": str(fm.get("applied_at", "")), + }, + ) + if len(out) >= max(1, limit): + break + return out + + def op_flow_status(self, *, limit: int = 500) -> dict[str, object]: + """Check that every still-open OPA has a valid OPS draft or a reasoned deferral. + + "Open" is decided by ``_opa_open_for_gate`` (closure_status, not + status), so a record marked ``status=resolved`` no longer bypasses + the gate unless its ``closure_status`` is ``closed`` or + ``deferred`` (the latter only with a ``closure_reason``). + """ + checkpoint = self.store.read_json("index/build_checkpoint.json") + build_id = ( + str(checkpoint.get("build_id", "")).strip() if isinstance(checkpoint, dict) else "" + ) + all_opas = self.get_opas(build_id=build_id, limit=limit) + pending = [opa for opa in all_opas if self._opa_open_for_gate(opa)] + # Load OPS once. The previous implementation rescanned every OPS + # file once per pending OPA, turning a quality gate into OPA×OPS remote + # directory reads on large builds. + all_ops = self.get_ops(limit=max(10000, limit * 4)) + ops_by_parent: dict[str, list[dict[str, object]]] = {} + for row in all_ops: + parent = self._record_id(str(row.get("parent_opa", "")), "opa") + ops_by_parent.setdefault(parent, []).append(row) + blockers: list[dict[str, object]] = [] + for opa in pending: + opa_id = str(opa["opa_id"]) + target_uri = str(opa.get("target_uri", "")) + target_readable = bool(target_uri and self.read_resource(target_uri) is not None) + ops = ops_by_parent.get(opa_id, []) + invalid: list[str] = self._op_reference_quality( + target_uri=target_uri, + evidence_uris=self._list_value(opa.get("evidence_uris")), + related_uris=self._list_value(opa.get("related_uris")), + ) + required_opa_fields = ("description", "finding", "missing", "recommendation") + if any(not str(opa.get(field, "")).strip() for field in required_opa_fields): + invalid.append("OPA_CONTENT_MISSING") + valid_ops = [ + row + for row in ops + if ( + str(row.get("status", "")) in {"unconfirmed", "confirmed"} + and self.store.resolve_redirect(str(row.get("target_uri", ""))) + == self.store.resolve_redirect(target_uri) + and ( + str(row.get("source_type", "pipeline")) == "external_expert" + or ( + bool(str(row.get("retrieval_query", "")).strip()) + and self._retrieval_scopes_match(row.get("retrieval_scopes", [])) + ) + ) + and (bool(self._list_value(row.get("evidence_uris"))) or target_readable) + and not self._op_reference_quality( + target_uri=str(row.get("target_uri", "")), + evidence_uris=self._list_value(row.get("evidence_uris")), + related_uris=self._list_value(row.get("related_uris")), + ) + ) + ] + if ops and any( + str(row.get("source_type", "pipeline")) != "external_expert" + and not str(row.get("retrieval_query", "")).strip() + for row in ops + ): + invalid.append("OPS_RETRIEVAL_RECEIPT_MISSING") + if ops and any( + self.store.resolve_redirect(str(row.get("target_uri", ""))) + != self.store.resolve_redirect(target_uri) + for row in ops + ): + invalid.append("OPS_TARGET_MISMATCH") + if ops and any( + self._list_value(row.get("retrieval_used_uris")) + and ( + not set(self._list_value(row.get("retrieval_used_uris"))).issubset( + set(self._list_value(row.get("retrieval_hit_uris"))), + ) + or not set(self._list_value(row.get("retrieval_used_uris"))).issubset( + set(self._list_value(row.get("evidence_uris"))) + | set(self._list_value(row.get("related_uris"))) + | {str(row.get("target_uri", ""))}, + ) + ) + for row in ops + ): + invalid.append("OPS_RETRIEVAL_USAGE_MISMATCH") + if not valid_ops or invalid: + blockers.append( + { + "opa_id": opa_id, + "opa_uri": opa["uri"], + "status": str(opa.get("status", "")), + "closure_status": str(opa.get("closure_status", "")), + "missing": ["OPS"] if not valid_ops else [], + "invalid": invalid, + }, + ) + return { + "passed": not blockers, + "open_opa_count": len(pending), + "blocker_count": len(blockers), + "blockers": blockers, + } + + def ops_dispatch_plan( + self, + *, + limit: int = 1000, + max_parallel_shards: int | None = None, + ) -> dict[str, object]: + """Code-generated, domain-grouped OPS dispatch plan for the conductor. + + Returns every open OPA that has no OPS draft yet, sorted cheapest + repair first (``relation_missed`` before ``source_incomplete``) and + grouped by target class so the conductor can dispatch the whole plan + in one ``task_create_batch`` instead of hand-enumerating. + + The plan also exposes a sharded view (``shards``): items grouped by + target class and chunked into ``_entity_batch_limit()``-sized OPS + work batches. ``max_parallel_shards`` caps how many shards are + dispatched in the current wave; ``remaining_count`` reports how many + shards are left for later waves. + """ + if max_parallel_shards is not None and max_parallel_shards < 1: + raise ValueError("max_parallel_shards must be positive when provided") + checkpoint = self.store.read_json("index/build_checkpoint.json") + build_id = ( + str(checkpoint.get("build_id", "")).strip() if isinstance(checkpoint, dict) else "" + ) + all_opas = self.get_opas(build_id=build_id, limit=limit) + open_opas = [opa for opa in all_opas if self._opa_open_for_gate(opa)] + all_ops = self.get_ops(limit=max(10000, limit * 4)) + ops_parent_ids = {self._record_id(str(row.get("parent_opa", "")), "opa") for row in all_ops} + # Cheapest repair first so the conductor can close link gaps quickly. + repair_cost = { + "relation_missed": 0, + "extraction_missed": 1, + "param_unhosted": 2, + "source_incomplete": 3, + "manual_error": 4, + "process_conflict": 5, + "hallucination": 6, + "content_missing": 7, + "fact_conflict": 8, + } + root_prefix = self.store.root_uri.rstrip("/") + "/" + items: list[dict[str, object]] = [] + for opa in open_opas: + opa_id = str(opa["opa_id"]) + if opa_id in ops_parent_ids: + continue + target_uri = str(opa.get("target_uri", "")) + target_class = target_uri.removeprefix(root_prefix).split("/")[0] if target_uri else "" + reason = str(opa.get("reason_code", "content_missing")) + section = str(opa.get("target_section", "")) + items.append( + { + "opa_id": opa_id, + "opa_uri": str(opa.get("uri", "")), + "target_uri": target_uri, + "target_class": target_class, + "target_section": section, + "reason_code": reason, + "suggested_retrieval_query": f"{opa.get('title', '')!s} {section}".strip(), + "repair_cost": repair_cost.get(reason, 9), + }, + ) + items.sort(key=lambda row: (row["repair_cost"], row["target_class"], row["opa_id"])) + by_domain: dict[str, int] = {} + for row in items: + by_domain[str(row["target_class"])] = by_domain.get(str(row["target_class"]), 0) + 1 + grouped_by_class: dict[str, list[dict[str, object]]] = {} + for row in items: + grouped_by_class.setdefault(str(row["target_class"]), []).append(row) + all_shards: list[dict[str, object]] = [] + shard_index = 0 + for target_class in sorted(grouped_by_class): + class_items = grouped_by_class[target_class] + for start in range(0, len(class_items), _entity_batch_limit()): + shard_index += 1 + chunk = class_items[start : start + _entity_batch_limit()] + opa_ids = [str(row["opa_id"]) for row in chunk] + shard_id = f"ops_{target_class.casefold()}_{shard_index}" + task_description = "\n".join( + [ + "worker_role: wiki_ops_worker", + "depends_on_stage: opa_discovered", + "ops_planner: ops_dispatch_plan", + f"ops_shard_id: {shard_id}", + f"write_scope: ops_draft:{shard_id}", + f"opa_ids: {json.dumps(opa_ids, ensure_ascii=False)}", + ( + "worker_task: read each OPA and its evidence, then create an " + "unconfirmed OPS draft proposing the repair for that OPA" + ), + "expected_artifacts: unconfirmed_ops_drafts", + ], + ) + all_shards.append( + { + "shard_id": shard_id, + "worker_role": "wiki_ops_worker", + "opa_ids": opa_ids, + "item_count": len(chunk), + "task_description": task_description, + }, + ) + shards = all_shards[:max_parallel_shards] if max_parallel_shards is not None else all_shards + remaining_count = len(all_shards) - len(shards) + return { + "build_id": build_id, + "open_opa_count": len(open_opas), + "to_dispatch": len(items), + "already_has_ops": len(open_opas) - len(items), + "by_domain": by_domain, + "items": items, # keep flat list for backward compat + "shards": shards, # NEW: sharded view + "remaining_count": remaining_count, # NEW + } + + def op_flow_report( + self, + *, + persist: bool = True, + limit: int = 10000, + build_id: str | None = None, + ) -> dict[str, object]: + """Report OPA/OPS/OPL counts and source/Wiki evidence coverage. + + Coverage is calculated from readable raw or Wiki URIs, not merely from + a non-empty YAML field. OPL counts are observational only; the + automatic gate is based on valid OPS drafts. The report is persisted under + ``index/op_flow_report.json`` by default for build handoff. + """ + if limit < 1 or limit > 10000: + raise ValueError("op_flow_report limit must be between 1 and 10000") + opas = self.get_opas(limit=limit, build_id=build_id or "") + ops = self.get_ops(limit=limit) + opls = self.get_opls(limit=limit) + if build_id is not None: + active_opa_ids = {str(row.get("opa_id", "")) for row in opas} + ops = [ + row + for row in ops + if self._record_id(str(row.get("parent_opa", "")), "opa") in active_opa_ids + ] + opls = [ + row + for row in opls + if self._record_id(str(row.get("parent_opa", "")), "opa") in active_opa_ids + ] + + def coverage(uris: list[str]) -> dict[str, object]: + unique = list(dict.fromkeys(uri.strip() for uri in uris if uri.strip())) + manual = 0 + wiki = 0 + readable = 0 + unresolved = 0 + for uri in unique: + kind = classify_raw_source_uri(uri, raw_root_uri=self._raw_fs.root_uri) + if kind is not None: + if kind.value == "manual_chapter": + manual += 1 + if self.read_raw_source(uri).status is SourceReadStatus.OK: + readable += 1 + else: + unresolved += 1 + continue + if self.store.is_wiki_uri(uri): + wiki += 1 + if self.read_resource(uri) is not None: + readable += 1 + else: + unresolved += 1 + continue + unresolved += 1 + return { + "reference_count": len(unique), + "readable_source_or_wiki_count": readable, + "manual_source_count": manual, + "wiki_reference_count": wiki, + "unresolved_reference_count": unresolved, + "coverage_percent": round((readable / len(unique)) * 100, 2) if unique else 0.0, + } + + opa_rows: list[dict[str, object]] = [] + for record in opas: + refs = [ + str(record.get("target_uri", "")), + *self._list_value(record.get("evidence_uris")), + *self._list_value(record.get("related_uris")), + ] + opa_rows.append( + { + "id": record.get("opa_id", ""), + "status": record.get("status", ""), + "category": record.get("category", ""), + "target_uri": record.get("target_uri", ""), + "coverage": coverage(refs), + }, + ) + ops_rows: list[dict[str, object]] = [] + for record in ops: + refs = [ + str(record.get("target_uri", "")), + *self._list_value(record.get("evidence_uris")), + *self._list_value(record.get("related_uris")), + *self._list_value(record.get("retrieval_used_uris")), + ] + ops_rows.append( + { + "id": record.get("ops_id", ""), + "status": record.get("status", ""), + "parent_opa": record.get("parent_opa", ""), + "coverage": coverage(refs), + }, + ) + opl_rows: list[dict[str, object]] = [] + for record in opls: + refs = [ + str(record.get("target_uri", "")), + *self._list_value(record.get("evidence_uris")), + *self._list_value(record.get("ops_uris")), + ] + opl_rows.append( + { + "id": record.get("opl_id", ""), + "status": record.get("status", ""), + "parent_opa": record.get("parent_opa", ""), + "coverage": coverage(refs), + }, + ) + + def aggregate(rows: list[dict[str, object]]) -> dict[str, object]: + covered = sum( + 1 + for row in rows + if isinstance(row.get("coverage"), dict) + and int(row["coverage"].get("readable_source_or_wiki_count", 0)) > 0 + ) + return { + "total": len(rows), + "with_readable_source_or_wiki": covered, + "coverage_percent": round((covered / len(rows)) * 100, 2) if rows else 0.0, + } + + report: dict[str, object] = { + "version": 1, + "generated_at": datetime.now(UTC).isoformat(), + "counts": { + "opa": len(opas), + "ops": len(ops), + "opl": len(opls), + "opa_pending": sum(1 for row in opas if row.get("status") == "pending"), + "ops_unconfirmed": sum(1 for row in ops if row.get("status") == "unconfirmed"), + "ops_confirmed": sum(1 for row in ops if row.get("status") == "confirmed"), + "ops_rejected": sum(1 for row in ops if row.get("status") == "rejected"), + "ops_applied": sum(1 for row in ops if row.get("apply_status") == "applied"), + "opl_unconfirmed": sum(1 for row in opls if row.get("status") == "unconfirmed"), + }, + "coverage": { + "opa": aggregate(opa_rows), + "ops": aggregate(ops_rows), + "opl": aggregate(opl_rows), + }, + "records": {"opa": opa_rows, "ops": ops_rows, "opl": opl_rows}, + } + if persist: + self.store.write_json("index/op_flow_report.json", report) + return report + + def discover_opa( + self, + *, + profile: BuildProfile = "manual", + entity_uris: list[str] | None = None, + include_warnings: bool = False, + limit: int = 500, + ) -> dict[str, object]: + """Materialize stable pending OPAs from the complete audit. + + This is mechanical: audit remains the source of truth, while workers + may enrich or resolve the OPA later. Repeated discovery reuses + ``create_opa``'s stable identity and never bypasses finalize. + """ + if limit < 1 or limit > 500: + raise ValueError("discover_opa limit must be between 1 and 500") + issues: list[dict[str, object]] = [] + offset = 0 + while True: + report = self.audit_wiki( + profile=profile, + entity_uris=entity_uris, + offset=offset, + limit=limit, + ) + issues.extend(dict(issue) for issue in report.get("issues", [])) + next_offset = int(report.get("next_offset", -1)) + if next_offset < 0: + break + offset = next_offset + + # Discovery is append/reuse-only for the current build. It must not + # mutate or reconcile OPA records created by an earlier build. + + created: list[dict[str, object]] = [] + skipped: list[dict[str, str]] = [] + unclassified: list[dict[str, str]] = [] + repair_only_count = 0 + unclassified_count = 0 + skipped_low_value = 0 + # Cross-issue caches: avoid re-reading the same entity / raw source + # for every issue that targets the same URI. Without these caches + # discover_opa makes ~1500+ HTTP round-trips for a 500-issue audit. + entity_cache: dict[str, str | None] = {} + raw_cache: dict[str, SourceReadResult] = {} + + def _cached_read_entity(uri: str) -> str | None: + if uri not in entity_cache: + entity_cache[uri] = self.read_resource(uri) + return entity_cache[uri] + + def _cached_read_raw(uri: str) -> SourceReadResult: + if uri not in raw_cache: + raw_cache[uri] = self.read_raw_source(uri) + return raw_cache[uri] + + for issue in issues: + severity = str(issue.get("severity", "error")) + if severity != "error" and not include_warnings: + continue + target_uri = str(issue.get("uri", "")).strip() + if not self.store.is_wiki_uri(target_uri) or target_uri == self.store.root_uri + "/": + skipped.append( + { + "code": str(issue.get("code", "")), + "reason": "audit issue has no concrete wiki target", + }, + ) + continue + code = str(issue.get("code", "audit_issue")).strip() or "audit_issue" + concept = str(issue.get("concept", "")).strip() + if is_optional_relation_issue(code, concept): + skipped.append({"code": code, "reason": "optional relation under backbone policy"}) + continue + if _is_relation_gap_code(code): + skipped.append({"code": code, "reason": "relation gap excluded from OPA"}) + continue + message = str(issue.get("message", "")).strip() or code + disposition_value = str(issue.get("disposition", "")).strip() + reason_code = str(issue.get("opa_reason_code", "")).strip() + if not disposition_value: + policy = audit_issue_policy(code) + if policy is not None: + disposition_value = policy.disposition.value + reason_code = reason_code or policy.opa_reason_code + if disposition_value == IssueDisposition.REPAIR_ONLY.value: + # repair_only means the target concept is already materialized + # but a content/link gap remains. Convert to an open_gap OPA + # instead of silently skipping, so the finalize gate can see + # the tracked record and the limitation is explicitly recorded. + if not reason_code: + reason_code = "content_missing" + disposition_value = IssueDisposition.GAP.value + if ( + disposition_value + not in { + IssueDisposition.GAP.value, + IssueDisposition.CONFLICT.value, + } + or not reason_code + ): + unclassified_count += 1 + item = {"code": code, "reason": "audit issue has no OPA disposition"} + skipped.append(item) + unclassified.append(item) + continue + # Skip known low-value issue classes before materializing an OPA. + # The audit prompt already avoids these; enforce it in code so a + # prompt drift cannot flood the OPA ledger with noise. + target_section = self._opa_target_section(message, code) + root_prefix = self.store.root_uri.rstrip("/") + "/" + target_class = target_uri.removeprefix(root_prefix).split("/")[0] if target_uri else "" + is_low_value = ( + reason_code == "relation_missed" + or ( + target_section.startswith("frontmatter:") + and reason_code not in {"content_missing", "fact_conflict"} + ) + or ( + target_class in {"Procedure", "DTC", "Part"} + and reason_code in {"relation_missed", "extraction_missed"} + ) + ) + if is_low_value: + skipped_low_value += 1 + skipped.append({"code": code, "reason": "low-value audit issue"}) + continue + category = disposition_value + content = _cached_read_entity(target_uri) or "" + frontmatter = parse_frontmatter(content) + evidence = self._list_value(frontmatter.get("sources")) + evidence.extend(extract_source_uris(message)) + evidence = list( + dict.fromkeys( + uri for uri in evidence if _cached_read_raw(uri).status is SourceReadStatus.OK + ) + ) + # A missing/invalid source citation is itself an audit finding. Do + # not drop that finding merely because the page has no usable raw + # URI: bind the OPA to the concrete page as observation evidence, + # while keeping ``missing`` explicit so this is never mistaken for + # a source citation. + if not evidence: + unclassified_count += 1 + item = {"code": code, "reason": "OPA issue has no readable evidence URI"} + skipped.append(item) + unclassified.append(item) + continue + related = list( + dict.fromkeys( + uri + for uri in re.findall(re.escape(self.store.root_uri) + r"/[^\s)\]>]+", message) + if uri != target_uri + ) + ) + category_label = "内容缺失" if category == "gap" else "知识冲突" + issue_concept = str(issue.get("concept", "")).strip() + entity_name = target_uri.rstrip("/").rsplit("/", 1)[-1].removesuffix(".md") + if entity_name and entity_name not in issue_concept: + prefix = f"{category_label}: {entity_name}" + else: + prefix = f"{category_label}:" + title = f"{prefix} {issue_concept} {target_section}".strip() + result = self.create_opa( + title=title, + description=message, + category=category, + reason_code=reason_code, + target_uri=target_uri, + target_section=target_section, + source_chapter=evidence[0] if evidence else "", + evidence_uris=evidence, + finding=message, + missing=self._opa_missing_text(code, message), + recommendation=self._opa_recommendation(reason_code), + related_uris=related, + ) + created.append(result) + return { + "issues_scanned": len(issues), + "opa_count": len(created), + "reconciled_count": 0, + "profile": profile, + "repair_only_count": repair_only_count, + "unclassified_count": unclassified_count, + "skipped_low_value": skipped_low_value, + "created": created, + "skipped": skipped, + "unclassified": unclassified, + } + + @staticmethod + def _opa_target_section(message: str, code: str) -> str: + """Extract a stable section label from an audit issue.""" + match = re.search(r"section\s+['\"]([^'\"]+)['\"]", message, re.IGNORECASE) + return match.group(1).strip() if match is not None else code + + @staticmethod + def _opa_missing_text(code: str, message: str) -> str: + """Describe the unresolved fact without inventing a domain answer.""" + return message + + @staticmethod + def _opa_recommendation(reason_code: str) -> str: + """Provide a deterministic next action for the worker queue.""" + if reason_code == "fact_conflict": + return "保留冲突双方及证据,完成裁决前不得静默覆盖或移动实体。" + return "读取实体 wiki 页面,基于已有内容补齐缺失字段或修补 URI 引用。仅在实体页面确实缺少必要内容时,才回读单个 raw 章节。" + + def resolve_opa( + self, + opa_id: str, + *, + solution: str, + evidence_uris: list[str] | None = None, + related_uris: list[str] | None = None, + closure_status: str = "deferred", + closure_reason: str = "", + ) -> dict: + """Resolve an existing OPA while retaining its complete evidence chain. + + ``closure_status`` defaults to ``deferred``: resolving a record + without applying a fix is an honest deferral, not a closure. Callers + that actually applied the fix (e.g. ``apply_opa``) pass + ``closure_status="closed"``. + """ + key = self._find_opa_key(opa_id) + if key is None: + raise FileNotFoundError(f"OPA not found: {opa_id}") + content = self.store.read_text(key) + if content is None: + raise FileNotFoundError(f"OPA not found: {opa_id}") + frontmatter = self._record_from_content(content) + return self.create_opa( + opa_id=opa_id, + title=str(frontmatter.get("title", opa_id)), + description=str(frontmatter.get("description", "")), + category=str(frontmatter.get("category", "conflict")), + reason_code=str( + frontmatter.get( + "reason_code", + infer_opa_reason_code(str(frontmatter.get("category", "conflict"))), + ), + ), + scope=str(frontmatter.get("scope", "entity")), + subtype=str(frontmatter.get("subtype", "wiki_error")), + target_uri=str(frontmatter.get("target_uri", "")), + target_path=str(frontmatter.get("target_path", "")), + target_section=str(frontmatter.get("target_section", "")), + source_chapter=str(frontmatter.get("source_chapter", "")), + evidence_uris=evidence_uris, + status="resolved", + solution=solution, + finding=str(frontmatter.get("finding", "")), + missing=str(frontmatter.get("missing", "")), + recommendation=str(frontmatter.get("recommendation", "")), + related_uris=related_uris, + build_id=str(frontmatter.get("build_id", "")).strip(), + closure_status=closure_status, + closure_reason=closure_reason or str(frontmatter.get("closure_reason", "")), + ) + + def apply_opa( + self, + opa_id: str, + *, + concept: str, + class_name: str, + object_name: str, + content: str, + solution: str, + expected_sha256: str = "", + ) -> dict: + """Apply a resolved candidate and close its OPA in one guarded call.""" + key = self._find_opa_key(opa_id) + if key is None: + raise FileNotFoundError(f"OPA not found: {opa_id}") + stored = self.store.read_text(key) + if stored is None: + raise FileNotFoundError(f"OPA not found: {opa_id}") + frontmatter = parse_frontmatter(stored) + target_uri = str(frontmatter.get("target_uri", "")) + expected_uri = self.store.entity_uri(concept, class_name or None, object_name) + if target_uri and self.store.resolve_redirect(target_uri) != self.store.resolve_redirect( + expected_uri + ): + raise ValueError( + f"OPA target does not match merge target: {target_uri} != {expected_uri}", + ) + merge = self.merge_entity( + concept, + class_name, + object_name, + stored, + expected_sha256=expected_sha256, + ) + resolved = self.resolve_opa( + opa_id, + solution=solution, + evidence_uris=[expected_uri], + closure_status="closed", + closure_reason="applied via merge_entity", + ) + return {"entity_uri": merge, "opa": resolved} + + def refine_opa_reason_code( + self, + opa_id: str, + *, + reason_code: str, + closure_status: str = "", + closure_reason: str = "", + ) -> dict: + """Refine an OPA's reason_code after retrieval-based triage (called by OPS). + + OPA are created with a coarse reason (``content_missing`` / + ``fact_conflict``) because at audit time the source has not yet been + retrieved. After OPS runs retrieval it can classify the gap precisely + into one of the fine codes (``source_incomplete`` / ``extraction_missed`` + / ``relation_missed`` / ``param_unhosted`` / ``manual_error`` / + ``process_conflict`` / ``hallucination``), each of which maps to a + distinct repair path. This rewrites the OPA in place preserving all + prior evidence. + """ + if reason_code not in OPA_REASON_CODES: + raise ValueError( + f"Unsupported reason_code: {reason_code!r}; expected one of {', '.join(OPA_REASON_CODES)}", + ) + key = self._find_opa_key(opa_id) + if key is None: + raise FileNotFoundError(f"OPA not found: {opa_id}") + content = self.store.read_text(key) + if content is None: + raise FileNotFoundError(f"OPA not found: {opa_id}") + frontmatter = self._record_from_content(content) + effective_closure = closure_status or str(frontmatter.get("closure_status", "")) + if effective_closure and effective_closure not in OPA_CLOSURE_STATUSES: + raise ValueError(f"Unsupported closure_status: {effective_closure!r}") + return self.create_opa( + opa_id=opa_id, + title=str(frontmatter.get("title", opa_id)), + description=str(frontmatter.get("description", "")), + category=str(frontmatter.get("category", "conflict")), + reason_code=reason_code, + scope=str(frontmatter.get("scope", "entity")), + subtype=str(frontmatter.get("subtype", "wiki_error")), + target_uri=str(frontmatter.get("target_uri", "")), + target_path=str(frontmatter.get("target_path", "")), + target_section=str(frontmatter.get("target_section", "")), + source_chapter=str(frontmatter.get("source_chapter", "")), + evidence_uris=self._list_value(frontmatter.get("evidence_uris")), + status=str(frontmatter.get("status", "pending")), + solution=str(frontmatter.get("solution", "")), + finding=str(frontmatter.get("finding", "")), + missing=str(frontmatter.get("missing", "")), + recommendation=str(frontmatter.get("recommendation", "")), + related_uris=self._list_value(frontmatter.get("related_uris")), + build_id=str(frontmatter.get("build_id", "")).strip(), + closure_status=effective_closure + or str(frontmatter.get("closure_status", "open")) + or "open", + closure_reason=closure_reason or str(frontmatter.get("closure_reason", "")), + ) + + def get_opas( + self, + *, + target_uri: str = "", + status: str = "", + category: str = "", + reason_code: str = "", + scope: str = "", + source_chapter: str = "", + limit: int = 50, + include_superseded: bool = False, + build_id: str = "", + ) -> list[dict]: + """读取 ``OP/OpA/`` 下匹配条件的 OPA 记录。. + + 可按 ``target_uri`` / ``status`` / ``category`` / ``reason_code`` / ``scope`` 过滤, + 返回结构化 frontmatter(含 ``uri`` 键,供证据链提取)。 + """ + out: list[dict] = [] + for path, content in self._opa_files(target_uri=target_uri): + fm = self._record_from_content(content) + opa_id = str(fm.get("id", Path(path).stem)) + if not include_superseded and str(fm.get("status", "")).strip().lower() in { + "superseded", + "rejected", + }: + continue + if target_uri and str(fm.get("target_uri", "")) != target_uri: + continue + if status and str(fm.get("status", "")) != status: + continue + if category and str(fm.get("category", "")) != category: + continue + effective_reason_code = str( + fm.get("reason_code", infer_opa_reason_code(str(fm.get("category", "")))), + ) + if reason_code and effective_reason_code != reason_code: + continue + if scope and str(fm.get("scope", "")) != scope: + continue + if source_chapter and str(fm.get("source_chapter", "")) != source_chapter: + continue + if build_id and not self._opa_matches_build(path, fm, build_id): + continue + out.append( + { + "opa_id": opa_id, + "path": path, + **{ + key: fm.get(key, "") + for key in ( + "title", + "human_key", + "description", + "category", + "reason_code", + "scope", + "subtype", + "target_uri", + "target_path", + "target_section", + "source_chapter", + "status", + "solution", + "finding", + "missing", + "recommendation", + "report_count", + "dedupe_key", + "build_id", + "closure_status", + "closure_reason", + ) + }, + "evidence_uris": self._list_value(fm.get("evidence_uris")), + "related_uris": self._list_value(fm.get("related_uris")), + "uri": self._opa_uri(opa_id, self._opa_category_from_key(path)), + }, + ) + if len(out) >= max(limit, 1): + break + return out + + def get_expert_authority(self, *, target_uri: str = "", limit: int = 50) -> list[dict]: + """Inspect sections protected by confirmed or applied expert knowledge. + + Expert authority comes from confirmed OPS recommendations and + applied OPL proposals persisted under ``OP/OpA/`` — the same + records OPA/OPS/OPL tickets are built from. + """ + authority: list[dict] = [] + for path, content in self._op_files("OPL", target_uri=target_uri): + fm = parse_frontmatter(content) + if target_uri and str(fm.get("target_uri", "")) != target_uri: + continue + if str(fm.get("apply_status", "not_applied")) != "applied": + continue + opl_id = str(fm.get("id", Path(path).stem)) + authority.append( + { + "source": "opl", + "uri": self._op_uri("OPL", opl_id), + "target_uri": str(fm.get("target_uri", "")), + "target_section": str(fm.get("target_section", "")), + "status": str(fm.get("status", "")), + "apply_status": "applied", + "applied_at": str(fm.get("applied_at", "")), + "expert_id": str(fm.get("expert_id", "")), + }, + ) + if len(authority) >= max(1, limit): + return authority + confirmed = self.get_ops(status="confirmed", target_uri=target_uri, limit=limit) + authority.extend( + { + "source": "ops", + "uri": str(record.get("uri", "")), + "target_uri": str(record.get("target_uri", "")), + "target_section": str(record.get("target_section", "")), + "status": "confirmed", + "apply_status": str(record.get("apply_status", "")), + "applied_at": str(record.get("applied_at", "")), + "expert_id": str(record.get("expert_id", "")), + } + for record in confirmed + ) + return authority[: max(1, limit)] + + def get_wiki_change_events(self, *, target_uri: str = "", limit: int = 50) -> list[dict]: + """Consume durable apply events from applied OPL proposals.""" + events: list[dict] = [] + for path, content in self._op_files("OPL", target_uri=target_uri): + fm = parse_frontmatter(content) + if target_uri and str(fm.get("target_uri", "")) != target_uri: + continue + if str(fm.get("apply_status", "")) != "applied": + continue + opl_id = str(fm.get("id", Path(path).stem)) + events.append( + { + "event": "opl_applied", + "uri": self._op_uri("OPL", opl_id), + "target_uri": str(fm.get("target_uri", "")), + "applied_at": str(fm.get("applied_at", "")), + "expert_id": str(fm.get("expert_id", "")), + }, + ) + if len(events) >= max(1, limit): + break + return events diff --git a/src/wolfharness/capabilities/wiki/tickets/ticket.py b/src/wolfharness/capabilities/wiki/tickets/ticket.py new file mode 100644 index 000000000..c7e3455e1 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/tickets/ticket.py @@ -0,0 +1,1077 @@ +"""Ticket surface for the WikiBuildCapability (external expert / eval flow). + +Exposes the OPA → OPS → OPL knowledge loop as *tickets* so an outside +system (an evaluator, an external expert) can submit a problem, attach an +expert recommendation, form an integrated proposal, and apply it to the +Wiki through six tool closures: + +- ``create_opa_ticket`` — file or revise a problem/feedback record (OPA). +- ``create_ops_ticket`` — attach an expert recommendation (OPS) to an OPA. +- ``create_opl_ticket`` — integrate one OPA + its OPS into a proposal + (OPL) snapshot. +- ``apply_opl_ticket`` — apply the OPL patch to the Wiki, close the OPA, + and return a terminal status. +- ``get_ticket_status`` — read the current state of OPA / OPS / OPL + tickets for one target or OPA. +- ``submit_eval_payload`` — one-shot ingestion of an eval revision + payload (``EvalPayload``) that materializes OPA + OPS tickets from it. + +The ticket models mirror the eval revision JSON (``target``, +``cited_references``, ``expert_opinion``, ``suggested_resolution``, ...); +``cited_references[].uri`` and ``evidence`` (修改关联的uri) are the +tractable engine evidence, and ``target.entity_uri`` is the source/target. +All persistence goes through the underlying ``WikiBuildTools`` engine — +nothing here re-implements OP storage. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import ( + Callable, # noqa: TC003 — runtime: function_schema evaluates closure annotations +) +import contextlib +import os +from typing import TYPE_CHECKING, Any + +from pydantic import BaseModel, Field +from pydantic_ai.tools import ( + RunContext, # noqa: TC002 — runtime: function_schema evaluates closure annotations +) + +from wolfharness.log import get_logger + + +if TYPE_CHECKING: + from wolfharness.capabilities.wiki.build import WikiBuildCapability + +logger = get_logger(__name__) + +# Viking metadata files that are not real entity pages. +_OP_METADATA_FILENAMES = frozenset({".overview.md", ".abstract.md", "entities.json"}) + +# --------------------------------------------------------------------------- +# Ticket inventory +# --------------------------------------------------------------------------- + +TICKET_TOOL_NAMES: frozenset[str] = frozenset( + { + "create_opa_ticket", + "create_ops_ticket", + "create_opl_ticket", + "apply_opl_ticket", + "get_ticket_status", + "submit_eval_payload", + }, +) + + +# --------------------------------------------------------------------------- +# Ticket models (mirror the eval revision JSON contract) +# --------------------------------------------------------------------------- + + +class CitedReference(BaseModel): + """One cited reference inside an eval revision (technical evidence URI).""" + + ref_id: str = "" + title: str = "" + uri: str = "" + + +class TicketTarget(BaseModel): + """The wiki entity a revision is anchored to (``target`` block).""" + + entity_uri: str = "" + entity_type: str = "" + matched_section_title: str = "" + content_snippet: str = "" + + +class EvalRevision(BaseModel): + """One eval-generated revision; sediments into an OPA and an OPS. + + Field mapping to the engine: + - ``target.entity_uri`` → OPA/OPS ``target_uri`` (the source). + - ``cited_references[].uri`` + ``evidence`` → ``evidence_uris``. + - ``target.content_snippet`` → OPA problem ``description`` / + ``finding`` / ``missing``. + - ``suggested_resolution`` → OPA ``recommendation`` / OPS ``analysis``. + - ``expert_opinion`` → OPS ``solution``. + """ + + ticket_id: str = "" + kind: str = "OPA" + ops_ref: str = "" + annotation_ref: str = "" + anchor_status: str = "" + resolution_path: str = "" + target: TicketTarget | None = None + cited_references: list[CitedReference] = Field(default_factory=list) + expert_opinion: str = "" + evidence: list[str] = Field(default_factory=list) + suggested_resolution: str = "" + source: str = "reflect" + status: str = "pending_external_submission" + + +class EvalPayload(BaseModel): + """Top-level eval output: one entity + N revisions.""" + + eval: str = "" + round: int = 0 + entity_uri: str = "" + revisions: list[EvalRevision] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _safe_create_opa(tools: Any, /, **kwargs: Any) -> dict[str, Any]: + """Call ``tools.create_opa`` with ``skip_dedupe_lookup`` if supported. + + Older ``WikiBuildTools`` versions may not accept ``skip_dedupe_lookup``; + fall back to calling without it on ``TypeError``. + """ + try: + return tools.create_opa(**kwargs) + except TypeError: + kwargs.pop("skip_dedupe_lookup", None) + return tools.create_opa(**kwargs) + + +def _record_id(value: str, prefix: str) -> str: + """Accept either a record id or a backend URI and return its id.""" + token = value.rstrip("/").rsplit("/", 1)[-1].removesuffix(".md") + return token if token.startswith(prefix + "-") else value + + +async def _notify_opl_applied(ctx: RunContext[Any], result: dict[str, Any]) -> dict[str, str]: + """Best-effort steer notification backed by the durable event ledger.""" + event = result.get("event") + if ( + result.get("apply_status") != "applied" + or result.get("idempotent") is True + or not isinstance(event, dict) + ): + return {"status": "skipped", "reason": "no_applied_event"} + from wolfharness.capabilities.viking.tools import _get_session_id + + session_id = _get_session_id(ctx) + try: + session_pool = ctx.deps.node.host_context.session_pool + except AttributeError: + session_pool = None + if session_pool is None or session_id is None: + return {"status": "unavailable", "event_id": str(event.get("event_id", ""))} + message = ( + "Wiki expert update applied: " + f"event={event.get('event_id', '')}, target={event.get('target_uri', '')}, " + f"opl={event.get('opl_uri', '')}, scopes={event.get('authority_scopes', [])}. " + f"Continue from change-event cursor {event.get('sequence', 0)}." + ) + try: + message_id = await asyncio.wait_for( + session_pool.steer_from_background_task(session_id, message), + timeout=5, + ) + except (TimeoutError, RuntimeError) as error: + logger.warning("OPL apply notification failed: %s", error) + return { + "status": "failed", + "event_id": str(event.get("event_id", "")), + "error": str(error), + } + return { + "status": "delivered" if message_id is not None else "queued", + "event_id": str(event.get("event_id", "")), + "message_id": str(message_id or ""), + } + + +def _title_from_text(text: str, fallback: str) -> str: + first_line = next( + (line.strip() for line in text.strip().splitlines() if line.strip()), + "", + ) + return (first_line[:60] or fallback) if first_line else fallback + + +def _ticket_evidence( + revision: EvalRevision | None, + *, + is_uri_valid: Callable[[str], bool] | None = None, +) -> list[str]: + """Collect tractable evidence URIs from an eval revision. + + Combines ``cited_references[].uri`` (引用的证据) and ``evidence`` + (修改关联的uri) — both are user contracts that must flow into the + OPA/OPS/OPL ``evidence_uris``. + + ``cited_references[].uri`` entries are trusted references and are always + kept. ``evidence`` entries are free-form user expressions: they may + carry provider URIs or plain audit text (for example ``QuotedText: ...`` + or ``Matched knowledge snippet: ...``). When *is_uri_valid* is supplied + (the engine's ``is_valid_op_uri`` predicate), only entries that pass are + treated as URIs; plain-text entries are dropped instead of being + submitted as invalid evidence URIs. + """ + if revision is None: + return [] + cited = [ref.uri.strip() for ref in revision.cited_references if ref.uri.strip()] + if is_uri_valid is None: + linked = [uri.strip() for uri in revision.evidence if uri.strip()] + else: + linked = [ + uri.strip() for uri in revision.evidence if uri.strip() and is_uri_valid(uri.strip()) + ] + return list(dict.fromkeys([*cited, *linked])) + + +def _sync_entity_to_remote(tools: Any, target_uri: str, opl_uri: str) -> dict[str, object]: + """Push the patched entity page and OPL record to remote Viking. + + Best-effort: if the SDK is unavailable or the API key is missing, + returns a ``skipped`` status without raising. + """ + if not target_uri.startswith("viking://"): + return {"sync_status": "skipped", "sync_reason": "target is not a viking:// URI"} + + api_key = os.environ.get("VIKING_API_KEY", "") + if not api_key: + return {"sync_status": "skipped", "sync_reason": "VIKING_API_KEY not set"} + + try: + from openviking_sdk import SyncHTTPClient + except ImportError: + return {"sync_status": "skipped", "sync_reason": "openviking-sdk not installed"} + + base_url = os.environ.get("VIKING_BASE_URL", "http://viking.ai.rootcloud.info/") + client = SyncHTTPClient(url=base_url, api_key=api_key) + initialize = getattr(client, "initialize", None) + if callable(initialize): + initialize() + + result: dict[str, object] = {"sync_status": "ok"} + errors: list[str] = [] + + entity_content = tools.read_resource(target_uri) + if entity_content: + try: + try: + stat = getattr(client, "stat", None) + if callable(stat): + stat(target_uri) + write_mode = "replace" + except Exception: + write_mode = "create" + client.write(target_uri, entity_content, mode=write_mode, wait=True) + result["synced_entity_uri"] = target_uri + except Exception as exc: + errors.append(f"entity: {exc}") + logger.warning("Sync entity to remote failed: %s", exc) + + if opl_uri.startswith("viking://"): + opl_content = tools.read_resource(opl_uri) + if opl_content: + try: + try: + stat = getattr(client, "stat", None) + if callable(stat): + stat(opl_uri) + write_mode = "replace" + except Exception: + write_mode = "create" + client.write(opl_uri, opl_content, mode=write_mode, wait=True) + result["synced_opl_uri"] = opl_uri + except Exception as exc: + errors.append(f"opl: {exc}") + logger.warning("Sync OPL to remote failed: %s", exc) + + if errors: + result["sync_status"] = "partial" + result["sync_errors"] = errors + + return result + + +# --------------------------------------------------------------------------- +# Ticket tool closures +# --------------------------------------------------------------------------- + + +def _build_ticket_fns(tools: Any, *, sync_after_apply: bool = False) -> list[Callable[..., Any]]: + """Build the six ticket tool closures capturing the tools instance.""" + # OPA/OPS ticket closures re-derive evidence URIs from the eval revision + # dict, bypassing the provider-side adapter filter. Capture the engine's + # URI-validity predicate so plain-text ``evidence`` entries are not + # blindly submitted as evidence URIs. Falls back to the historical + # blind merge when the engine does not expose ``is_valid_op_uri``. + raw_predicate = getattr(tools, "is_valid_op_uri", None) + is_uri_valid: Callable[[str], bool] | None = raw_predicate if callable(raw_predicate) else None + + async def create_opa_ticket( + ctx: RunContext[Any], + *, + description: str = "", + related_uris: list[str] | None = None, + target_uri: str = "", + opa_uri: str = "", + title: str = "", + target_section: str = "", + finding: str = "", + missing: str = "", + recommendation: str = "", + ticket: dict[str, object] | None = None, + ) -> dict[str, Any]: + """File or revise an OPA ticket (problem/feedback for one target). + + Creates a ``category=feedback``, ``reason_code=expert_feedback`` + OPA targeted at ``target_uri``, or — when ``opa_uri`` is given — + revises that existing OPA record in place. + + Each submission without ``opa_uri`` creates its own distinct OPA + record: the same problem reported again (possibly by another + expert) is filed as a new record, never merged into a prior one. + + When ``ticket`` is provided, fields are derived from an eval + revision: ``target.entity_uri`` → source/target, + ``target.content_snippet`` or ``suggested_resolution`` → problem + description, ``cited_references[].uri`` plus ``evidence`` + (修改关联的uri) → evidence URIs. Explicit args override derived + values. + + All URIs use the ``viking://resources/<namespace>/...`` scheme. + Create the OPA for the given ``target_uri`` directly — the returned + ``uri`` is the audit anchor that later OPS/OPL steps must reuse. + Do not call ``get_ticket_status`` first; the create result is the + source of truth for what was written. + + Args: + description: The problem / feedback content. First line + becomes the title when ``title`` is empty. + related_uris: ``viking://`` URI references (evidence pages, + source chapters, cited references, 修改关联的uri). + target_uri: ``viking://`` URI of the wiki entity this ticket + targets (the source). + opa_uri: ``viking://`` URI of an existing OPA ticket to + revise instead of creating a new one. + title: Optional short title; derived from ``description``. + target_section: Optional page section this problem refers to. + finding: Optional structured finding; defaults to + ``description``. + missing: Optional description of what is missing; defaults to + ``description``. + recommendation: Optional suggested fix; falls back to + ``finding``. + ticket: Optional ``EvalRevision``-shaped dict to derive the + fields above from (eval revision output). + + Returns: + Dict with ``opa_id``, ``uri``, ``title``, ``target_uri``, + ``status``. Keep the returned ``uri`` for revision. + """ + revision = EvalRevision.model_validate(ticket) if ticket is not None else None + target = revision.target if revision is not None else None + derived_evidence = _ticket_evidence(revision, is_uri_valid=is_uri_valid) + description = description or ( + (target.content_snippet if target is not None and target.content_snippet else "") + or (revision.suggested_resolution if revision is not None else "") + or "" + ) + effective_related = list( + dict.fromkeys(uri.strip() for uri in (related_uris or []) if uri.strip()), + ) + evidence = list(dict.fromkeys([*derived_evidence, *effective_related])) + effective_target = target_uri or (target.entity_uri if target is not None else "") + effective_section = target_section or ( + target.matched_section_title if target is not None else "" + ) + effective_title = ( + title.strip() + or (revision.ticket_id if revision is not None and revision.ticket_id else "") + or _title_from_text(description, "专家反馈") + ) + opa_id = ( + _record_id(opa_uri, "opa") + if opa_uri + else ( + _record_id(revision.ticket_id, "opa") + if revision is not None and revision.ticket_id + else "" + ) + ) + effective_finding = finding or description + return await asyncio.to_thread( + _safe_create_opa, + tools, + opa_id=opa_id, + title=effective_title, + description=description, + category="feedback", + reason_code="expert_feedback", + target_uri=effective_target, + target_section=effective_section, + evidence_uris=evidence, + status="pending", + finding=effective_finding, + missing=missing or description, + recommendation=recommendation or effective_finding, + skip_dedupe_lookup=True, + ) + + async def create_ops_ticket( + ctx: RunContext[Any], + *, + suggestion: str = "", + related_uris: list[str] | None = None, + parent_opa: str = "", + ops_uri: str = "", + title: str = "", + expert_id: str = "", + expert_name: str = "", + ticket: dict[str, object] | None = None, + ) -> dict[str, Any]: + """Submit or revise an OPS ticket (expert recommendation / draft). + + When ``ops_uri`` is provided, the existing OPS record is revised + in place; otherwise a new OPS is created under ``parent_opa`` as + an unconfirmed draft. + + When ``ticket`` is provided, the suggestion and evidence URIs are + derived from an eval revision: ``expert_opinion`` / + ``suggested_resolution`` → suggestion, ``cited_references[].uri`` + plus ``evidence`` (修改关联的uri) → evidence URIs. + + Create or revise the OPS under ``parent_opa`` — the ``viking://`` URI + returned by ``create_opa_ticket``. Do not call ``get_ticket_status`` + to rediscover the parent; pass the returned URI directly. + + Args: + suggestion: The expert's recommendation. First line becomes + the title; remaining lines become the analysis. + related_uris: ``viking://`` URI references (evidence — + cited_references from an eval revision). + parent_opa: OPA id or ``viking://`` URI this ticket attaches + to. Required when creating a new record. + ops_uri: ``viking://`` URI of an existing OPS to revise + instead of creating a new one. + title: Optional short title; derived from ``suggestion``. + expert_id: Expert identifier recorded on the draft. + expert_name: Expert display name recorded on the draft. + ticket: Optional ``EvalRevision``-shaped dict to derive the + suggestion / evidence from. + + Returns: + Dict with ``ops_id``, ``uri``, ``parent_opa``, + ``target_uri``, ``status``. Keep the returned ``uri`` for + revision. + """ + revision = EvalRevision.model_validate(ticket) if ticket is not None else None + suggestion = suggestion or ( + (revision.expert_opinion if revision is not None else "") + or (revision.suggested_resolution if revision is not None else "") + or "" + ) + derived_evidence = _ticket_evidence(revision, is_uri_valid=is_uri_valid) + effective_related = list( + dict.fromkeys(uri.strip() for uri in (related_uris or []) if uri.strip()), + ) + evidence = list(dict.fromkeys([*derived_evidence, *effective_related])) + lines = [line.strip() for line in suggestion.strip().splitlines() if line.strip()] + effective_title = ( + title.strip() + or (revision.ops_ref if revision is not None and revision.ops_ref else "") + or _title_from_text(suggestion, "专家建议") + ) + effective_analysis = "\n".join(lines[1:]) if len(lines) > 1 else suggestion.strip() + return await asyncio.to_thread( + tools.ingest_external_ops, + parent_opa=parent_opa, + title=effective_title, + analysis=effective_analysis, + solution=suggestion.strip(), + evidence_uris=evidence, + expert_id=expert_id, + expert_name=expert_name, + ops_uri=ops_uri, + ) + + async def update_ops_ticket( + ctx: RunContext[Any], + *, + ops_uri: str, + title: str = "", + analysis: str = "", + solution: str = "", + evidence_uris: list[str] | None = None, + related_uris: list[str] | None = None, + candidate_content: str = "", + candidate_operations: list[dict[str, object]] | None = None, + expected_sha256: str = "", + status: str = "", + reviewed_by: str = "", + review_notes: str = "", + ) -> dict[str, Any]: + """Patch one OPS ticket in place, leaving untouched fields intact. + + A true patch, unlike ``create_ops_ticket`` with ``ops_uri`` (which + rewrites the record from the full ``suggestion`` text). Only the + fields you pass are changed; every omitted field keeps its current + value. This is the capability's external-facing update surface, + mirroring the engine's ``update_ops``. + + Pass ``status`` to transition the draft — ``unconfirmed`` | + ``confirmed`` | ``rejected``; confirming or rejecting requires + ``reviewed_by``. Reuse the ``ops_uri`` returned by + ``create_ops_ticket``; do not rediscover it via ``get_ticket_status``. + + Args: + ops_uri: ``viking://`` URI of the existing OPS ticket. + title: New short title (kept when omitted). + analysis: New analysis body (kept when omitted). + solution: New expert solution text (kept when omitted). + evidence_uris: New ``viking://`` evidence references. + related_uris: New ``viking://`` related references. + candidate_content: New full replacement markdown candidate. + candidate_operations: List of deterministic patch operations. Each + is a dict with an ``op`` key; supported ops: + - Content patch ops (mutually exclusive with + ``candidate_content``): ``line_replace``/``line_insert``/ + ``line_delete``, ``section_replace``/``section_insert_after``, + ``fm_append``/``fm_set``/``fm_set_list``. + - Storage relocation op ``move_entity``: + ``{"op": "move_entity", "dst_class_name": "...", + "dst_object_name": "..."}``. Relocates the target entity file + to ``Component/<dst_class_name>/<dst_object_name>.md`` + (omitted values carry over the current class/name). The + target file is physically moved (neither + ``candidate_content`` nor ``expected_sha256`` is used); an + emptied source class directory is pruned, and the old URI is + kept via a redirect. Use this for class/prefix (BOM 归类) + changes, not content edits. + expected_sha256: New locked-content SHA-256 for apply-time + optimistic locking (required for content patches on an + existing target; not needed for a pure ``move_entity``). + status: New status — ``unconfirmed`` | ``confirmed`` | + ``rejected``. + reviewed_by: Reviewer identifier; required to confirm/reject. + review_notes: Notes recorded with a status transition. + + Returns: + Dict with ``ops_id``, ``uri``, ``parent_opa``, ``target_uri``, + ``status``. Keep the returned ``uri`` for further patches. + """ + ops_id = _record_id(ops_uri, "ops") + return await asyncio.to_thread( + tools.update_ops, + ops_id, + title=title or None, + analysis=analysis or None, + solution=solution or None, + evidence_uris=evidence_uris, + related_uris=related_uris, + candidate_content=candidate_content or None, + candidate_operations=candidate_operations, + expected_sha256=expected_sha256 or None, + status=status or None, + reviewed_by=reviewed_by, + review_notes=review_notes, + ) + + async def create_opl_ticket( + ctx: RunContext[Any], + *, + ticket: dict[str, object] | None = None, + parent_opa: str, + ops_uris: list[str], + proposal: str = "", + rationale: str = "", + title: str = "", + evidence_uris: list[str] | None = None, + related_uris: list[str] | None = None, + target_uri: str = "", + candidate_content: str = "", + candidate_operations: list[dict[str, object]] | None = None, + expected_sha256: str = "", + expert_id: str = "", + expert_name: str = "", + opl_uri: str = "", + ) -> dict[str, Any]: + """Integrate one OPA and its OPS records into an OPL ticket. + + Each referenced OPS is confirmed (``reviewed_by`` = expert or + ``external_ticket``) and then archived into a single OPL + snapshot. ``candidate_content`` (full replacement markdown) or + ``candidate_operations`` (deterministic patch ops) is what + ``apply_opl_ticket`` will later merge into the Wiki. + + When ``ticket`` is provided, ``proposal`` (suggested_resolution), + ``rationale`` (expert_opinion + content_snippet) and + ``evidence_uris`` (cited_references + evidence, 修改关联的uri) are + derived from an eval revision. + + Args: + ticket: Optional ``EvalRevision``-shaped dict (eval revision). + parent_opa: OPA id or ``viking://`` URI the ticket attaches + to. + ops_uris: ``viking://`` URIs of the OPS records to integrate. + proposal: The proposed knowledge change. + rationale: Why the change is correct (expert opinion / + source reasoning). + title: Optional short title; derived from ``proposal``. + evidence_uris: Extra ``viking://`` evidence beyond what the + OPA/OPS already carry. + related_uris: ``viking://`` related URIs. + target_uri: Must match the OPA's ``target_uri`` when given. + candidate_content: Optional full replacement page markdown. + candidate_operations: Optional list of deterministic patch + operations. Each is a dict with an ``op`` key; supported ops: + - Content patch ops (mutually exclusive with + ``candidate_content``): ``line_replace``/``line_insert``/ + ``line_delete``, ``section_replace``/``section_insert_after``, + ``fm_append``/``fm_set``/``fm_set_list``. + - Storage relocation op ``move_entity``: + ``{"op": "move_entity", "dst_class_name": "...", + "dst_object_name": "..."}``. Physically relocates the target + entity file to ``Component/<dst_class_name>/<dst_object_name>.md`` + (omitted values carry over the current class/name); the + emptied source class directory is pruned and the old URI kept + via a redirect. Use this for class/prefix (BOM 归类) changes, + not content edits. A pure ``move_entity`` carries no + ``candidate_content`` and needs no ``expected_sha256``. + expected_sha256: SHA-256 hex of current target content (for + optimistic locking at apply time; required for content patches + on an existing target, not needed for a pure ``move_entity``). + expert_id: Expert identifier (also the OPS reviewer). + expert_name: Expert display name. + opl_uri: ``viking://`` URI of an existing OPL to revise. + + Returns: + Dict with ``opl_id``, ``uri``, ``parent_opa``, ``ops_uris``, + ``target_uri``, ``status``, ``apply_status``. + """ + revision = EvalRevision.model_validate(ticket) if ticket is not None else None + target = revision.target if revision is not None else None + proposal = proposal or (revision.suggested_resolution if revision is not None else "") + if not rationale.strip() and revision is not None: + rationale = "".join( + piece + for piece in (revision.expert_opinion, target.content_snippet if target else "") + if piece + ) + effective_derived_evidence = _ticket_evidence(revision, is_uri_valid=is_uri_valid) + effective_evidence = list( + dict.fromkeys( + [ + *effective_derived_evidence, + *(uri.strip() for uri in (evidence_uris or []) if uri.strip()), + ], + ), + ) + effective_target_uri = target_uri or (target.entity_uri if target is not None else "") + effective_title = title.strip() or _title_from_text(proposal, "专家提案") + ops_ids = [_record_id(uri, "ops") for uri in ops_uris if uri.strip()] + reviewer = expert_id.strip() or "external_ticket" + for ops_id in ops_ids: + await asyncio.to_thread( + tools.update_ops, + ops_id, + status="confirmed", + reviewed_by=reviewer, + review_notes=f"Confirmed by ticket OPL: {expert_name or expert_id}", + ) + opl_id = _record_id(opl_uri, "opl") if opl_uri else "" + return await asyncio.to_thread( + tools.create_opl, + parent_opa=parent_opa, + ops_uris=ops_uris, + title=effective_title, + proposal=proposal, + rationale=rationale, + evidence_uris=effective_evidence, + related_uris=related_uris, + target_uri=effective_target_uri, + opl_id=opl_id, + source_type="external_expert", + expert_id=expert_id, + expert_name=expert_name, + candidate_content=candidate_content, + candidate_operations=candidate_operations, + expected_sha256=expected_sha256, + ) + + async def apply_opl_ticket( + ctx: RunContext[Any], + *, + ticket: dict[str, object] | None = None, + opl_uri: str, + expert_id: str = "", + expert_name: str = "", + ) -> dict[str, Any]: + """Apply an OPL ticket to the Wiki and close its ticket chain. + + Merges the stored ``candidate_content`` / ``candidate_operations`` + into the target entity (ownership enforced by the engine), resolves + the parent OPA as ``closed``, persists a cursor-addressable change + event, notifies the current agent session when available, and — when + the capability was built with ``sync_after_apply`` — pushes the patched page and OPL to + remote Viking. Returns the terminal status for the caller. + + Args: + ticket: Optional ``EvalRevision``-shaped dict (used for + traceability in the returned status; not required to + apply an already-created OPL). + opl_uri: ``viking://`` URI of the OPL ticket to apply + (returned by ``create_opl_ticket``). + expert_id: Expert identifier (recorded when closing the OPA). + expert_name: Expert display name. + + Returns: + Dict with ``opl_id``, ``status``, ``apply_status``, + ``entity_uri``, ``opa`` (closed record), ``event``, + ``notification`` and — when sync ran — ``sync``. + """ + opl_id = _record_id(opl_uri, "opl") + result: dict[str, Any] = await asyncio.to_thread(tools.apply_opl, opl_id) + opl_rows = await asyncio.to_thread(tools.get_opls, limit=200) + opl_row = next( + (row for row in opl_rows if row["opl_id"] == opl_id), + None, + ) + if result.get("apply_status") == "applied" and opl_row is not None: + parent_id = _record_id(str(opl_row.get("parent_opa", "")), "opa") + if parent_id: + entity_uri = str(result.get("entity_uri", "")) + with contextlib.suppress(FileNotFoundError): + result["opa"] = await asyncio.to_thread( + tools.resolve_opa, + parent_id, + solution=str(opl_row.get("proposal", "")), + evidence_uris=[entity_uri] if entity_uri else None, + closure_status="closed", + closure_reason="applied via ticket OPL", + ) + result["notification"] = await _notify_opl_applied(ctx, result) + if sync_after_apply and result.get("apply_status") == "applied": + entity_uri = str(result.get("entity_uri", "")) or ( + str(opl_row.get("target_uri", "")) if opl_row else "" + ) + opl_uri_effective = str(opl_row.get("uri", opl_uri)) if opl_row else opl_uri + result["sync"] = await asyncio.to_thread( + _sync_entity_to_remote, + tools, + entity_uri, + opl_uri_effective, + ) + return result + + async def get_ticket_status( + ctx: RunContext[Any], + *, + parent_opa: str = "", + target_uri: str = "", + limit: int = 50, + ) -> dict[str, Any]: + """Read the current state of OPA / OPS / OPL tickets. + + Filters OPA records by ``target_uri`` and OPS/OPL records by + ``parent_opa``. Always includes the engine-level OP flow report + (``open_opa_count`` / ``review_pending``). + + Args: + parent_opa: OPA id or ``viking://`` URI to scope OPS/OPL + records to. + target_uri: ``viking://`` entity URI to scope OPA records to. + limit: Maximum records per category (1..200). + + Returns: + Dict with ``opas``, ``ops``, ``opls``, and the merged + ``op_flow`` state. + """ + opas = await asyncio.to_thread(tools.get_opas, target_uri=target_uri, limit=limit) + ops = await asyncio.to_thread( + tools.get_ops, parent_opa=parent_opa, target_uri=target_uri, limit=limit + ) + opls = await asyncio.to_thread( + tools.get_opls, parent_opa=parent_opa, target_uri=target_uri, limit=limit + ) + # op_flow_status is a global scan (get_opas 500 + get_ops 10000) — skip + # it when a single target_uri is queried; it's irrelevant to per-target + # ticket lookup and dominates latency on remote backends. + if target_uri: + op_flow: dict[str, Any] = {} + else: + op_flow = await asyncio.to_thread(tools.op_flow_status, limit=limit) + authority = await asyncio.to_thread( + tools.get_expert_authority, + target_uri=target_uri, + limit=limit, + ) + events = await asyncio.to_thread( + tools.get_wiki_change_events, + target_uri=target_uri, + limit=limit, + ) + return { + "opas": opas, + "ops": ops, + "opls": opls, + "op_flow": op_flow, + "expert_authority": authority, + "events": events, + } + + async def submit_eval_payload( + ctx: RunContext[Any], + *, + payload: dict[str, Any], + expert_id: str = "", + expert_name: str = "", + ) -> dict[str, Any]: + """Sediment an eval payload as OPA + OPS-draft tickets (链路 C). + + Pure ingestion: each ``revisions[]`` entry is materialized through + the engine — ``target.entity_uri`` is the source, + ``cited_references[].uri`` plus ``evidence`` (修改关联的uri) the + evidence, ``content_snippet`` the OPA problem statement and + ``expert_opinion`` the expert assessment. + + An OPA ticket is always created. An OPS draft is created only when + the revision carries an actionable solution (a non-empty + ``suggested_resolution``); otherwise the ticket stays as OPA only + (no ``ops`` entry in its record). The capability never judges + whether an assessment is actionable — that decision stays on the + business side, which decides whether to supply a + ``suggested_resolution``. + + This is sedimentation only: no OPL snapshot, confirmation or apply + happens here. Iterating the draft (``create_ops_ticket`` with + ``ops_uri``), integrating into an OPL proposal + (``create_opl_ticket``) and applying to the Wiki + (``apply_opl_ticket``) are explicit frontend-driven steps. + + Args: + payload: An ``EvalPayload``-shaped dict: + ``{"eval", "round", "entity_uri", "revisions": [...]}``. + expert_id: Expert identifier recorded on the sedimented tickets. + expert_name: Expert display name recorded on the sedimented + tickets. + + Returns: + Dict with ``round``, ``eval``, ``tickets`` (one entry per + revision: ticket id, kind, OPA/OPS ids and URIs, statuses). + """ + payload_obj = EvalPayload.model_validate(payload) + records: list[dict[str, Any]] = [] + for revision in payload_obj.revisions: + target = revision.target + target_uri = (target.entity_uri if target is not None else "") or payload_obj.entity_uri + evidence_uris = _ticket_evidence(revision, is_uri_valid=is_uri_valid) + description = ( + (target.content_snippet if target is not None else "") + or revision.suggested_resolution + or revision.expert_opinion + or revision.ticket_id + ) + opa_result = await create_opa_ticket( + ctx, + ticket=revision.model_dump(mode="json"), + description=description, + related_uris=evidence_uris, + target_uri=target_uri, + title=revision.ticket_id, + finding=description, + missing=description, + recommendation=revision.suggested_resolution, + ) + record: dict[str, Any] = { + "ticket_id": revision.ticket_id, + "kind": revision.kind or "OPA", + "annotation_ref": revision.annotation_ref, + "source": revision.source, + "status": revision.status, + "opa": { + "opa_id": opa_result["opa_id"], + "uri": opa_result["uri"], + "target_uri": opa_result.get("target_uri", target_uri), + }, + } + if revision.suggested_resolution.strip(): + ops_result = await create_ops_ticket( + ctx, + ticket=revision.model_dump(mode="json"), + suggestion=revision.suggested_resolution.strip(), + related_uris=evidence_uris, + parent_opa=str(opa_result["opa_id"]), + title=revision.ops_ref or "", + expert_id=expert_id, + expert_name=expert_name, + ) + record["ops"] = { + "ops_id": ops_result["ops_id"], + "uri": ops_result["uri"], + "parent_opa": ops_result.get("parent_opa", ""), + } + records.append(record) + return { + "eval": payload_obj.eval, + "round": payload_obj.round, + "entity_uri": payload_obj.entity_uri, + "ticket_count": len(records), + "tickets": records, + } + + async def find_op( + ctx: RunContext[Any], + *, + query: str = "", + prefix: str = "", + limit: int = 50, + ) -> dict[str, Any]: + """Fast precise lookup over ticket & component identifiers in the wiki store. + + Ticket-domain search — directly uses the OpenViking client grep + primitive against the configured wiki root, NOT the slower semantic + ``find_wiki``. Resolves a ``bom_path`` prefix, an ``object_name``, a + class keep, or a ``OP/`` ticket id before assembling a candidate. + Each match carries the matched line content (e.g. the full + ``bom_path: ...`` value) and its URI. + """ + if not query and not prefix: + return {"entries": [], "error": "find_op requires query or prefix"} + _fs = getattr(getattr(tools, "store", None), "_fs", None) + if _fs is None: + return {"entries": [], "error": "wiki store not available"} + client = getattr(_fs, "_client", None) + target = str(getattr(_fs, "root_uri", "") or "").rstrip("/") + entries: list[dict[str, object]] = [] + + if client is not None: + result = await asyncio.to_thread( + client.grep, + target or "", + query or prefix, + node_limit=limit, + ) + for match in (result or {}).get("matches", []) or []: + uri = str(match.get("uri", "")) if isinstance(match, dict) else "" + if not uri or not uri.startswith(target + "/"): + continue + leaf = uri.rsplit("/", 1)[-1] + if leaf in _OP_METADATA_FILENAMES: + continue + entries.append( + { + "uri": uri, + "content": match.get("content", "") if isinstance(match, dict) else "", + }, + ) + if len(entries) >= limit: + break + return {"entries": entries} + keys = await asyncio.to_thread(_fs.list_dir, target, recursive=True) + for key in keys: + if prefix and not str(key).startswith(prefix): + continue + entries.append({"key": str(key), "uri": f"{target}/{key}"}) + if len(entries) >= limit: + break + return {"entries": entries} + + return [ + create_opa_ticket, + create_ops_ticket, + update_ops_ticket, + create_opl_ticket, + apply_opl_ticket, + get_ticket_status, + submit_eval_payload, + find_op, + ] + + +# --------------------------------------------------------------------------- +# Main entry points +# --------------------------------------------------------------------------- + + +def build_ticket_tools(cap: WikiBuildCapability) -> list[Callable[..., Any]]: + """Build the ticket tool closures for the WikiBuildCapability. + + Args: + cap: The ``WikiBuildCapability`` instance that owns the tools. + + Returns: + A list of async tool functions suitable for ``FunctionToolset``. + """ + cap._ensure_tools() + tools = cap.tools + if tools is None: + return [] + return _build_ticket_fns(tools, sync_after_apply=cap.config.sync_after_apply) + + +def get_ticket_instructions() -> str: + """Return guidance for an agent using the ticket surface. + + Returns: + A multi-line instruction block describing the seven ticket tools + and the recommended OPA → OPS → OPL → apply flow. + """ + return ( + "You are a wiki ticket interface. All wiki resources use the " + "viking:// URI scheme (e.g. viking://resources/814/Component/...).\n" + "\n" + "Discovery:\n" + "- read_resource(uri) / find_wiki(query) — locate the target entity " + "page and cited source chapters.\n" + "- get_ticket_status(target_uri=...) — check existing OPA/OPS/OPL " + "tickets, expert authority claims, and recent apply events before " + "submitting new ones.\n" + "- get_expert_authority(target_uri=...) — inspect sections/fields " + "protected by confirmed or applied expert knowledge.\n" + "- get_wiki_change_events(after_sequence=...) — consume durable " + "apply notifications after reconnect/restart.\n" + "\n" + "Ticket tools:\n" + "1. create_opa_ticket — file a problem/feedback ticket (OPA) with a " + "description and related viking:// URIs. Pass opa_uri to revise.\n" + "2. create_ops_ticket — attach an expert recommendation (OPS) to an " + "OPA: suggestion text + related URIs. Pass ops_uri to revise the " + "same recommendation until accepted.\n" + "3. update_ops_ticket — patch an existing OPS in place (only the " + "fields you pass change). Pass status to confirm or reject the " + "draft, with reviewed_by.\n" + "4. create_opl_ticket — integrate one OPA + its OPS into an OPL " + "proposal. Provide candidate_content (full replacement markdown) " + "and expected_sha256 (SHA-256 of current page content, computed " + "from read_resource output) when the patch is machine-applicable. " + "For a class/prefix (BOM 归类) change, pass candidate_operations " + "instead: [{'op': 'move_entity', 'dst_class_name': '...', " + "'dst_object_name': '...'}] to physically relocate the target file " + "to Component/<dst_class_name>/<dst_object_name>.md (no " + "candidate_content/expected_sha256; emptied source dir pruned, old " + "URI kept via redirect).\n" + "5. apply_opl_ticket — merge the OPL candidate into the target page, " + "activate scoped expert authority, emit a durable event, notify the " + "current agent, and close the ticket chain.\n" + "6. get_ticket_status — read OPS/OPL lifecycle state plus authority " + "and durable event state.\n" + "7. submit_eval_payload — ingest a full eval revision payload " + "({eval, round, entity_uri, revisions: [...]}) and materialize " + "OPA/OPS tickets from it. Per revision: target.entity_uri → OPA " + "target_uri, content_snippet → OPA problem description, " + "suggested_resolution → OPA recommendation and OPS suggestion, " + "cited_references[].uri + evidence → OPA/OPS evidence_uris, " + "expert_opinion → fallback description. An empty " + "suggested_resolution leaves the ticket OPA-only (no OPS).\n" + "\n" + "Typical flow:\n" + " find_wiki → read_resource(target) → create_opa_ticket → " + "create_ops_ticket → create_opl_ticket → apply_opl_ticket.\n" + "Each submission tool returns a URI; keep it and pass it back on " + "the next revision." + ) diff --git a/src/wolfharness/capabilities/wiki/tickets/ticket_engine.py b/src/wolfharness/capabilities/wiki/tickets/ticket_engine.py new file mode 100644 index 000000000..4b48b1b4e --- /dev/null +++ b/src/wolfharness/capabilities/wiki/tickets/ticket_engine.py @@ -0,0 +1,3221 @@ +"""TicketEngine — standalone OPA/OPS/OPL ticket engine for wiki knowledge revisions. + +Extracted from ``opa.py`` (OPAMixin). OPA records live under ``OP/OpA/``, +reference at least one KB URI, and do not register a natural key (so they +never enter the finalize/audit entity index). + +This module is self-contained: it depends only on ``wolfharness.wiki.*`` +(storage, models, quality, namespaces) and has no import on +``xeno_adp_agentic``. When ``xeno_adp_agentic`` is available, +``WikiBuildTools`` can subclass or compose this engine to provide the +full entity-write path (``merge_entity``, ``_apply_operations``, etc.). +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from hashlib import sha256 +import json +import logging +from pathlib import Path +import re +from typing import TYPE_CHECKING + +from wolfharness.capabilities.wiki._helpers import _entity_batch_limit +from wolfharness.capabilities.wiki.models import ( + OPA_CLOSURE_STATUSES, + OPA_REASON_CODES, + OPAModel, + OPLModel, + OPSModel, + infer_opa_reason_code, +) +from wolfharness.capabilities.wiki.quality import ( + BuildProfile, + IssueDisposition, + SourceReadResult, + SourceReadStatus, + audit_issue_policy, + classify_raw_source_uri, + extract_sections, + extract_source_uris, + is_optional_relation_issue, + parse_frontmatter, +) + + +if TYPE_CHECKING: + from wolfharness.capabilities.wiki.storage import WikiStore + + +logger = logging.getLogger(__name__) + +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + +_OPA_CATEGORY_DIRS: dict[str, str] = { + "gap": "内容缺失", + "conflict": "知识冲突", + "feedback": "专家反馈", +} + +_DEDUPE_KEY_MIN_LEN = 6 +_MAX_RETRIEVAL_LIMIT = 50 +_MAX_REPORT_LIMIT = 10000 +_MAX_DISCOVERY_LIMIT = 500 + +# Relation/link gap codes that should NOT generate OPAs. These are +# relationship completeness issues (dangling refs, missing links, isolated +# entities) that are best fixed via rebuild_index/backlinks, not via the +# manual OPA→OPS→OPL review pipeline. Content-missing codes +# (source_missing, controller_identity, etc.) stay in OPA. +_RELATION_GAP_CODES: frozenset[str] = frozenset({ + # Policy-level relation codes + "dangling_relation_target", + "dangling_wiki_reference", + "dangling_reference", + "Procedure.specification_ref_unresolvable", + "Profile.parent_symptom_not_indexed_by_device", + "Profile.direct_component_not_in_device_bom", + "relationship_completeness", + # RequirementCheck relation-field codes (field_present with target_concepts) + "SymptomProfile.device_refs", + "SymptomProfile.direct_component_uri", + "SymptomProfile.possible_faults", + "Device.critical_components", + "Device.symptom_refs", + "DTC.related_faults", + "Fault.affected_components", + "Fault.procedures", + "Procedure.target_components", +}) + +# Any code ending in ".body_link" is a section-link relation check. +_RELATION_GAP_SUFFIX = ".body_link" + + +def _is_relation_gap_code(code: str) -> bool: + """True when an audit issue code is a relation/link gap, not a content gap.""" + return code in _RELATION_GAP_CODES or code.endswith(_RELATION_GAP_SUFFIX) + + +_SECTION_ANNOTATION_RE = re.compile(r"(?:section|章节)\s*[::]\s*([^\)\]\n,,]+)", re.IGNORECASE) + +# Audit rules use stable machine field names while entity pages use Chinese +# section headings. Keep this mapping in one place so discovery, audit +# downgrading, and finalize all agree on what it means for an OPA to be +# attached to a page section. +_SECTION_ALIASES: dict[str, tuple[str, ...]] = { + "affected_components": ("影响范围",), + "verification_procedures": ("验证方法",), + "repair_procedures": ("修复方式",), + "related_faults": ("可能失效机理",), + "possible_faults": ("可能失效机理",), + "assembly_parts": ("总成概览",), + "target_components": ("操作目的",), + "critical_components": ("关重件清单",), +} + + +def opa_section_names(target_section: str, annotations: str = "") -> set[str]: + """Return machine and human names that can identify one page section.""" + names: set[str] = set() + value = target_section.strip() + if value: + names.add(value) + names.add(value.rsplit(".", 1)[-1]) + names.update(part.strip() for part in value.replace("|", "/").split("/") if part.strip()) + names.update(_SECTION_ALIASES.get(value.rsplit(".", 1)[-1], ())) + names.update( + match.strip() for match in _SECTION_ANNOTATION_RE.findall(annotations) if match.strip() + ) + return names + + +class TicketEngine: + """Standalone OPA/OPS/OPL ticket engine. + + Provides the full CRUD lifecycle for OPA/OPS/OPL records backed by + a :class:`~wolfharness.wiki.storage.WikiStore`. When + ``xeno_adp_agentic`` is installed, ``WikiBuildTools`` can subclass + or compose this engine and override the cross-mixin stubs below + with full entity-write implementations. + """ + + def __init__(self, store: WikiStore) -> None: + """Initialise the engine with a wiki store. + + Args: + store: Wiki store used for all OPA/OPS/OPL persistence. + """ + self.store = store + # ponytail: _raw_fs mirrors the WikiBuildTools mixin contract; + # use store._fs so raw-source URI prefix stripping works. + self._raw_fs = store._fs + + # ------------------------------------------------------------------ + # Cross-mixin stubs — overridden by WikiBuildTools when available. + # ------------------------------------------------------------------ + + def read_resource(self, uri: str, line_numbers: bool = False) -> str: + """Read a wiki entity page by URI. + + Strips the store root_uri prefix and delegates to ``store.read_text``. + """ + key = uri + root = self.store.root_uri + if key.startswith(root + "/"): + key = key[len(root) + 1 :] + text = self.store.read_text(key) + return text if text is not None else "" + + def read_raw_source(self, uri: str) -> SourceReadResult: + """Read a raw source chapter. + + Stub: returns NOT_FOUND. WikiBuildTools overrides with full + raw-reader access. + """ + return SourceReadResult(uri=uri, kind=None, status=SourceReadStatus.NOT_FOUND) + + def find_wiki( + self, + query: str, + *, + target_uri: str = "", + limit: int = 10, + deep: bool = False, + ) -> list[dict[str, object]]: + """Semantic search over wiki entities. + + Delegates to ``store._fs.find()`` when available (VikingFS), + otherwise returns an empty list. + """ + fs = self.store._fs + finder = getattr(fs, "find", None) + if finder is not None: + result = finder(query, target_uri=target_uri, limit=limit, deep=deep) + return list(result) if isinstance(result, list) else [] + return [] + + def _apply_operations(self, content: str, operations: list[dict[str, object]]) -> str: + """Apply patch operations to entity content. + + Stub: returns content unchanged. The OPL ``candidate_content`` + field is the primary mechanism — operations are optional. + """ + return content + + def merge_entity( + self, + concept: str, + class_name: str, + object_name: str, + content: str, + *, + expected_sha256: str = "", + **kwargs: object, + ) -> str: + """Write entity content and return its URI. + + Stub: writes directly via ``store.write_text_durable``. + WikiBuildTools overrides with validation + hooks + sha256 lock. + """ + key = f"{concept}/{class_name}/{object_name}.md" + self.store.write_text_durable(key, content) + return f"{self.store.root_uri}/{key}" + + def audit_wiki(self, **kwargs: object) -> dict[str, object]: + """Run wiki audit and return issues. + + Stub: returns no issues. WikiBuildTools overrides with full + audit engine. + """ + return {"issues": [], "next_offset": -1, "passed": True} + + def _opa_dir(self) -> Path: + """Return the ``OP/OpA`` directory, creating it if absent.""" + path = self.store.root / self.store.CONCEPT_DIRS["OPA"] + self.store.mkdir_p(self.store.CONCEPT_DIRS["OPA"]) + return path + + def _opa_dir_key(self) -> str: + """Return the backend-relative OPA directory key.""" + key = self.store.CONCEPT_DIRS["OPA"] + self.store.mkdir_p(key) + return key + + def _target_hash(self, target_uri: str) -> str: + """Return a stable 16-char directory segment for a target URI.""" + canonical = self.store.resolve_redirect(target_uri.strip()) if target_uri.strip() else "" + return sha256(canonical.encode("utf-8")).hexdigest()[:16] if canonical else "" + + def _opa_key(self, opa_id: str, category: str = "", target_uri: str = "") -> str: + """Return the backend-relative OPA key, optionally under a category and target subdir.""" + base = self._opa_dir_key() + subdir = _OPA_CATEGORY_DIRS.get(category.strip().lower(), "") + th = self._target_hash(target_uri) + parts = [base] + if subdir: + parts.append(subdir) + if th: + parts.append(th) + parts.append(f"{opa_id}.md") + return "/".join(parts) + + def _find_opa_key(self, opa_id: str) -> str | None: + """Locate an OPA by id, searching flat, category subdirs, and target-hash subdirs.""" + base = self._opa_dir_key() + flat = f"{base}/{opa_id}.md" + if self.store.read_text(flat) is not None: + return flat + for subdir in _OPA_CATEGORY_DIRS.values(): + key = f"{base}/{subdir}/{opa_id}.md" + if self.store.read_text(key) is not None: + return key + for key in self.store.list_dir(base, recursive=True): + if key.endswith(f"/{opa_id}.md") and self.store.read_text(key) is not None: + return key + return None + + def _op_dir_key(self, concept: str) -> str: + """Return and create the storage key for one OP record type.""" + key = self.store.CONCEPT_DIRS[concept] + self.store.mkdir_p(key) + return key + + def _op_key(self, concept: str, record_id: str, target_uri: str = "") -> str: + base = self._op_dir_key(concept) + th = self._target_hash(target_uri) + if th: + return f"{base}/{th}/{record_id}.md" + return f"{base}/{record_id}.md" + + def _find_op_key(self, concept: str, record_id: str) -> str | None: + """Locate an OP record by id, trying flat path then target-hash subdirs.""" + base = self._op_dir_key(concept) + flat = f"{base}/{record_id}.md" + if self.store.read_text(flat) is not None: + return flat + for key in self.store.list_dir(base, recursive=True): + if key.endswith(f"/{record_id}.md") and self.store.read_text(key) is not None: + return key + return None + + def _op_files(self, concept: str, *, target_uri: str = "") -> list[tuple[str, str]]: + th = self._target_hash(target_uri) + if th: + subdir = f"{self._op_dir_key(concept)}/{th}" + th_records = self._read_md_dir(subdir) + if th_records: + return sorted(th_records) + records: list[tuple[str, str]] = [] + for key in self.store.list_dir(self._op_dir_key(concept), recursive=True): + if not key.endswith(".md"): + continue + content = self.store.read_text(key) + if content is not None: + records.append((key, content)) + return sorted(records) + + def _read_md_dir(self, dir_key: str) -> list[tuple[str, str]]: + records: list[tuple[str, str]] = [] + for key in self.store.list_dir(dir_key, recursive=False): + if not key.endswith(".md"): + continue + content = self.store.read_text(key) + if content is not None: + records.append((key, content)) + return records + + def _opa_files(self, *, target_uri: str = "") -> list[tuple[str, str]]: + """Read OPA Markdown files through local or Viking storage. + + When ``target_uri`` is given, only the matching target-hash + subdir is read (O(1) directory listing); otherwise a full + recursive scan is performed. + """ + th = self._target_hash(target_uri) + base = self._opa_dir_key() + if th: + records: list[tuple[str, str]] = [] + for subdir in _OPA_CATEGORY_DIRS.values(): + records.extend(self._read_md_dir(f"{base}/{subdir}/{th}")) + return sorted(records) + records: list[tuple[str, str]] = [] + for key in self.store.list_dir(base, recursive=True): + if not key.endswith(".md"): + continue + content = self.store.read_text(key) + if content is not None: + records.append((key, content)) + return sorted(records) + + def _opa_uri(self, opa_id: str, category: str = "") -> str: + """Return a URI that can actually be read from the active backend.""" + key = self._find_opa_key(opa_id) + if key is None: + key = self._opa_key(opa_id, category) + return f"{self.store.root_uri}/{key}" + + def _op_uri(self, concept: str, record_id: str) -> str: + if concept == "OPA": + return self._opa_uri(record_id) + key = self._find_op_key(concept, record_id) + if key is not None: + return f"{self.store.root_uri}/{key}" + return f"{self.store.root_uri}/{self.store.CONCEPT_DIRS[concept]}/{record_id}.md" + + def is_valid_op_uri(self, uri: str) -> bool: + """Return whether *uri* is an acceptable OPA/OPS reference URI. + + Mirrors the format rules enforced by ``_validate_opa_uris``: a + ``viking://resources/...`` provider reference (any namespace), a URI + inside the active wiki store's namespace, or a raw-source library + URI all pass. Existence is not checked -- the record may be filed + before the entity is merged, and reference scopes may live outside + the write store's namespace. + """ + return ( + uri.startswith("viking://resources/") + or self.store.is_wiki_uri(uri) + or classify_raw_source_uri(uri, raw_root_uri=self._raw_fs.root_uri) is not None + ) + + def _validate_opa_uris(self, target_uri: str, evidence_uris: list[str]) -> None: + """Require and validate the URI association of an OPA. + + Every OPA must reference at least one KB URI (the wiki entity under + conflict or the source corpus that surfaced the conflict). Formats + are checked; existence is not (the record may be filed before the + entity is merged). + + Accepts provider resource references outside the active write store. + OPA/OPS records are written to the provider-owned ticket scope, while + ``target_uri`` and ``evidence_uris`` are read/citation references and + may point at a different provider-owned resource scope. + """ + uris = [uri for uri in (target_uri, *evidence_uris) if uri] + if not uris: + raise ValueError( + f"OPA must reference at least one URI: set target_uri " + f"({self.store.root_uri}/...) or evidence_uris " + f"(viking://resources/... source chapters).", + ) + for uri in uris: + if not self.is_valid_op_uri(uri): + raise ValueError( + f"OPA URI must be a provider resource URI or local wiki/raw URI, got {uri!r}.", + ) + + @staticmethod + def _list_value(value: object) -> list[str]: + """Normalize a YAML list/scalar field into unique strings.""" + if isinstance(value, list): + return list( + dict[str, object].fromkeys(str(item).strip() for item in value if str(item).strip()) + ) + if isinstance(value, str) and value.strip(): + return [value.strip()] + return [] + + @staticmethod + def _list_of_dicts(value: object) -> list[dict[str, object]]: + """Read structured patch operations without accepting arbitrary YAML.""" + if not isinstance(value, list): + return [] + return [dict[str, object](item) for item in value if isinstance(item, dict)] + + @staticmethod + def _normalize_dedupe_text(value: object) -> str: + """Normalize issue text so title/source wording does not create duplicates.""" + return re.sub(r"\s+", " ", str(value or "").strip()).casefold() + + def _opa_dedupe_key( + self, + *, + category: str, + reason_code: str, + target_uri: str, + target_section: str, + finding: str, + missing: str, + ) -> str: + """Return an evidence-independent identity for one logical OPA issue. + + A target section is the unit of work for audit and repair. Multiple + workers may describe that same section differently, so descriptions + and evidence are deliberately not part of the primary key. When no + concrete target/section exists, the normalized finding remains the + fallback discriminator. + """ + target = self.store.resolve_redirect(target_uri.strip()) if target_uri.strip() else "" + identity = [ + self._normalize_dedupe_text(category), + self._normalize_dedupe_text(reason_code), + target, + self._normalize_dedupe_text(target_section), + ] + if not target or not target_section.strip(): + identity.extend(( + self._normalize_dedupe_text(finding), + self._normalize_dedupe_text(missing), + )) + raw = "\x1f".join(identity) + return "opa-dedupe-" + sha256(raw.encode("utf-8")).hexdigest()[:24] + + def _canonical_uri_identity(self, value: str) -> str: + """Return a redirect-stable identity while preserving URI fragments.""" + base, separator, fragment = value.partition("#") + canonical = self.store.resolve_redirect(base) if self.store.is_wiki_uri(base) else base + return canonical + (separator + fragment if separator else "") + + def _dedupe_uri_values( + self, values: list[str], *, exclude: set[str] | None = None + ) -> list[str]: + excluded = { + self._canonical_uri_identity(value.strip()) + for value in (exclude or set()) + if value.strip() + } + seen: set[str] = set() + result: list[str] = [] + for raw_value in values: + value = raw_value.strip() + if not value: + continue + identity = self._canonical_uri_identity(value) + if identity in excluded or identity in seen: + continue + seen.add(identity) + result.append(value) + return result + + @staticmethod + def _merge_observations(previous: object, incoming: str) -> str: + """Keep distinct observations while avoiding repeated generated prose.""" + separator = "\n\n--- additional observation ---\n\n" + values = [part.strip() for part in str(previous or "").split(separator)] + values.append(incoming.strip()) + seen: set[str] = set() + unique: list[str] = [] + for value in values: + if not value: + continue + identity = re.sub(r"[\s,。;;,.!?!?::]+", "", value).casefold() + if identity in seen: + continue + seen.add(identity) + unique.append(value) + values = unique + return "\n\n--- additional observation ---\n\n".join(values) + + def _find_opa_by_dedupe_key( + self, + dedupe_key: str, + *, + build_id: str = "", + ) -> tuple[str, dict[str, object]] | None: + """Find an existing OPA by its logical issue identity.""" + if not dedupe_key: + return None + for key, content in self._opa_files(): + frontmatter = self._record_from_content(content) + if str(frontmatter.get("status", "")).strip().lower() in {"superseded", "rejected"}: + continue + if build_id and not self._opa_matches_build(key, frontmatter, build_id): + continue + if str(frontmatter.get("dedupe_key", "")).strip() == dedupe_key: + return key, frontmatter + # Legacy records did not persist a dedupe key. Compute one from + # their stable fields so the first new report consolidates them. + category = str(frontmatter.get("category", "")) + reason_code = str(frontmatter.get("reason_code", infer_opa_reason_code(category))) + computed = self._opa_dedupe_key( + category=category, + reason_code=reason_code, + target_uri=str(frontmatter.get("target_uri", "")), + target_section=str(frontmatter.get("target_section", "")), + finding=str(frontmatter.get("finding", "")), + missing=str(frontmatter.get("missing", "")), + ) + if computed == dedupe_key: + return key, frontmatter + return None + + def _opa_matches_build(self, key: str, frontmatter: dict[str, object], build_id: str) -> bool: + """Match a record to a build without reviving unscoped old records.""" + record_build_id = str(frontmatter.get("build_id", "")).strip() + if record_build_id: + return record_build_id == build_id + # Records without build_id were created before the checkpoint was + # initialised (common early in a build). Accept them as part of the + # current build rather than silently dropping current-build OPAs — + # the checkpoint build_id match is sufficient scoping. + checkpoint = self.store.read_json("index/build_checkpoint.json") + return ( + isinstance(checkpoint, dict) and str(checkpoint.get("build_id", "")).strip() == build_id + ) + + def _opa_category_from_key(self, key: str) -> str: + for category, subdir in _OPA_CATEGORY_DIRS.items(): + if f"/{subdir}/" in f"/{key}": + return category + return "" + + @staticmethod + def _human_key(title: str, category: str, target_section: str) -> str: + """Create a compact readable display key for an OPA.""" + value = "__".join( + part.strip() for part in (category, target_section, title) if part.strip() + ) + value = re.sub(r"\s+", "-", value) + value = re.sub(r"[\\/:*?\"<>|]+", "-", value) + return value[:160].strip("-_") or "wiki-issue" + + @staticmethod + def _clip_utf8(value: str, max_bytes: int) -> str: + """Clip a string so its UTF-8 encoding fits in ``max_bytes``. + + Filenames are byte-length-limited (255 on disk, and Viking's unzip + rejects longer paths), but the earlier character-based truncation + let CJK ids reach ~570 bytes. Never split a multibyte codepoint. + """ + encoded = value.encode("utf-8") + if len(encoded) <= max_bytes: + return value + return encoded[:max_bytes].decode("utf-8", "ignore") + + @staticmethod + def _readable_slug(value: str, *, fallback: str) -> str: + """Keep identity names searchable without using opaque hashes.""" + slug = re.sub(r"\s+", "-", value.strip()) + slug = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff._-]+", "-", slug) + slug = re.sub(r"-+", "-", slug).strip("-._") + return TicketEngine._clip_utf8(slug, 60).rstrip("-._") or fallback + + @staticmethod + def _record_from_content(content: str) -> dict[str, object]: + """Parse an OPA record, recovering prose fields from the markdown body. + + Returns the frontmatter merged with: + - ``title`` from the first H1 heading when the frontmatter lacks it + - ``description`` / ``finding`` / ``missing`` / ``recommendation`` + from the body sections ``问题描述`` / ``冲突点`` / ``缺失点`` / ``建议`` + when the frontmatter lacks them (legacy YAML wins). + """ + record = dict(parse_frontmatter(content or "")) + if not str(record.get("title", "")).strip(): + match = re.search(r"^#\s+(.+)$", content or "", re.MULTILINE) + if match is not None: + record["title"] = match.group(1).strip() + sections = extract_sections(content or "") + for field, heading in ( + ("description", "问题描述"), + ("finding", "冲突点"), + ("missing", "缺失点"), + ("recommendation", "建议"), + ): + if not str(record.get(field, "")).strip() and heading in sections: + record[field] = sections[heading] + return record + + def _readable_opa_id( + self, + *, + category: str, + reason_code: str, + target_uri: str, + target_path: str, + target_section: str, + title: str, + dedupe_key: str = "", + ) -> str: + """Build a short, human-readable, dedupe-stable OPA filename. + + The readable target and title slugs keep the filename searchable, + while the short dedupe suffix keeps it stable per logical issue + without packing the whole record into the name. + """ + target = target_uri.rstrip("/").rsplit("/", 1)[-1] if target_uri else (target_path or title) + target_slug = self._readable_slug(target, fallback="target")[:20].rstrip("-._") or "target" + title_slug = self._readable_slug(title, fallback="title")[:20].rstrip("-._") or "title" + base = dedupe_key.removeprefix("opa-dedupe-") if dedupe_key else "" + suffix = base[:8] if len(base) >= _DEDUPE_KEY_MIN_LEN else "" + if not suffix: + source = f"{category}\x1f{reason_code}\x1f{target_uri}\x1f{target_section}\x1f{title}" + suffix = sha256(source.encode("utf-8")).hexdigest()[:8] + kind = self._readable_slug(category, fallback="issue")[:12].rstrip("-._") or "issue" + return self._clip_utf8(f"opa-{kind}-{target_slug}-{title_slug}-{suffix}", 80).rstrip("-._") + + @staticmethod + def _opa_open_for_gate(record: dict[str, object]) -> bool: + """Whether an OPA still blocks finalize, keyed on closure_status not status.""" + closure = str(record.get("closure_status", "")).strip().lower() + status = str(record.get("status", "")).strip().lower() + if closure == "closed": + return False + if closure == "deferred": + return not str(record.get("closure_reason", "")).strip() + if closure == "open": + return True + # Legacy record without closure_status: preserve prior gate semantics + # so already-finalized builds are not retroactively blocked. + return status == "pending" + + def _unresolved_opa_records(self, *, build_id: str | None = None) -> list[dict[str, object]]: + """Return active OPA records for the selected build scope.""" + records: list[dict[str, object]] = [] + for key, content in self._opa_files(): + frontmatter = self._record_from_content(content) + opa_id = Path(key).stem + if str(frontmatter.get("status", "")).lower() not in { + "resolved", + "rejected", + "superseded", + }: + record_build_id = str(frontmatter.get("build_id", "")).strip() + if build_id is not None and not self._opa_matches_build(key, frontmatter, build_id): + continue + records.append( + { + "opa_id": str(frontmatter.get("id", opa_id)), + "human_key": str( + frontmatter.get("human_key", frontmatter.get("title", opa_id)) + ), + "uri": self._opa_uri( + str(frontmatter.get("id", opa_id)), self._opa_category_from_key(key) + ), + "status": str(frontmatter.get("status", "")), + "category": str(frontmatter.get("category", "")), + "reason_code": str( + frontmatter.get( + "reason_code", + infer_opa_reason_code(str(frontmatter.get("category", ""))), + ), + ), + "target_uri": str(frontmatter.get("target_uri", "")), + "target_section": str(frontmatter.get("target_section", "")), + "build_id": record_build_id, + }, + ) + return records + + def _is_explicit_tracked_record(self, record: dict[str, object]) -> bool: + """Return whether a pending OPA is bound to a marked page section.""" + target_uri = str(record.get("target_uri", "")).strip() + target_section = str(record.get("target_section", "")).strip() + if not target_uri or not target_section: + return False + content = self.read_resource(target_uri) + if content is None: + return False + sections = extract_sections(content) + # discover_opa keeps a stable machine code in target_section (for + # example ``Component.fault.body_link``), while the page uses a + # human-readable heading. Accept the section annotation emitted in + # the OPA description/finding so an explicit open_gap can downgrade + # the corresponding required relation to a tracked warning. + annotations = " ".join( + str(record.get(field, "")) + for field in ("description", "finding", "missing", "recommendation") + ) + section_names = opa_section_names(target_section, annotations) + matching_sections = [ + value + for heading, value in sections.items() + if any(name == heading or name in heading for name in section_names) + ] + category = str(record.get("category", "")).strip().lower() + markers = ( + ("conflict_pending", "冲突") + if category == "conflict" + else ("open_gap", "待补充", "来源未说明", "来源缺失", "未物化") + ) + if matching_sections: + return any( + any(marker in value.lower() for marker in markers) for value in matching_sections + ) + frontmatter = parse_frontmatter(content) + return target_section.rsplit(".", 1)[-1] in frontmatter and any( + marker in content.lower() for marker in markers + ) + + def _is_explicit_gap_record(self, record: dict[str, object]) -> bool: + """Backward-compatible alias for source-honest gap callers.""" + return str( + record.get("category", "") + ).strip().lower() == "gap" and self._is_explicit_tracked_record(record) + + def create_opa( + self, + *, + title: str, + description: str, + category: str = "conflict", + reason_code: str = "", + scope: str = "entity", + subtype: str = "wiki_error", + target_uri: str = "", + target_path: str = "", + target_section: str = "", + source_chapter: str = "", + evidence_uris: list[str] | None = None, + status: str = "pending", + solution: str = "", + opa_id: str = "", + finding: str = "", + missing: str = "", + recommendation: str = "", + related_uris: list[str] | None = None, + dedupe_key: str = "", + build_id: str = "", + closure_status: str = "", + closure_reason: str = "", + skip_dedupe_lookup: bool = False, + ) -> dict[str, object]: + """结构化落盘一条 OPA 冲突/问题记录,替代手写 md。. + + 存入 ``OP/OpA/<opa_id>.md``(YAML frontmatter + 证据 + 可解析 URI), + 不注册 natural key,因此不会进入 finalize/audit 的实体索引。 + + ``opa_id`` 未提供时自动生成(同 target_uri + title 稳定复用)。 + 返回 ``{opa_id, uri, path, title, target_uri}``。 + + ``skip_dedupe_lookup`` 为 True 时跳过全目录去重检索——外部专家 + 路径的 target_uri 是确定性输入,不需要扫全库找重复 OPA。 + """ + effective_category = category.strip().lower() or "conflict" + effective_reason_code = reason_code or infer_opa_reason_code(effective_category) + effective_target = target_uri.strip() + if not build_id: + checkpoint = self.store.read_json("index/build_checkpoint.json") + if isinstance(checkpoint, dict): + build_id = str(checkpoint.get("build_id", "")).strip() + effective_section = target_section.strip() + effective_finding = finding.strip() + effective_missing = missing.strip() + supplied_dedupe_key = dedupe_key.strip() + dedupe_key = supplied_dedupe_key or self._opa_dedupe_key( + category=effective_category, + reason_code=effective_reason_code, + target_uri=effective_target, + target_section=effective_section, + finding=effective_finding, + missing=effective_missing, + ) + existing_by_key: tuple[str, dict[str, object]] | None = None + if not skip_dedupe_lookup: + existing_by_key = self._find_opa_by_dedupe_key(dedupe_key, build_id=build_id) + if existing_by_key is not None: + # Dedupe is authoritative even when a caller supplies a readable + # or legacy hash-like opa_id. Otherwise repeated merge/conflict + # paths create parallel OPA records for one target section. + opa_id = Path(existing_by_key[0]).stem + elif not opa_id: + opa_id = self._readable_opa_id( + category=effective_category, + reason_code=effective_reason_code, + target_uri=effective_target, + target_path=target_path, + target_section=effective_section, + title=title, + dedupe_key=dedupe_key, + ) + previous: dict[str, object] = {} + existing_key = self._find_opa_key(opa_id) + if existing_key and build_id: + existing_content = self.store.read_text(existing_key) + existing_frontmatter = self._record_from_content(existing_content or "") + existing_build_id = str(existing_frontmatter.get("build_id", "")).strip() + if existing_build_id != build_id: + # Readable OPA IDs are intentionally stable within a build. + # Add a build suffix on a cross-build collision so a new run + # never mutates an old run's record. + build_suffix = sha256(build_id.encode("utf-8")).hexdigest()[:10] + opa_id = f"{opa_id}-b{build_suffix}" + existing_key = self._find_opa_key(opa_id) + existing = self.store.read_text(existing_key) if existing_key else None + if existing is not None: + previous = self._record_from_content(existing) + dedupe_key = str(previous.get("dedupe_key", "")).strip() or dedupe_key + allowed_statuses = {"pending", "resolved", "rejected", "superseded"} + if status not in allowed_statuses: + raise ValueError(f"Unsupported OPA status: {status!r}") + if not category.strip(): + raise ValueError("OPA category must not be empty") + previous_evidence = self._list_value(previous.get("evidence_uris")) + previous_related = self._list_value(previous.get("related_uris")) + effective_target = effective_target or str(previous.get("target_uri", "")) + evidence = self._dedupe_uri_values([*previous_evidence, *list(evidence_uris or [])]) + if effective_target: + evidence = [uri for uri in evidence if uri != effective_target] + related = self._dedupe_uri_values( + [*previous_related, *list(related_uris or [])], + exclude=set(evidence) | ({effective_target} if effective_target else set()), + ) + self._validate_opa_uris(effective_target, [*evidence, *related]) + effective_category = effective_category or str(previous.get("category", "conflict")) + key = existing_key or self._opa_key(opa_id, effective_category, target_uri=effective_target) + effective_reason_code = ( + reason_code + or str(previous.get("reason_code", "")) + or infer_opa_reason_code(effective_category) + ) + if effective_reason_code not in OPA_REASON_CODES: + raise ValueError( + f"Unsupported OPA reason_code: {effective_reason_code!r}; " + f"expected one of {', '.join(OPA_REASON_CODES)}", + ) + effective_title = title or str(previous.get("title", opa_id)) + effective_description = self._merge_observations( + previous.get("description", ""), description + ) + human_key = str(previous.get("human_key", "")).strip() or self._human_key( + effective_title, + effective_category, + target_section or str(previous.get("target_section", "")), + ) + effective_section = effective_section or str(previous.get("target_section", "")) + effective_finding = self._merge_observations(previous.get("finding", ""), effective_finding) + effective_missing = self._merge_observations(previous.get("missing", ""), effective_missing) + effective_recommendation = self._merge_observations( + previous.get("recommendation", ""), recommendation + ) + if status == "pending": + required_fields = { + "description": effective_description, + "finding": effective_finding, + "missing": effective_missing, + "recommendation": effective_recommendation, + # A readable target is already the OPA binding. Do not copy it + # into evidence_uris just to satisfy the non-empty check. + "evidence_uris": "\n".join(evidence) or effective_target, + } + missing_fields = [name for name, value in required_fields.items() if not value.strip()] + if missing_fields: + raise ValueError( + "pending OPA requires structured fields: " + ", ".join(missing_fields), + ) + count_value: object = previous.get("report_count") + report_count: int = (count_value if isinstance(count_value, int) else 0) + 1 + model = OPAModel( + opa_id=opa_id, + title=effective_title, + description=effective_description, + human_key=human_key, + category=effective_category, + reason_code=effective_reason_code, + scope=scope or str(previous.get("scope", "entity")), + subtype=subtype or str(previous.get("subtype", "wiki_error")), + target_uri=effective_target, + target_path=target_path or str(previous.get("target_path", "")), + target_section=target_section or str(previous.get("target_section", "")), + source_chapter=source_chapter or str(previous.get("source_chapter", "")), + evidence_uris=evidence, + status=status, + solution=solution or str(previous.get("solution", "")), + finding=effective_finding, + missing=effective_missing, + recommendation=effective_recommendation, + related_uris=related, + report_count=report_count, + dedupe_key=dedupe_key or str(previous.get("dedupe_key", "")), + build_id=build_id or str(previous.get("build_id", "")), + closure_status=closure_status or str(previous.get("closure_status", "open")) or "open", + closure_reason=closure_reason or str(previous.get("closure_reason", "")), + ) + self.store.write_text_durable( + key, + model.to_markdown(ops_uri=self._op_uri("OPS", model.opa_id.replace("opa-", "ops-", 1))), + ) + logger.info("OPA created: %s (target=%s)", opa_id, target_uri or target_path or "-") + return { + "opa_id": opa_id, + "uri": self._opa_uri(opa_id, effective_category), + "path": key, + "title": effective_title, + "human_key": human_key, + "target_uri": effective_target, + "reason_code": model.reason_code, + "status": status, + "closure_status": model.closure_status, + "report_count": model.report_count, + "dedupe_key": model.dedupe_key, + "build_id": model.build_id, + } + + @staticmethod + def _record_id(value: str, prefix: str) -> str: + """Accept either a record id or a backend URI and return its id.""" + token = value.rstrip("/").rsplit("/", 1)[-1].removesuffix(".md") + return token if token.startswith(prefix + "-") else value + + def _opa_record(self, value: str) -> tuple[str, dict[str, object], str]: + opa_id = self._record_id(value, "opa") + key = self._find_opa_key(opa_id) + if key is None: + raise FileNotFoundError(f"OPA not found: {value}") + content = self.store.read_text(key) + if content is None: + raise FileNotFoundError(f"OPA not found: {value}") + category = "" + for cat, subdir in _OPA_CATEGORY_DIRS.items(): + if f"/{subdir}/" in key: + category = cat + break + return opa_id, parse_frontmatter(content), self._opa_uri(opa_id, category) + + def _validate_op_evidence(self, evidence_uris: list[str], related_uris: list[str]) -> None: + self._validate_opa_uris("", [*evidence_uris, *related_uris]) + unreadable = [ + uri + for uri in dict.fromkeys([*evidence_uris, *related_uris]) + if uri + and not uri.startswith("viking://resources/") + and self.read_resource(uri) is None + and classify_raw_source_uri(uri, raw_root_uri=self._raw_fs.root_uri) is None + ] + if unreadable: + raise ValueError(f"OP records require readable evidence/related URIs: {unreadable}") + + def _op_reference_quality( + self, + *, + target_uri: str, + evidence_uris: list[str], + related_uris: list[str], + ) -> list[str]: + """Return hard quality violations for a generated OP reference set.""" + issues: list[str] = [] + target = target_uri.strip() + evidence = [str(uri).strip() for uri in evidence_uris if str(uri).strip()] + related = [str(uri).strip() for uri in related_uris if str(uri).strip()] + + def canonical(uri: str) -> str: + return self.store.resolve_redirect(uri) if self.store.is_wiki_uri(uri) else uri + + evidence_keys = [canonical(uri) for uri in evidence] + related_keys = [canonical(uri) for uri in related] + target_key = canonical(target) if target else "" + if len(evidence_keys) != len(set(evidence_keys)) or len(related_keys) != len( + set(related_keys) + ): + issues.append("REFERENCE_DUPLICATE") + if target_key and (target_key in evidence_keys or target_key in related_keys): + issues.append("REFERENCE_TARGET_REPEATED") + if set(evidence_keys) & set(related_keys): + issues.append("REFERENCE_EVIDENCE_REPEATED_IN_RELATED") + try: + self._validate_opa_uris(target, [*evidence, *related]) + except ValueError: + issues.append("REFERENCE_URI_INVALID") + unreadable: list[str] = [] + for uri in dict[str, object].fromkeys([target, *evidence, *related]): + if not uri: + continue + if self.store.is_wiki_uri(uri): + readable = self.read_resource(uri) is not None + else: + raw_result = self.read_raw_source(uri) + readable = raw_result.status is SourceReadStatus.OK + if not readable: + unreadable.append(uri) + if unreadable: + issues.append("REFERENCE_UNREADABLE") + return list(dict[str, object].fromkeys(issues)) + + def _require_parent_target(self, *, parent_target: str, requested_target: str) -> str: + """Keep all records in an OP chain attached to the same target.""" + expected = parent_target.strip() + requested = requested_target.strip() + if requested and ( + not expected + or self.store.resolve_redirect(requested) != self.store.resolve_redirect(expected) + ): + raise ValueError("target_uri must match parent OPA target_uri") + return expected + + def _ops_retrieval_receipt( + self, + query: str, + *, + limit: int, + ) -> tuple[list[str], list[str]]: + """Run bounded retrieval over configured Wiki and raw roots.""" + normalized_query = query.strip() + if not normalized_query: + raise ValueError("OPS requires a non-empty retrieval_query") + if limit < 1 or limit > _MAX_RETRIEVAL_LIMIT: + raise ValueError("OPS retrieval_limit must be between 1 and 50") + scopes = [self.store.root_uri, self._raw_fs.root_uri] + hit_uris: list[str] = [] + for scope in scopes: + hits = self.find_wiki( + normalized_query, + target_uri=scope, + limit=limit, + deep=True, + ) + hit_uris.extend( + str(hit.get("uri", "")).strip() for hit in hits if str(hit.get("uri", "")).strip() + ) + return scopes, list(dict[str, object].fromkeys(hit_uris)) + + def _retrieval_scopes_match(self, scopes: list[str]) -> bool: + """Accept the current raw root and the historical ``<root>/raw`` alias.""" + scope_set = set(scopes) + raw_root = self._raw_fs.root_uri.rstrip("/") + raw_aliases = {raw_root, raw_root + "/raw"} + return self.store.root_uri in scope_set and bool(raw_aliases & scope_set) + + def create_ops( + self, + *, + parent_opa: str, + title: str, + retrieval_query: str, + retrieved_uris: list[str], + analysis: str, + solution: str, + evidence_uris: list[str] | None = None, + related_uris: list[str] | None = None, + target_uri: str = "", + status: str = "unconfirmed", + ops_id: str = "", + retrieval_limit: int = 10, + candidate_content: str = "", + candidate_operations: list[dict[str, object]] | None = None, + expected_sha256: str = "", + source_type: str = "pipeline", + ) -> dict[str, object]: + """Persist one source-backed expert recommendation for an OPA.""" + opa_id, opa_fm, opa_uri = self._opa_record(parent_opa) + if status != "unconfirmed": + raise ValueError("New OPS records must remain status=unconfirmed") + candidate_operations = list(candidate_operations or []) + if candidate_content.strip() and candidate_operations: + raise ValueError("OPS must provide candidate_content or candidate_operations, not both") + if any(not isinstance(operation, dict) for operation in candidate_operations): + raise ValueError("OPS candidate_operations must contain only objects") + if expected_sha256 and _SHA256_RE.fullmatch(expected_sha256) is None: + raise ValueError("OPS expected_sha256 must be a 64-character lowercase SHA-256 hash") + effective_target = self._require_parent_target( + parent_target=str(opa_fm.get("target_uri", "")), + requested_target=target_uri, + ) + evidence = self._dedupe_uri_values( + [*self._list_value(opa_fm.get("evidence_uris")), *(evidence_uris or [])], + exclude={effective_target} if effective_target else set(), + ) + related = self._dedupe_uri_values( + [*self._list_value(opa_fm.get("related_uris")), *(related_uris or [])], + exclude=set(evidence) | ({effective_target} if effective_target else set()), + ) + self._validate_op_evidence(evidence, [effective_target, *related]) + target_is_readable = bool( + effective_target and self.read_resource(effective_target) is not None + ) + if ( + not analysis.strip() + or not solution.strip() + or (not evidence and not target_is_readable) + ): + raise ValueError( + "OPS requires detailed analysis, solution, and at least one evidence URI" + ) + retrieval_scopes, retrieval_hit_uris = self._ops_retrieval_receipt( + retrieval_query, + limit=retrieval_limit, + ) + used_uris = list( + dict[str, object].fromkeys(uri.strip() for uri in retrieved_uris if uri.strip()) + ) + missing_hits = [uri for uri in used_uris if uri not in retrieval_hit_uris] + if missing_hits: + raise ValueError( + f"OPS retrieved_uris were not returned by the server-side " + f"retrieval receipt: {missing_hits}", + ) + # The formal target is implicitly bound to the OPS; it need not be + # repeated in related_uris just to satisfy the retrieval receipt. + unbound_hits = [ + uri for uri in used_uris if uri not in {*evidence, *related, effective_target} + ] + if unbound_hits: + raise ValueError( + f"OPS retrieved_uris must also appear in evidence_uris or " + f"related_uris: {unbound_hits}", + ) + if not ops_id: + normalized_query = self._normalize_dedupe_text(retrieval_query) + for existing_key, existing_content in self._op_files("OPS"): + existing_fm = self._record_from_content(existing_content) + existing_parent = str(existing_fm.get("parent_opa", "")) + existing_target = str(existing_fm.get("target_uri", "")) + if ( + str(existing_fm.get("status", "unconfirmed")).lower() == "unconfirmed" + and existing_parent in {opa_uri, opa_id} + and self.store.resolve_redirect(existing_target) + == self.store.resolve_redirect(effective_target) + and self._normalize_dedupe_text(existing_fm.get("retrieval_query", "")) + == normalized_query + ): + ops_id = Path(existing_key).stem + break + if not ops_id: + title_slug = ( + self._readable_slug(title, fallback="recommendation")[:20].rstrip("-._") + or "recommendation" + ) + ops_id = self._clip_utf8( + "ops-{}-{}-{}".format( + opa_id.removeprefix("opa-")[:20], + title_slug, + sha256(f"{opa_uri}\x1f{effective_target}\x1f{title}".encode()).hexdigest()[ + :8 + ], + ), + 80, + ).rstrip("-._") + key = self._op_key("OPS", ops_id, target_uri=effective_target) + previous = self._record_from_content(self.store.read_text(key) or "") + model = OPSModel( + ops_id=ops_id, + parent_opa=opa_uri, + title=title or str(previous.get("title", ops_id)), + status=status, + target_uri=effective_target, + solution=self._merge_observations(previous.get("solution", ""), solution), + analysis=self._merge_observations(previous.get("analysis", ""), analysis), + retrieval_query=retrieval_query.strip() or str(previous.get("retrieval_query", "")), + retrieval_scopes=retrieval_scopes, + retrieval_hit_uris=list( + dict[str, object].fromkeys( + [ + *self._list_value(previous.get("retrieval_hit_uris")), + *retrieval_hit_uris, + ], + ), + ), + # A retrieval receipt describes this invocation. Do not carry + # stale used hits into a later retry where the evidence set may + # have changed. + retrieval_used_uris=used_uris, + evidence_uris=self._dedupe_uri_values( + [*self._list_value(previous.get("evidence_uris")), *evidence], + exclude={effective_target} if effective_target else set(), + ), + related_uris=self._dedupe_uri_values( + [*self._list_value(previous.get("related_uris")), *related], + exclude=set(evidence) | ({effective_target} if effective_target else set()), + ), + candidate_content=candidate_content or str(previous.get("candidate_content", "")), + candidate_operations=candidate_operations + or self._list_of_dicts(previous.get("candidate_operations")), + expected_sha256=expected_sha256 or str(previous.get("expected_sha256", "")), + source_type=source_type or str(previous.get("source_type", "pipeline")), + reviewed_by=str(previous.get("reviewed_by", "")), + review_notes=str(previous.get("review_notes", "")), + apply_status=str(previous.get("apply_status", "not_ready")), + apply_error=str(previous.get("apply_error", "")), + applied_at=str(previous.get("applied_at", "")), + applied_entity_sha256=str(previous.get("applied_entity_sha256", "")), + ) + self.store.write_text_durable(key, model.to_markdown()) + return { + "ops_id": ops_id, + "uri": self._op_uri("OPS", ops_id), + "parent_opa": opa_uri, + "target_uri": effective_target, + "status": "unconfirmed", + "retrieval_query": retrieval_query.strip(), + "retrieval_scopes": retrieval_scopes, + "retrieval_hit_uris": retrieval_hit_uris, + "retrieval_used_uris": used_uris, + "apply_status": model.apply_status, + } + + def get_ops( + self, + *, + parent_opa: str = "", + status: str = "", + target_uri: str = "", + limit: int = 50, + ) -> list[dict[str, object]]: + """Read OPS recommendations, optionally scoped to one OPA/target.""" + parent_id = self._record_id(parent_opa, "opa") if parent_opa else "" + parent_uri = "" + if parent_id: + try: + _pid, _pfm, parent_uri = self._opa_record(parent_id) + except FileNotFoundError: + parent_uri = self._opa_uri(parent_id) + out: list[dict[str, object]] = [] + for path, content in self._op_files("OPS", target_uri=target_uri): + fm = self._record_from_content(content) + record_parent = str(fm.get("parent_opa", "")) + if parent_uri and record_parent not in {parent_uri, parent_id}: + continue + if status and str(fm.get("status", "")) != status: + continue + if target_uri and str(fm.get("target_uri", "")) != target_uri: + continue + ops_id = str(fm.get("id", Path(path).stem)) + out.append( + { + "ops_id": ops_id, + "uri": self._op_uri("OPS", ops_id), + "path": path, + "parent_opa": record_parent, + "title": str(fm.get("title", ops_id)), + "status": str(fm.get("status", "unconfirmed")), + "target_uri": str(fm.get("target_uri", "")), + "retrieval_query": str(fm.get("retrieval_query", "")), + "retrieval_scopes": self._list_value(fm.get("retrieval_scopes")), + "retrieval_hit_uris": self._list_value(fm.get("retrieval_hit_uris")), + "retrieval_used_uris": self._list_value(fm.get("retrieval_used_uris")), + "evidence_uris": self._list_value(fm.get("evidence_uris")), + "related_uris": self._list_value(fm.get("related_uris")), + "candidate_content": str(fm.get("candidate_content", "")), + "candidate_operations": self._list_of_dicts(fm.get("candidate_operations")), + "expected_sha256": str(fm.get("expected_sha256", "")), + "source_type": str(fm.get("source_type", "pipeline")), + "reviewed_by": str(fm.get("reviewed_by", "")), + "review_notes": str(fm.get("review_notes", "")), + "apply_status": str(fm.get("apply_status", "not_ready")), + "apply_error": str(fm.get("apply_error", "")), + "applied_at": str(fm.get("applied_at", "")), + "applied_entity_sha256": str(fm.get("applied_entity_sha256", "")), + }, + ) + if len(out) >= max(1, limit): + break + return out + + def ingest_external_ops( + self, + *, + parent_opa: str = "", + title: str, + analysis: str, + solution: str, + evidence_uris: list[str], + expert_id: str = "", + expert_name: str = "", + related_uris: list[str] | None = None, + candidate_content: str = "", + candidate_operations: list[dict[str, object]] | None = None, + expected_sha256: str = "", + ops_id: str = "", + ops_uri: str = "", + ) -> dict[str, object]: + """Store external expert knowledge as an OPS draft, not an OPL. + + External input still requires business review. It is therefore kept + in the same editable OPS lifecycle as generated advice and cannot + update formal entities until ``update_ops(status="confirmed")`` and + an explicit ``apply_ops`` call. + + ``ops_uri`` lets an external expert revise an existing OPS record in + place: the URI resolves to its ops_id, and repeated submissions with + the same URI overwrite content (analysis / solution / evidence). + ``parent_opa`` is only required on first creation; revisions inherit + it from the existing record. + """ + if not title.strip() or not analysis.strip() or not solution.strip(): + raise ValueError("External OPS requires title, analysis, and solution") + if not evidence_uris: + raise ValueError("External OPS requires a non-empty evidence_uris list") + candidate_operations = list(candidate_operations or []) + if candidate_content.strip() and candidate_operations: + raise ValueError( + "External OPS must provide candidate_content or candidate_operations, not both" + ) + if any(not isinstance(operation, dict) for operation in candidate_operations): + raise ValueError("External OPS candidate_operations must contain only objects") + if expected_sha256 and _SHA256_RE.fullmatch(expected_sha256) is None: + raise ValueError( + "External OPS expected_sha256 must be a 64-character lowercase SHA-256 hash" + ) + if not ops_id and ops_uri: + ops_id = self._record_id(ops_uri, "ops") + if ops_id: + existing_key = self._find_op_key("OPS", ops_id) + existing_content = self.store.read_text(existing_key) if existing_key else None + if existing_content is not None: + existing_fm = parse_frontmatter(existing_content) + opa_uri = str(existing_fm.get("parent_opa", "") or parent_opa) + target_uri = str(existing_fm.get("target_uri", "")) + return self._rewrite_external_ops( + ops_id=ops_id, + parent_opa=opa_uri, + target_uri=target_uri, + title=title, + analysis=analysis, + solution=solution, + evidence_uris=evidence_uris, + related_uris=related_uris, + candidate_content=candidate_content, + candidate_operations=candidate_operations, + expected_sha256=expected_sha256, + expert_id=expert_id, + expert_name=expert_name, + ) + if not parent_opa: + raise ValueError("External OPS requires parent_opa when creating a new record") + _opa_id, opa_fm, opa_uri = self._opa_record(parent_opa) + target_uri = str(opa_fm.get("target_uri", "")) + evidence = self._dedupe_uri_values( + evidence_uris, exclude={target_uri} if target_uri else set() + ) + related = self._dedupe_uri_values( + related_uris or [], + exclude=set(evidence) | ({target_uri} if target_uri else set()), + ) + self._validate_op_evidence(evidence, [target_uri, *related]) + if not evidence and not (target_uri and self.read_resource(target_uri) is not None): + raise ValueError("External OPS requires at least one readable evidence URI") + if not ops_id: + payload = "\x1f".join( + ( + opa_uri, + target_uri, + title, + expert_id, + candidate_content, + json.dumps( + candidate_operations, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ), + ), + ) + ops_id = self._clip_utf8( + "ops-external-" + + self._readable_slug(title, fallback="expert")[:20].rstrip("-._") + + "-" + + sha256(payload.encode("utf-8")).hexdigest()[:10], + 100, + ).rstrip("-._") + key = self._find_op_key("OPS", ops_id) + if key and self.store.read_text(key) is not None: + return { + "ops_id": ops_id, + "uri": self._op_uri("OPS", ops_id), + "parent_opa": opa_uri, + "target_uri": target_uri, + "status": "unconfirmed", + "source_type": "external_expert", + "apply_status": "not_ready", + "idempotent": True, + } + model = OPSModel( + ops_id=ops_id, + parent_opa=opa_uri, + title=title, + status="unconfirmed", + target_uri=target_uri, + solution=solution, + analysis=analysis, + evidence_uris=evidence, + related_uris=related, + candidate_content=candidate_content, + candidate_operations=candidate_operations, + expected_sha256=expected_sha256, + source_type="external_expert", + reviewed_by="", + review_notes=f"External expert: {expert_name or expert_id}", + apply_status="not_ready", + ) + result = self._write_ops_model(model) + result.update({ + "source_type": "external_expert", + "expert_id": expert_id, + "expert_name": expert_name, + }) + return result + + def _rewrite_external_ops( + self, + *, + ops_id: str, + parent_opa: str, + target_uri: str, + title: str, + analysis: str, + solution: str, + evidence_uris: list[str], + related_uris: list[str] | None, + candidate_content: str, + candidate_operations: list[dict[str, object]] | None, + expected_sha256: str, + expert_id: str, + expert_name: str, + ) -> dict[str, object]: + """Overwrite content of an existing external OPS record in place.""" + evidence = self._dedupe_uri_values( + evidence_uris, exclude={target_uri} if target_uri else set() + ) + related = self._dedupe_uri_values( + related_uris or [], + exclude=set(evidence) | ({target_uri} if target_uri else set()), + ) + self._validate_op_evidence(evidence, [target_uri, *related]) + model = OPSModel( + ops_id=ops_id, + parent_opa=parent_opa, + title=title, + status="unconfirmed", + target_uri=target_uri, + solution=solution, + analysis=analysis, + evidence_uris=evidence, + related_uris=related, + candidate_content=candidate_content, + candidate_operations=list(candidate_operations or []), + expected_sha256=expected_sha256, + source_type="external_expert", + reviewed_by="", + review_notes=f"External expert: {expert_name or expert_id}", + apply_status="not_ready", + ) + result = self._write_ops_model(model) + result.update({ + "source_type": "external_expert", + "expert_id": expert_id, + "expert_name": expert_name, + "revised": True, + }) + return result + + def _ops_record(self, value: str) -> tuple[str, str, dict[str, object]]: + """Return an OPS id, storage key and parsed record.""" + ops_id = self._record_id(value, "ops") + for path, content in self._op_files("OPS"): + if Path(path).stem == ops_id: + return ops_id, path, parse_frontmatter(content) + raise FileNotFoundError(f"OPS not found: {value}") + + def _write_ops_model(self, model: OPSModel) -> dict[str, object]: + """Persist an OPS draft/review state and return its canonical receipt.""" + key = self._op_key("OPS", model.ops_id, target_uri=str(model.target_uri)) + self.store.write_text_durable(key, model.to_markdown()) + return { + "ops_id": model.ops_id, + "uri": self._op_uri("OPS", model.ops_id), + "parent_opa": model.parent_opa, + "target_uri": model.target_uri, + "status": model.status, + "reviewed_by": model.reviewed_by, + "apply_status": model.apply_status, + "apply_error": model.apply_error, + } + + def update_ops( + self, + ops_id: str, + *, + title: str | None = None, + analysis: str | None = None, + solution: str | None = None, + evidence_uris: list[str] | None = None, + related_uris: list[str] | None = None, + candidate_content: str | None = None, + candidate_operations: list[dict[str, object]] | None = None, + expected_sha256: str | None = None, + status: str | None = None, + reviewed_by: str = "", + review_notes: str = "", + ) -> dict[str, object]: + """Iterate or review one OPS draft without creating an OPL snapshot. + + ``unconfirmed`` is the machine-generated draft state. A business + reviewer may revise it and move it to ``confirmed`` or ``rejected``. + Formal Wiki content is not changed by this method; use ``apply_ops`` + explicitly after confirmation. + """ + current_id, _key, frontmatter = self._ops_record(ops_id) + next_status = status or str(frontmatter.get("status", "unconfirmed")) + if next_status not in {"unconfirmed", "confirmed", "rejected"}: + raise ValueError("OPS status must be unconfirmed, confirmed, or rejected") + if next_status in {"confirmed", "rejected"} and not ( + reviewed_by.strip() or str(frontmatter.get("reviewed_by", "")).strip() + ): + raise ValueError("Confirming or rejecting OPS requires reviewed_by") + operations = ( + candidate_operations + if candidate_operations is not None + else self._list_of_dicts(frontmatter.get("candidate_operations")) + ) + candidate = ( + candidate_content + if candidate_content is not None + else str(frontmatter.get("candidate_content", "")) + ) + if candidate.strip() and operations: + raise ValueError("OPS must provide candidate_content or candidate_operations, not both") + next_sha = ( + expected_sha256 + if expected_sha256 is not None + else str(frontmatter.get("expected_sha256", "")) + ) + if next_sha and _SHA256_RE.fullmatch(next_sha) is None: + raise ValueError("OPS expected_sha256 must be a 64-character lowercase SHA-256 hash") + target_uri = str(frontmatter.get("target_uri", "")) + evidence = self._dedupe_uri_values( + evidence_uris + if evidence_uris is not None + else self._list_value(frontmatter.get("evidence_uris")), + exclude={target_uri} if target_uri else set(), + ) + related = self._dedupe_uri_values( + related_uris + if related_uris is not None + else self._list_value(frontmatter.get("related_uris")), + exclude=set(evidence) | ({target_uri} if target_uri else set()), + ) + self._validate_op_evidence(evidence, [target_uri, *related]) + model = OPSModel( + ops_id=current_id, + parent_opa=str(frontmatter.get("parent_opa", "")), + title=title if title is not None else str(frontmatter.get("title", current_id)), + status=next_status, + target_uri=target_uri, + solution=solution if solution is not None else str(frontmatter.get("solution", "")), + analysis=analysis if analysis is not None else str(frontmatter.get("analysis", "")), + retrieval_query=str(frontmatter.get("retrieval_query", "")), + retrieval_scopes=self._list_value(frontmatter.get("retrieval_scopes")), + retrieval_hit_uris=self._list_value(frontmatter.get("retrieval_hit_uris")), + retrieval_used_uris=self._list_value(frontmatter.get("retrieval_used_uris")), + evidence_uris=evidence, + related_uris=related, + candidate_content=candidate, + candidate_operations=list(operations), + expected_sha256=next_sha, + source_type=str(frontmatter.get("source_type", "pipeline")), + reviewed_by=reviewed_by.strip() or str(frontmatter.get("reviewed_by", "")), + review_notes=review_notes.strip() or str(frontmatter.get("review_notes", "")), + apply_status="rejected" + if next_status == "rejected" + else str(frontmatter.get("apply_status", "not_ready")), + apply_error=str(frontmatter.get("apply_error", "")), + applied_at=str(frontmatter.get("applied_at", "")), + applied_entity_sha256=str(frontmatter.get("applied_entity_sha256", "")), + ) + result = self._write_ops_model(model) + result["updated"] = True + return result + + def apply_ops(self, ops_id: str) -> dict[str, object]: + """Apply a confirmed OPS candidate with optimistic locking. + + This is deliberately separate from OPS creation and review. No + automatically generated draft can mutate a formal entity. + """ + current_id, _key, frontmatter = self._ops_record(ops_id) + if str(frontmatter.get("status", "unconfirmed")) != "confirmed": + raise ValueError("Only confirmed OPS records can be applied") + if str(frontmatter.get("apply_status", "")) == "applied": + return { + "ops_id": current_id, + "uri": self._op_uri("OPS", current_id), + "status": "confirmed", + "apply_status": "applied", + "idempotent": True, + } + target_uri = str(frontmatter.get("target_uri", "")) + target_info = self.store.lookup_by_uri(target_uri) + if target_info is None: + raise ValueError("Confirmed OPS target_uri is not a known Wiki entity") + concept, class_name, object_name = target_info[0], target_info[1] or "", target_info[2] + candidate = str(frontmatter.get("candidate_content", "")) + operations = self._list_of_dicts(frontmatter.get("candidate_operations")) + if not candidate and not operations: + return self._write_ops_model( + OPSModel( + ops_id=current_id, + parent_opa=str(frontmatter.get("parent_opa", "")), + title=str(frontmatter.get("title", current_id)), + status="confirmed", + target_uri=target_uri, + solution=str(frontmatter.get("solution", "")), + analysis=str(frontmatter.get("analysis", "")), + retrieval_query=str(frontmatter.get("retrieval_query", "")), + retrieval_scopes=self._list_value(frontmatter.get("retrieval_scopes")), + retrieval_hit_uris=self._list_value(frontmatter.get("retrieval_hit_uris")), + retrieval_used_uris=self._list_value(frontmatter.get("retrieval_used_uris")), + evidence_uris=self._list_value(frontmatter.get("evidence_uris")), + related_uris=self._list_value(frontmatter.get("related_uris")), + apply_status="needs_review", + apply_error="Confirmed OPS has no candidate content or operations.", + ), + ) + current = self.store.read_entity(concept, class_name or None, object_name) + expected_sha256 = str(frontmatter.get("expected_sha256", "")) + if current is not None: + current_sha256 = sha256(current.encode("utf-8")).hexdigest() + if not expected_sha256: + return self._update_ops_apply_status( + current_id, + "needs_review", + "Existing target requires expected_sha256 before application.", + ) + if expected_sha256 != current_sha256: + return self._update_ops_apply_status( + current_id, "failed", "Target changed; refresh OPS with a new expected_sha256." + ) + if not candidate: + candidate = self._apply_operations(current, operations) + elif not candidate: + return self._update_ops_apply_status( + current_id, "failed", "Patch operations cannot create a missing entity." + ) + try: + entity_uri = self.merge_entity( + concept, + class_name, + object_name, + candidate, + expected_sha256=expected_sha256, + conflict_policy="detect", + ) + except (FileNotFoundError, OSError, ValueError) as error: + return self._update_ops_apply_status(current_id, "failed", str(error)) + applied_hash = sha256( + (self.store.read_entity_by_uri(entity_uri) or "").encode("utf-8") + ).hexdigest() + result = self._update_ops_apply_status( + current_id, "applied", "", applied_entity_sha256=applied_hash + ) + result.update({"entity_uri": entity_uri}) + return result + + def _update_ops_apply_status( + self, + ops_id: str, + apply_status: str, + apply_error: str, + *, + applied_entity_sha256: str = "", + ) -> dict[str, object]: + """Update only application metadata while preserving the reviewed OPS.""" + current_id, _key, fm = self._ops_record(ops_id) + model = OPSModel( + ops_id=current_id, + parent_opa=str(fm.get("parent_opa", "")), + title=str(fm.get("title", current_id)), + status=str(fm.get("status", "unconfirmed")), + target_uri=str(fm.get("target_uri", "")), + solution=str(fm.get("solution", "")), + analysis=str(fm.get("analysis", "")), + retrieval_query=str(fm.get("retrieval_query", "")), + retrieval_scopes=self._list_value(fm.get("retrieval_scopes")), + retrieval_hit_uris=self._list_value(fm.get("retrieval_hit_uris")), + retrieval_used_uris=self._list_value(fm.get("retrieval_used_uris")), + evidence_uris=self._list_value(fm.get("evidence_uris")), + related_uris=self._list_value(fm.get("related_uris")), + candidate_content=str(fm.get("candidate_content", "")), + candidate_operations=self._list_of_dicts(fm.get("candidate_operations")), + expected_sha256=str(fm.get("expected_sha256", "")), + source_type=str(fm.get("source_type", "pipeline")), + reviewed_by=str(fm.get("reviewed_by", "")), + review_notes=str(fm.get("review_notes", "")), + apply_status=apply_status, + apply_error=apply_error, + applied_at=datetime.now(UTC).isoformat() + if apply_status == "applied" + else str(fm.get("applied_at", "")), + applied_entity_sha256=applied_entity_sha256 or str(fm.get("applied_entity_sha256", "")), + ) + return self._write_ops_model(model) + + def create_opl( + self, + *, + parent_opa: str, + ops_uris: list[str], + title: str, + proposal: str, + rationale: str, + evidence_uris: list[str] | None = None, + related_uris: list[str] | None = None, + target_uri: str = "", + status: str = "unconfirmed", + opl_id: str = "", + opl_uri: str = "", + source_type: str = "pipeline", + expert_id: str = "", + expert_name: str = "", + candidate_content: str = "", + candidate_operations: list[dict[str, object]] | None = None, + expected_sha256: str = "", + archive_reason: str = "", + ) -> dict[str, object]: + """Form an unconfirmed knowledge proposal from one OPA and its OPS. + + OPL is an optional derived snapshot, not a fact source. Automatic + builds must not create OPL records: only an explicit manual archive + (``archive_reason``) may persist one, and every referenced OPS must + already be ``confirmed`` — an unconfirmed OPS means the proposal is + still being reviewed and cannot be archived yet. + """ + opa_id, opa_fm, opa_uri = self._opa_record(parent_opa) + if status != "unconfirmed": + raise ValueError("New OPL records must remain status=unconfirmed") + if not ops_uris: + raise ValueError("OPL requires at least one OPS URI") + ops_rows = self.get_ops(parent_opa=opa_id, limit=200) + ops_by_uri = {str(row["uri"]): row for row in ops_rows} + missing_ops = [uri for uri in ops_uris if uri not in ops_by_uri] + if missing_ops: + raise ValueError(f"OPL references OPS records not attached to {opa_id}: {missing_ops}") + if source_type != "external_expert" and not archive_reason.strip(): + raise ValueError( + "OPL is a derived snapshot, not a fact source: automatic builds must not " + "create OPL records. Provide archive_reason to archive an explicit " + "manual snapshot.", + ) + unconfirmed_ops = [ + uri + for uri in ops_uris + if str(ops_by_uri[uri].get("status", "unconfirmed")) != "confirmed" + ] + if unconfirmed_ops: + raise ValueError( + "OPL may only archive confirmed OPS records; unconfirmed OPS must be " + f"reviewed (update_ops status=confirmed) first: {unconfirmed_ops}", + ) + inherited_evidence = self._list_value(opa_fm.get("evidence_uris")) + for row in ops_rows: + if row["uri"] in ops_uris: + inherited_evidence.extend(self._list_value(row["evidence_uris"])) + effective_target = self._require_parent_target( + parent_target=str(opa_fm.get("target_uri", "")), + requested_target=target_uri, + ) + evidence = self._dedupe_uri_values( + [*inherited_evidence, *(evidence_uris or [])], + exclude={effective_target} if effective_target else set(), + ) + related = self._dedupe_uri_values( + [*self._list_value(opa_fm.get("related_uris")), *(related_uris or [])], + exclude=set(evidence) | ({effective_target} if effective_target else set()), + ) + mismatched_ops = [ + uri + for uri in ops_uris + if self.store.resolve_redirect(str(ops_by_uri[uri].get("target_uri", ""))) + != self.store.resolve_redirect(effective_target) + ] + if mismatched_ops: + raise ValueError( + f"OPL references OPS records with a different target_uri: {mismatched_ops}" + ) + self._validate_op_evidence(evidence, [effective_target, *related, *ops_uris]) + target_is_readable = bool( + effective_target and self.read_resource(effective_target) is not None + ) + if ( + not proposal.strip() + or not rationale.strip() + or (not evidence and not target_is_readable) + ): + raise ValueError("OPL requires detailed proposal, rationale, and evidence URIs") + if not opl_id and opl_uri: + opl_id = self._record_id(opl_uri, "opl") + if not opl_id: + requested_ops = set( + dict[str, object].fromkeys(uri.strip() for uri in ops_uris if uri.strip()) + ) + for existing_key, existing_content in self._op_files("OPL"): + existing_fm = parse_frontmatter(existing_content) + existing_ops = set(self._list_value(existing_fm.get("ops_uris"))) + if ( + str(existing_fm.get("status", "unconfirmed")).lower() == "unconfirmed" + and existing_ops == requested_ops + and self.store.resolve_redirect(str(existing_fm.get("target_uri", ""))) + == self.store.resolve_redirect(effective_target) + ): + opl_id = Path(existing_key).stem + break + if not opl_id: + title_slug = ( + self._readable_slug(title, fallback="proposal")[:20].rstrip("-._") or "proposal" + ) + opl_id = self._clip_utf8( + "opl-{}-{}-{}".format( + opa_id.removeprefix("opa-")[:20], + title_slug, + sha256(f"{opa_uri}\x1f{effective_target}\x1f{title}".encode()).hexdigest()[ + :8 + ], + ), + 80, + ).rstrip("-._") + key = self._op_key("OPL", opl_id, target_uri=effective_target) + previous = parse_frontmatter(self.store.read_text(key) or "") + model = OPLModel( + opl_id=opl_id, + title=title or str(previous.get("title", opl_id)), + parent_opa=opa_uri, + ops_uris=list( + dict[str, object].fromkeys(uri.strip() for uri in ops_uris if uri.strip()) + ), + target_uri=effective_target, + status="unconfirmed", + proposal=self._merge_observations(previous.get("proposal", ""), proposal), + rationale=self._merge_observations(previous.get("rationale", ""), rationale), + evidence_uris=self._dedupe_uri_values( + [*self._list_value(previous.get("evidence_uris")), *evidence], + exclude={effective_target} if effective_target else set(), + ), + related_uris=self._dedupe_uri_values( + [*self._list_value(previous.get("related_uris")), *related], + exclude=set(evidence) | ({effective_target} if effective_target else set()), + ), + source_type=source_type, + expert_id=expert_id, + expert_name=expert_name, + candidate_content=candidate_content, + candidate_operations=list(candidate_operations or []), + expected_sha256=expected_sha256, + archive_reason=archive_reason.strip(), + ) + self.store.write_text_durable(key, model.to_markdown()) + return { + "opl_id": opl_id, + "uri": self._op_uri("OPL", opl_id), + "parent_opa": opa_uri, + "ops_uris": list(dict[str, object].fromkeys(ops_uris)), + "target_uri": effective_target, + "status": "unconfirmed", + "source_type": source_type, + "apply_status": "not_applied" if source_type == "external_expert" else "not_applicable", + } + + def _write_opl_model(self, model: OPLModel) -> dict[str, object]: + """Persist an OPL model and return its canonical receipt.""" + key = self._op_key("OPL", model.opl_id, target_uri=str(model.target_uri)) + self.store.write_text_durable(key, model.to_markdown()) + return { + "opl_id": model.opl_id, + "uri": self._op_uri("OPL", model.opl_id), + "parent_opa": model.parent_opa, + "ops_uris": list(model.ops_uris), + "target_uri": model.target_uri, + "status": model.status, + "source_type": model.source_type, + "apply_status": model.apply_status, + "apply_error": model.apply_error, + } + + def _external_opl_id( + self, + *, + target_uri: str, + title: str, + expert_id: str, + candidate_content: str, + candidate_operations: list[dict[str, object]], + ) -> str: + payload = "\x1f".join( + ( + target_uri, + title, + expert_id, + candidate_content, + json.dumps( + candidate_operations, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ), + ), + ) + return self._clip_utf8( + "opl-external-" + + self._readable_slug(title, fallback="expert")[:20].rstrip("-._") + + "-" + + sha256(payload.encode("utf-8")).hexdigest()[:10], + 100, + ).rstrip("-._") + + def ingest_external_opl( + self, + *, + title: str, + target_uri: str, + proposal: str, + rationale: str, + evidence_uris: list[str], + expert_id: str = "", + expert_name: str = "", + source_uri: str = "", + related_uris: list[str] | None = None, + candidate_content: str = "", + candidate_operations: list[dict[str, object]] | None = None, + target_concept: str = "", + target_class_name: str = "", + target_object_name: str = "", + expected_sha256: str = "", + opl_id: str = "", + opl_uri: str = "", + auto_apply: bool = True, + ) -> dict[str, object]: + """Ingest a direct expert OPL and optionally apply its machine patch. + + Direct expert input does not need a pre-existing OPS. The method + creates a resolved ``expert_feedback`` OPA as the audit parent, stores + the OPL with its expert identity and evidence, then applies only a + full candidate page or deterministic patch operations. Application + uses optimistic locking and an explicit external-authority policy; + free-form prose is stored but never guessed into the Wiki. + """ + if not title.strip() or not proposal.strip() or not rationale.strip(): + raise ValueError("External OPL requires title, proposal, and rationale") + if not target_uri.strip(): + raise ValueError("External OPL requires target_uri") + if not isinstance(evidence_uris, list) or not evidence_uris: + raise ValueError("External OPL requires a non-empty evidence_uris list") + if not (expert_id.strip() or expert_name.strip()): + raise ValueError("External OPL requires expert_id or expert_name") + candidate_operations = list(candidate_operations or []) + if candidate_content.strip() and candidate_operations: + raise ValueError( + "External OPL must provide candidate_content or candidate_operations, not both" + ) + if any(not isinstance(operation, dict) for operation in candidate_operations): + raise ValueError("External OPL candidate_operations must contain only objects") + if expected_sha256 and _SHA256_RE.fullmatch(expected_sha256) is None: + raise ValueError( + "External OPL expected_sha256 must be a 64-character lowercase SHA-256 hash" + ) + target_info = self.store.lookup_by_uri(target_uri) + if target_info is not None: + target_concept = target_concept or target_info[0] + target_class_name = target_class_name or target_info[1] or "" + target_object_name = target_object_name or target_info[2] + if not target_concept or not target_object_name: + raise ValueError("External OPL requires a resolvable target_uri or target identity") + expected_target_uri = self.store.entity_uri( + target_concept, + target_class_name or None, + target_object_name, + ) + if self.store.resolve_redirect(target_uri) != self.store.resolve_redirect( + expected_target_uri + ): + raise ValueError( + f"External OPL target does not match target identity: " + f"{target_uri} != {expected_target_uri}", + ) + current_content = self.store.read_entity( + target_concept, target_class_name or None, target_object_name + ) + target_is_readable = self.read_resource(target_uri) is not None + evidence_candidates = [*evidence_uris, source_uri] + effective_evidence = self._dedupe_uri_values( + evidence_candidates, + exclude={target_uri} if target_is_readable else set(), + ) + if not effective_evidence: + raise ValueError("External OPL requires at least one readable evidence URI") + effective_related = self._dedupe_uri_values( + related_uris or [], + exclude=set(effective_evidence) | {target_uri}, + ) + if not opl_id and opl_uri: + opl_id = self._record_id(opl_uri, "opl") + if not opl_id: + opl_id = self._external_opl_id( + target_uri=target_uri, + title=title, + expert_id=expert_id, + candidate_content=candidate_content, + candidate_operations=candidate_operations, + ) + opl_key = self._find_op_key("OPL", opl_id) + if not opl_key: + raise FileNotFoundError(f"OPL not found: {opl_id}") + existing = self.store.read_text(opl_key) + if existing is not None: + previous = parse_frontmatter(existing) + if str(previous.get("apply_status", "")) == "applied": + return { + "opl_id": opl_id, + "uri": self._op_uri("OPL", opl_id), + "status": str(previous.get("status", "applied")), + "apply_status": "applied", + "idempotent": True, + } + previous_parent_opa = ( + parse_frontmatter(existing).get("parent_opa", "") if existing is not None else "" + ) + if previous_parent_opa: + parent_opa = str(previous_parent_opa) + else: + opa = self.create_opa( + title=f"外部专家反馈:{title}", + description=f"外部专家 {expert_name or expert_id} 提交了直接 OPL。", + category="feedback", + reason_code="expert_feedback", + subtype="expert_feedback", + target_uri=target_uri, + target_section="external_opl", + evidence_uris=effective_evidence, + status="resolved", + solution="已接收外部专家 OPL;正式实体更新状态见 OPL apply_status。", + finding=proposal, + missing="", + recommendation="保留专家身份、来源和候选变更;仅对结构化候选内容执行幂等更新。", + related_uris=effective_related, + ) + parent_opa = str(opa["uri"]) + machine_candidate = bool(candidate_content.strip() or candidate_operations) + apply_status = "pending" if auto_apply and machine_candidate else "needs_review" + apply_error = "" + if apply_status == "pending" and current_content is not None and not expected_sha256: + apply_status = "needs_review" + apply_error = "Existing target requires expected_sha256 before automatic application." + model = OPLModel( + opl_id=opl_id, + title=title, + parent_opa=parent_opa, + target_uri=target_uri, + status="unconfirmed", + proposal=proposal, + rationale=rationale, + evidence_uris=effective_evidence, + related_uris=effective_related, + source_type="external_expert", + expert_id=expert_id, + expert_name=expert_name, + source_uri=source_uri, + target_concept=target_concept, + target_class_name=target_class_name, + target_object_name=target_object_name, + expected_sha256=expected_sha256, + candidate_content=candidate_content, + candidate_operations=candidate_operations, + apply_status=apply_status, + apply_error=apply_error, + ) + stored = self._write_opl_model(model) + if apply_status != "pending": + return stored + return self.apply_opl(opl_id) + + def _update_external_opl_status( + self, + opl_id: str, + *, + status: str, + apply_status: str, + apply_error: str = "", + applied_at: str = "", + applied_entity_sha256: str = "", + ) -> dict[str, object]: + key = self._find_op_key("OPL", opl_id) + if not key: + raise FileNotFoundError(f"OPL not found: {opl_id}") + content = self.store.read_text(key) or "" + if not content: + raise FileNotFoundError(f"OPL not found: {opl_id}") + frontmatter = parse_frontmatter(content) + model = OPLModel( + opl_id=opl_id, + title=str(frontmatter.get("title", opl_id)), + parent_opa=str(frontmatter.get("parent_opa", "")), + ops_uris=self._list_value(frontmatter.get("ops_uris")), + target_uri=str(frontmatter.get("target_uri", "")), + status=status, + proposal=str(frontmatter.get("proposal", "")), + rationale=str(frontmatter.get("rationale", "")), + evidence_uris=self._list_value(frontmatter.get("evidence_uris")), + related_uris=self._list_value(frontmatter.get("related_uris")), + source_type=str(frontmatter.get("source_type", "pipeline")), + expert_id=str(frontmatter.get("expert_id", "")), + expert_name=str(frontmatter.get("expert_name", "")), + source_uri=str(frontmatter.get("source_uri", "")), + target_concept=str(frontmatter.get("target_concept", "")), + target_class_name=str(frontmatter.get("target_class_name", "")), + target_object_name=str(frontmatter.get("target_object_name", "")), + expected_sha256=str(frontmatter.get("expected_sha256", "")), + candidate_content=str(frontmatter.get("candidate_content", "")), + candidate_operations=self._list_of_dicts(frontmatter.get("candidate_operations")), + apply_status=apply_status, + apply_error=apply_error, + applied_at=applied_at, + applied_entity_sha256=applied_entity_sha256, + ) + return self._write_opl_model(model) + + def _resolve_external_target_conflicts( + self, target_uri: str, evidence_uris: list[str], opl_uri: str + ) -> int: + resolved = 0 + for record in self.get_opas( + target_uri=target_uri, status="pending", category="conflict", limit=200 + ): + self.resolve_opa( + str(record["opa_id"]), + solution=f"Resolved by external expert OPL {opl_uri}.", + evidence_uris=evidence_uris, + ) + resolved += 1 + return resolved + + def apply_opl(self, opl_id: str) -> dict[str, object]: + """Apply a stored external OPL exactly once when it is machine-ready.""" + key = self._find_op_key("OPL", opl_id) + if not key: + raise FileNotFoundError(f"OPL not found: {opl_id}") + content = self.store.read_text(key) or "" + if not content: + raise FileNotFoundError(f"OPL not found: {opl_id}") + frontmatter = parse_frontmatter(content) + if str(frontmatter.get("source_type", "")) != "external_expert": + raise ValueError("Only external_expert OPL records can be auto-applied") + if str(frontmatter.get("apply_status", "")) == "applied": + return { + "opl_id": opl_id, + "uri": self._op_uri("OPL", opl_id), + "status": str(frontmatter.get("status", "applied")), + "apply_status": "applied", + "idempotent": True, + } + target_uri = str(frontmatter.get("target_uri", "")) + target_info = self.store.lookup_by_uri(target_uri) + concept = str(frontmatter.get("target_concept", "")) + class_name = str(frontmatter.get("target_class_name", "")) + object_name = str(frontmatter.get("target_object_name", "")) + if target_info is not None: + concept, class_name, object_name = target_info[0], target_info[1] or "", target_info[2] + candidate = str(frontmatter.get("candidate_content", "")) + operations: list[dict[str, object]] = self._list_of_dicts( + frontmatter.get("candidate_operations") + ) + if not candidate and not operations: + return self._update_external_opl_status( + opl_id, status="unconfirmed", apply_status="needs_review" + ) + current = self.store.read_entity(concept, class_name or None, object_name) + expected_sha256 = str(frontmatter.get("expected_sha256", "")) + if current is not None: + current_sha256 = sha256(current.encode("utf-8")).hexdigest() + if not expected_sha256: + return self._update_external_opl_status( + opl_id, + status="unconfirmed", + apply_status="needs_review", + apply_error=( + "Existing target requires expected_sha256 before automatic application." + ), + ) + if expected_sha256 != current_sha256: + return self._update_external_opl_status( + opl_id, + status="unconfirmed", + apply_status="failed", + apply_error=( + "Target changed after external OPL was prepared; " + "re-submit with a fresh expected_sha256." + ), + ) + expected_sha256 = current_sha256 + if not candidate: + candidate = self._apply_operations(current, operations) + elif not candidate: + return self._update_external_opl_status( + opl_id, + status="unconfirmed", + apply_status="failed", + apply_error=( + "candidate_operations cannot create a missing target without candidate_content." + ), + ) + try: + entity_uri = self.merge_entity( + concept, + class_name, + object_name, + candidate, + expected_sha256=expected_sha256, + conflict_policy="external_authority", + ) + except (FileNotFoundError, OSError, ValueError) as error: + return self._update_external_opl_status( + opl_id, + status="unconfirmed", + apply_status="failed", + apply_error=str(error), + ) + opl_uri = self._op_uri("OPL", opl_id) + resolved_conflicts = self._resolve_external_target_conflicts( + target_uri=entity_uri, + evidence_uris=self._list_value(frontmatter.get("evidence_uris")), + opl_uri=opl_uri, + ) + result = self._update_external_opl_status( + opl_id, + status="applied", + apply_status="applied", + applied_at=datetime.now(UTC).isoformat(), + applied_entity_sha256=sha256( + (self.store.read_entity_by_uri(entity_uri) or "").encode("utf-8") + ).hexdigest(), + ) + result.update({"entity_uri": entity_uri, "resolved_conflict_count": resolved_conflicts}) + return result + + def get_opls( + self, + *, + parent_opa: str = "", + status: str = "", + target_uri: str = "", + limit: int = 50, + ) -> list[dict[str, object]]: + """Read unconfirmed or historical OPL proposals.""" + parent_id = self._record_id(parent_opa, "opa") if parent_opa else "" + parent_uri = "" + if parent_id: + try: + _pid, _pfm, parent_uri = self._opa_record(parent_id) + except FileNotFoundError: + parent_uri = self._opa_uri(parent_id) + out: list[dict[str, object]] = [] + for path, content in self._op_files("OPL", target_uri=target_uri): + fm = self._record_from_content(content) + sections = extract_sections(content) + if target_uri and str(fm.get("target_uri", "")) != target_uri: + continue + if parent_uri and str(fm.get("parent_opa", "")) not in {parent_uri, parent_id}: + continue + if status and str(fm.get("status", "")) != status: + continue + opl_id = str(fm.get("id", Path(path).stem)) + out.append( + { + "opl_id": opl_id, + "uri": self._op_uri("OPL", opl_id), + "path": path, + "title": str(fm.get("title", opl_id)), + "parent_opa": str(fm.get("parent_opa", "")), + "ops_uris": self._list_value(fm.get("ops_uris")), + "target_uri": str(fm.get("target_uri", "")), + "status": str(fm.get("status", "unconfirmed")), + "proposal": str(fm.get("proposal", "")) + or sections.get("初版知识提案", "").strip(), + "rationale": str(fm.get("rationale", "")) + or sections.get("形成依据", "").strip(), + "evidence_uris": self._list_value(fm.get("evidence_uris")), + "related_uris": self._list_value(fm.get("related_uris")), + "source_type": str(fm.get("source_type", "pipeline")), + "expert_id": str(fm.get("expert_id", "")), + "expert_name": str(fm.get("expert_name", "")), + "source_uri": str(fm.get("source_uri", "")), + "apply_status": str(fm.get("apply_status", "not_applied")), + "apply_error": str(fm.get("apply_error", "")), + "applied_at": str(fm.get("applied_at", "")), + }, + ) + if len(out) >= max(1, limit): + break + return out + + def op_flow_status(self, *, limit: int = 500) -> dict[str, object]: + """Check that every still-open OPA has a valid OPS draft or a reasoned deferral. + + "Open" is decided by ``_opa_open_for_gate`` (closure_status, not + status), so a record marked ``status=resolved`` no longer bypasses + the gate unless its ``closure_status`` is ``closed`` or + ``deferred`` (the latter only with a ``closure_reason``). + """ + checkpoint = self.store.read_json("index/build_checkpoint.json") + build_id = ( + str(checkpoint.get("build_id", "")).strip() if isinstance(checkpoint, dict) else "" + ) + all_opas = self.get_opas(build_id=build_id, limit=limit) + pending = [opa for opa in all_opas if self._opa_open_for_gate(opa)] + # Load OPS once. The previous implementation rescanned every OPS + # file once per pending OPA, turning a quality gate into OPAxOPS remote + # directory reads on large builds. + all_ops = self.get_ops(limit=max(10000, limit * 4)) + ops_by_parent: dict[str, list[dict[str, object]]] = {} + for row in all_ops: + parent = self._record_id(str(row.get("parent_opa", "")), "opa") + ops_by_parent.setdefault(parent, []).append(row) + blockers: list[dict[str, object]] = [] + for opa in pending: + opa_id = str(opa["opa_id"]) + target_uri = str(opa.get("target_uri", "")) + target_readable = bool(target_uri and self.read_resource(target_uri) is not None) + ops = ops_by_parent.get(opa_id, []) + invalid: list[str] = self._op_reference_quality( + target_uri=target_uri, + evidence_uris=self._list_value(opa.get("evidence_uris")), + related_uris=self._list_value(opa.get("related_uris")), + ) + required_opa_fields = ("description", "finding", "missing", "recommendation") + if any(not str(opa.get(field, "")).strip() for field in required_opa_fields): + invalid.append("OPA_CONTENT_MISSING") + valid_ops = [ + row + for row in ops + if ( + str(row.get("status", "")) in {"unconfirmed", "confirmed"} + and self.store.resolve_redirect(str(row.get("target_uri", ""))) + == self.store.resolve_redirect(target_uri) + and ( + str(row.get("source_type", "pipeline")) == "external_expert" + or ( + bool(str(row.get("retrieval_query", "")).strip()) + and self._retrieval_scopes_match( + self._list_value(row.get("retrieval_scopes")) + ) + ) + ) + and (bool(self._list_value(row.get("evidence_uris"))) or target_readable) + and not self._op_reference_quality( + target_uri=str(row.get("target_uri", "")), + evidence_uris=self._list_value(row.get("evidence_uris")), + related_uris=self._list_value(row.get("related_uris")), + ) + ) + ] + if ops and any( + str(row.get("source_type", "pipeline")) != "external_expert" + and not str(row.get("retrieval_query", "")).strip() + for row in ops + ): + invalid.append("OPS_RETRIEVAL_RECEIPT_MISSING") + if ops and any( + self.store.resolve_redirect(str(row.get("target_uri", ""))) + != self.store.resolve_redirect(target_uri) + for row in ops + ): + invalid.append("OPS_TARGET_MISMATCH") + if ops and any( + self._list_value(row.get("retrieval_used_uris")) + and ( + not set(self._list_value(row.get("retrieval_used_uris"))).issubset( + set(self._list_value(row.get("retrieval_hit_uris"))), + ) + or not set(self._list_value(row.get("retrieval_used_uris"))).issubset( + set(self._list_value(row.get("evidence_uris"))) + | set(self._list_value(row.get("related_uris"))) + | {str(row.get("target_uri", ""))}, + ) + ) + for row in ops + ): + invalid.append("OPS_RETRIEVAL_USAGE_MISMATCH") + if not valid_ops or invalid: + blockers.append( + { + "opa_id": opa_id, + "opa_uri": opa["uri"], + "status": str(opa.get("status", "")), + "closure_status": str(opa.get("closure_status", "")), + "missing": ["OPS"] if not valid_ops else [], + "invalid": invalid, + }, + ) + return { + "passed": not blockers, + "open_opa_count": len(pending), + "blocker_count": len(blockers), + "blockers": blockers, + } + + def ops_dispatch_plan( + self, + *, + limit: int = 1000, + max_parallel_shards: int | None = None, + ) -> dict[str, object]: + """Code-generated, domain-grouped OPS dispatch plan for the conductor. + + Returns every open OPA that has no OPS draft yet, sorted cheapest + repair first (``relation_missed`` before ``source_incomplete``) and + grouped by target class so the conductor can dispatch the whole plan + in one ``task_create_batch`` instead of hand-enumerating. + + The plan also exposes a sharded view (``shards``): items grouped by + target class and chunked into ``_entity_batch_limit()``-sized OPS + work batches. ``max_parallel_shards`` caps how many shards are + dispatched in the current wave; ``remaining_count`` reports how many + shards are left for later waves. + """ + if max_parallel_shards is not None and max_parallel_shards < 1: + raise ValueError("max_parallel_shards must be positive when provided") + checkpoint = self.store.read_json("index/build_checkpoint.json") + build_id = ( + str(checkpoint.get("build_id", "")).strip() if isinstance(checkpoint, dict) else "" + ) + all_opas = self.get_opas(build_id=build_id, limit=limit) + open_opas = [opa for opa in all_opas if self._opa_open_for_gate(opa)] + all_ops = self.get_ops(limit=max(10000, limit * 4)) + ops_parent_ids = {self._record_id(str(row.get("parent_opa", "")), "opa") for row in all_ops} + # Cheapest repair first so the conductor can close link gaps quickly. + repair_cost = { + "relation_missed": 0, + "extraction_missed": 1, + "param_unhosted": 2, + "source_incomplete": 3, + "manual_error": 4, + "process_conflict": 5, + "hallucination": 6, + "content_missing": 7, + "fact_conflict": 8, + } + root_prefix = self.store.root_uri.rstrip("/") + "/" + items: list[dict[str, object]] = [] + for opa in open_opas: + opa_id = str(opa["opa_id"]) + if opa_id in ops_parent_ids: + continue + target_uri = str(opa.get("target_uri", "")) + target_class = target_uri.removeprefix(root_prefix).split("/")[0] if target_uri else "" + reason = str(opa.get("reason_code", "content_missing")) + section = str(opa.get("target_section", "")) + items.append( + { + "opa_id": opa_id, + "opa_uri": str(opa.get("uri", "")), + "target_uri": target_uri, + "target_class": target_class, + "target_section": section, + "reason_code": reason, + "suggested_retrieval_query": f"{opa.get('title', '')!s} {section}".strip(), + "repair_cost": repair_cost.get(reason, 9), + }, + ) + items.sort(key=lambda row: (row["repair_cost"], row["target_class"], row["opa_id"])) + by_domain: dict[str, int] = {} + for row in items: + by_domain[str(row["target_class"])] = by_domain.get(str(row["target_class"]), 0) + 1 + grouped_by_class: dict[str, list[dict[str, object]]] = {} + for row in items: + grouped_by_class.setdefault(str(row["target_class"]), []).append(row) + all_shards: list[dict[str, object]] = [] + shard_index = 0 + for target_class in sorted(grouped_by_class): + class_items = grouped_by_class[target_class] + for start in range(0, len(class_items), _entity_batch_limit()): + shard_index += 1 + chunk = class_items[start : start + _entity_batch_limit()] + opa_ids = [str(row["opa_id"]) for row in chunk] + shard_id = f"ops_{target_class.casefold()}_{shard_index}" + task_description = "\n".join( + [ + "worker_role: wiki_ops_worker", + "depends_on_stage: opa_discovered", + "ops_planner: ops_dispatch_plan", + f"ops_shard_id: {shard_id}", + f"write_scope: ops_draft:{shard_id}", + f"opa_ids: {json.dumps(opa_ids, ensure_ascii=False)}", + ( + "worker_task: read each OPA and its evidence, then create an " + "unconfirmed OPS draft proposing the repair for that OPA" + ), + "expected_artifacts: unconfirmed_ops_drafts", + ], + ) + all_shards.append( + { + "shard_id": shard_id, + "worker_role": "wiki_ops_worker", + "opa_ids": opa_ids, + "item_count": len(chunk), + "task_description": task_description, + }, + ) + shards = all_shards[:max_parallel_shards] if max_parallel_shards is not None else all_shards + remaining_count = len(all_shards) - len(shards) + return { + "build_id": build_id, + "open_opa_count": len(open_opas), + "to_dispatch": len(items), + "already_has_ops": len(open_opas) - len(items), + "by_domain": by_domain, + "items": items, # keep flat list for backward compat + "shards": shards, # NEW: sharded view + "remaining_count": remaining_count, # NEW + } + + def op_flow_report( + self, + *, + persist: bool = True, + limit: int = 10000, + build_id: str | None = None, + ) -> dict[str, object]: + """Report OPA/OPS/OPL counts and source/Wiki evidence coverage. + + Coverage is calculated from readable raw or Wiki URIs, not merely from + a non-empty YAML field. OPL counts are observational only; the + automatic gate is based on valid OPS drafts. The report is persisted under + ``index/op_flow_report.json`` by default for build handoff. + """ + if limit < 1 or limit > _MAX_REPORT_LIMIT: + raise ValueError("op_flow_report limit must be between 1 and 10000") + opas = self.get_opas(limit=limit, build_id=build_id or "") + ops = self.get_ops(limit=limit) + opls = self.get_opls(limit=limit) + if build_id is not None: + active_opa_ids = {str(row.get("opa_id", "")) for row in opas} + ops = [ + row + for row in ops + if self._record_id(str(row.get("parent_opa", "")), "opa") in active_opa_ids + ] + opls = [ + row + for row in opls + if self._record_id(str(row.get("parent_opa", "")), "opa") in active_opa_ids + ] + + def coverage(uris: list[str]) -> dict[str, object]: + unique = list(dict[str, object].fromkeys(uri.strip() for uri in uris if uri.strip())) + manual = 0 + wiki = 0 + readable = 0 + unresolved = 0 + for uri in unique: + kind = classify_raw_source_uri(uri, raw_root_uri=self._raw_fs.root_uri) + if kind is not None: + if kind.value == "manual_chapter": + manual += 1 + if self.read_raw_source(uri).status is SourceReadStatus.OK: + readable += 1 + else: + unresolved += 1 + continue + if self.store.is_wiki_uri(uri): + wiki += 1 + if self.read_resource(uri) is not None: + readable += 1 + else: + unresolved += 1 + continue + unresolved += 1 + return { + "reference_count": len(unique), + "readable_source_or_wiki_count": readable, + "manual_source_count": manual, + "wiki_reference_count": wiki, + "unresolved_reference_count": unresolved, + "coverage_percent": round((readable / len(unique)) * 100, 2) if unique else 0.0, + } + + opa_rows: list[dict[str, object]] = [] + for record in opas: + refs = [ + str(record.get("target_uri", "")), + *self._list_value(record.get("evidence_uris")), + *self._list_value(record.get("related_uris")), + ] + opa_rows.append( + { + "id": record.get("opa_id", ""), + "status": record.get("status", ""), + "category": record.get("category", ""), + "target_uri": record.get("target_uri", ""), + "coverage": coverage(refs), + }, + ) + ops_rows: list[dict[str, object]] = [] + for record in ops: + refs = [ + str(record.get("target_uri", "")), + *self._list_value(record.get("evidence_uris")), + *self._list_value(record.get("related_uris")), + *self._list_value(record.get("retrieval_used_uris")), + ] + ops_rows.append( + { + "id": record.get("ops_id", ""), + "status": record.get("status", ""), + "parent_opa": record.get("parent_opa", ""), + "coverage": coverage(refs), + }, + ) + opl_rows: list[dict[str, object]] = [] + for record in opls: + refs = [ + str(record.get("target_uri", "")), + *self._list_value(record.get("evidence_uris")), + *self._list_value(record.get("ops_uris")), + ] + opl_rows.append( + { + "id": record.get("opl_id", ""), + "status": record.get("status", ""), + "parent_opa": record.get("parent_opa", ""), + "coverage": coverage(refs), + }, + ) + + def aggregate(rows: list[dict[str, object]]) -> dict[str, object]: + covered = 0 + for row in rows: + coverage = row.get("coverage") + if isinstance(coverage, dict): + count = coverage.get("readable_source_or_wiki_count", 0) + if isinstance(count, (int, float)) and count > 0: + covered += 1 + return { + "total": len(rows), + "with_readable_source_or_wiki": covered, + "coverage_percent": round((covered / len(rows)) * 100, 2) if rows else 0.0, + } + + report: dict[str, object] = { + "version": 1, + "generated_at": datetime.now(UTC).isoformat(), + "counts": { + "opa": len(opas), + "ops": len(ops), + "opl": len(opls), + "opa_pending": sum(1 for row in opas if row.get("status") == "pending"), + "ops_unconfirmed": sum(1 for row in ops if row.get("status") == "unconfirmed"), + "ops_confirmed": sum(1 for row in ops if row.get("status") == "confirmed"), + "ops_rejected": sum(1 for row in ops if row.get("status") == "rejected"), + "ops_applied": sum(1 for row in ops if row.get("apply_status") == "applied"), + "opl_unconfirmed": sum(1 for row in opls if row.get("status") == "unconfirmed"), + }, + "coverage": { + "opa": aggregate(opa_rows), + "ops": aggregate(ops_rows), + "opl": aggregate(opl_rows), + }, + "records": {"opa": opa_rows, "ops": ops_rows, "opl": opl_rows}, + } + if persist: + self.store.write_json("index/op_flow_report.json", report) + return report + + def discover_opa( + self, + *, + profile: BuildProfile = "manual", + entity_uris: list[str] | None = None, + include_warnings: bool = False, + limit: int = 500, + ) -> dict[str, object]: + """Materialize stable pending OPAs from the complete audit. + + This is mechanical: audit remains the source of truth, while workers + may enrich or resolve the OPA later. Repeated discovery reuses + ``create_opa``'s stable identity and never bypasses finalize. + """ + if limit < 1 or limit > _MAX_DISCOVERY_LIMIT: + raise ValueError("discover_opa limit must be between 1 and 500") + issues: list[dict[str, object]] = [] + offset = 0 + while True: + report = self.audit_wiki( + profile=profile, + entity_uris=entity_uris, + offset=offset, + limit=limit, + ) + report_issues = report.get("issues", []) + if isinstance(report_issues, list): + issues.extend(dict(issue) for issue in report_issues if isinstance(issue, dict)) + next_offset_raw = report.get("next_offset", -1) + next_offset = ( + int(next_offset_raw) if isinstance(next_offset_raw, (int, float, str)) else -1 + ) + if next_offset < 0: + break + offset = next_offset + + # Discovery is append/reuse-only for the current build. It must not + # mutate or reconcile OPA records created by an earlier build. + + created: list[dict[str, object]] = [] + skipped: list[dict[str, str]] = [] + unclassified: list[dict[str, str]] = [] + repair_only_count = 0 + unclassified_count = 0 + skipped_low_value = 0 + # Cross-issue caches: avoid re-reading the same entity / raw source + # for every issue that targets the same URI. Without these caches + # discover_opa makes ~1500+ HTTP round-trips for a 500-issue audit. + entity_cache: dict[str, str | None] = {} + raw_cache: dict[str, SourceReadResult] = {} + + def _cached_read_entity(uri: str) -> str | None: + if uri not in entity_cache: + entity_cache[uri] = self.read_resource(uri) + return entity_cache[uri] + + def _cached_read_raw(uri: str) -> SourceReadResult: + if uri not in raw_cache: + raw_cache[uri] = self.read_raw_source(uri) + return raw_cache[uri] + + for issue in issues: + severity = str(issue.get("severity", "error")) + if severity != "error" and not include_warnings: + continue + target_uri = str(issue.get("uri", "")).strip() + if not self.store.is_wiki_uri(target_uri) or target_uri == self.store.root_uri + "/": + skipped.append( + { + "code": str(issue.get("code", "")), + "reason": "audit issue has no concrete wiki target", + }, + ) + continue + code = str(issue.get("code", "audit_issue")).strip() or "audit_issue" + concept = str(issue.get("concept", "")).strip() + if is_optional_relation_issue(code, concept): + skipped.append({"code": code, "reason": "optional relation under backbone policy"}) + continue + if _is_relation_gap_code(code): + skipped.append({"code": code, "reason": "relation gap excluded from OPA"}) + continue + message = str(issue.get("message", "")).strip() or code + disposition_value = str(issue.get("disposition", "")).strip() + reason_code = str(issue.get("opa_reason_code", "")).strip() + if not disposition_value: + policy = audit_issue_policy(code) + if policy is not None: + disposition_value = policy.disposition.value + reason_code = reason_code or policy.opa_reason_code + if disposition_value == IssueDisposition.REPAIR_ONLY.value: + # repair_only means the target concept is already materialized + # but a content/link gap remains. Convert to an open_gap OPA + # instead of silently skipping, so the finalize gate can see + # the tracked record and the limitation is explicitly recorded. + if not reason_code: + reason_code = "content_missing" + disposition_value = IssueDisposition.GAP.value + if ( + disposition_value + not in { + IssueDisposition.GAP.value, + IssueDisposition.CONFLICT.value, + } + or not reason_code + ): + unclassified_count += 1 + item = {"code": code, "reason": "audit issue has no OPA disposition"} + skipped.append(item) + unclassified.append(item) + continue + # Skip known low-value issue classes before materializing an OPA. + # The audit prompt already avoids these; enforce it in code so a + # prompt drift cannot flood the OPA ledger with noise. + target_section = self._opa_target_section(message, code) + root_prefix = self.store.root_uri.rstrip("/") + "/" + target_class = target_uri.removeprefix(root_prefix).split("/")[0] if target_uri else "" + is_low_value = ( + reason_code == "relation_missed" + or ( + target_section.startswith("frontmatter:") + and reason_code not in {"content_missing", "fact_conflict"} + ) + or ( + target_class in {"Procedure", "DTC", "Part"} + and reason_code in {"relation_missed", "extraction_missed"} + ) + ) + if is_low_value: + skipped_low_value += 1 + skipped.append({"code": code, "reason": "low-value audit issue"}) + continue + category = disposition_value + content = _cached_read_entity(target_uri) or "" + frontmatter = parse_frontmatter(content) + evidence = self._list_value(frontmatter.get("sources")) + evidence.extend(extract_source_uris(message)) + evidence = list( + dict[str, object].fromkeys( + uri for uri in evidence if _cached_read_raw(uri).status is SourceReadStatus.OK + ) + ) + # A missing/invalid source citation is itself an audit finding. Do + # not drop that finding merely because the page has no usable raw + # URI: bind the OPA to the concrete page as observation evidence, + # while keeping ``missing`` explicit so this is never mistaken for + # a source citation. + if not evidence: + unclassified_count += 1 + item = {"code": code, "reason": "OPA issue has no readable evidence URI"} + skipped.append(item) + unclassified.append(item) + continue + related = list( + dict[str, object].fromkeys( + uri + for uri in re.findall(re.escape(self.store.root_uri) + r"/[^\s)\]>]+", message) + if uri != target_uri + ) + ) + category_label = "内容缺失" if category == "gap" else "知识冲突" + issue_concept = str(issue.get("concept", "")).strip() + entity_name = target_uri.rstrip("/").rsplit("/", 1)[-1].removesuffix(".md") + if entity_name and entity_name not in issue_concept: + prefix = f"{category_label}: {entity_name}" + else: + prefix = f"{category_label}:" + title = f"{prefix} {issue_concept} {target_section}".strip() + result = self.create_opa( + title=title, + description=message, + category=category, + reason_code=reason_code, + target_uri=target_uri, + target_section=target_section, + source_chapter=evidence[0] if evidence else "", + evidence_uris=evidence, + finding=message, + missing=self._opa_missing_text(code, message), + recommendation=self._opa_recommendation(reason_code), + related_uris=related, + ) + created.append(result) + return { + "issues_scanned": len(issues), + "opa_count": len(created), + "reconciled_count": 0, + "profile": profile, + "repair_only_count": repair_only_count, + "unclassified_count": unclassified_count, + "skipped_low_value": skipped_low_value, + "created": created, + "skipped": skipped, + "unclassified": unclassified, + } + + @staticmethod + def _opa_target_section(message: str, code: str) -> str: + """Extract a stable section label from an audit issue.""" + match = re.search(r"section\s+['\"]([^'\"]+)['\"]", message, re.IGNORECASE) + return match.group(1).strip() if match is not None else code + + @staticmethod + def _opa_missing_text(code: str, message: str) -> str: + """Describe the unresolved fact without inventing a domain answer.""" + return message + + @staticmethod + def _opa_recommendation(reason_code: str) -> str: + """Provide a deterministic next action for the worker queue.""" + if reason_code == "fact_conflict": + return "保留冲突双方及证据,完成裁决前不得静默覆盖或移动实体。" + return "回读实体与 raw 来源补齐内容;原文确实缺失时保留 open_gap。" + + def resolve_opa( + self, + opa_id: str, + *, + solution: str, + evidence_uris: list[str] | None = None, + related_uris: list[str] | None = None, + closure_status: str = "deferred", + closure_reason: str = "", + ) -> dict[str, object]: + """Resolve an existing OPA while retaining its complete evidence chain. + + ``closure_status`` defaults to ``deferred``: resolving a record + without applying a fix is an honest deferral, not a closure. Callers + that actually applied the fix (e.g. ``apply_opa``) pass + ``closure_status="closed"``. + """ + key = self._find_opa_key(opa_id) + if key is None: + raise FileNotFoundError(f"OPA not found: {opa_id}") + content = self.store.read_text(key) + if content is None: + raise FileNotFoundError(f"OPA not found: {opa_id}") + frontmatter = self._record_from_content(content) + return self.create_opa( + opa_id=opa_id, + title=str(frontmatter.get("title", opa_id)), + description=str(frontmatter.get("description", "")), + category=str(frontmatter.get("category", "conflict")), + reason_code=str( + frontmatter.get( + "reason_code", + infer_opa_reason_code(str(frontmatter.get("category", "conflict"))), + ), + ), + scope=str(frontmatter.get("scope", "entity")), + subtype=str(frontmatter.get("subtype", "wiki_error")), + target_uri=str(frontmatter.get("target_uri", "")), + target_path=str(frontmatter.get("target_path", "")), + target_section=str(frontmatter.get("target_section", "")), + source_chapter=str(frontmatter.get("source_chapter", "")), + evidence_uris=evidence_uris, + status="resolved", + solution=solution, + finding=str(frontmatter.get("finding", "")), + missing=str(frontmatter.get("missing", "")), + recommendation=str(frontmatter.get("recommendation", "")), + related_uris=related_uris, + build_id=str(frontmatter.get("build_id", "")).strip(), + closure_status=closure_status, + closure_reason=closure_reason or str(frontmatter.get("closure_reason", "")), + ) + + def apply_opa( + self, + opa_id: str, + *, + concept: str, + class_name: str, + object_name: str, + content: str, + solution: str, + expected_sha256: str = "", + ) -> dict[str, object]: + """Apply a resolved candidate and close its OPA in one guarded call.""" + key = self._find_opa_key(opa_id) + if key is None: + raise FileNotFoundError(f"OPA not found: {opa_id}") + stored = self.store.read_text(key) + if stored is None: + raise FileNotFoundError(f"OPA not found: {opa_id}") + frontmatter = self._record_from_content(stored) + target_uri = str(frontmatter.get("target_uri", "")) + expected_uri = self.store.entity_uri(concept, class_name or None, object_name) + if target_uri and self.store.resolve_redirect(target_uri) != self.store.resolve_redirect( + expected_uri + ): + raise ValueError( + f"OPA target does not match merge target: {target_uri} != {expected_uri}", + ) + merge = self.merge_entity( + concept, + class_name, + object_name, + stored, + expected_sha256=expected_sha256, + ) + resolved = self.resolve_opa( + opa_id, + solution=solution, + evidence_uris=[expected_uri], + closure_status="closed", + closure_reason="applied via merge_entity", + ) + return {"entity_uri": merge, "opa": resolved} + + def refine_opa_reason_code( + self, + opa_id: str, + *, + reason_code: str, + closure_status: str = "", + closure_reason: str = "", + ) -> dict[str, object]: + """Refine an OPA's reason_code after retrieval-based triage (called by OPS). + + OPA are created with a coarse reason (``content_missing`` / + ``fact_conflict``) because at audit time the source has not yet been + retrieved. After OPS runs retrieval it can classify the gap precisely + into one of the fine codes (``source_incomplete`` / ``extraction_missed`` + / ``relation_missed`` / ``param_unhosted`` / ``manual_error`` / + ``process_conflict`` / ``hallucination``), each of which maps to a + distinct repair path. This rewrites the OPA in place preserving all + prior evidence. + """ + if reason_code not in OPA_REASON_CODES: + raise ValueError( + f"Unsupported reason_code: {reason_code!r}; " + f"expected one of {', '.join(OPA_REASON_CODES)}", + ) + key = self._find_opa_key(opa_id) + if key is None: + raise FileNotFoundError(f"OPA not found: {opa_id}") + content = self.store.read_text(key) + if content is None: + raise FileNotFoundError(f"OPA not found: {opa_id}") + frontmatter = self._record_from_content(content) + effective_closure = closure_status or str(frontmatter.get("closure_status", "")) + if effective_closure and effective_closure not in OPA_CLOSURE_STATUSES: + raise ValueError(f"Unsupported closure_status: {effective_closure!r}") + return self.create_opa( + opa_id=opa_id, + title=str(frontmatter.get("title", opa_id)), + description=str(frontmatter.get("description", "")), + category=str(frontmatter.get("category", "conflict")), + reason_code=reason_code, + scope=str(frontmatter.get("scope", "entity")), + subtype=str(frontmatter.get("subtype", "wiki_error")), + target_uri=str(frontmatter.get("target_uri", "")), + target_path=str(frontmatter.get("target_path", "")), + target_section=str(frontmatter.get("target_section", "")), + source_chapter=str(frontmatter.get("source_chapter", "")), + evidence_uris=self._list_value(frontmatter.get("evidence_uris")), + status=str(frontmatter.get("status", "pending")), + solution=str(frontmatter.get("solution", "")), + finding=str(frontmatter.get("finding", "")), + missing=str(frontmatter.get("missing", "")), + recommendation=str(frontmatter.get("recommendation", "")), + related_uris=self._list_value(frontmatter.get("related_uris")), + build_id=str(frontmatter.get("build_id", "")).strip(), + closure_status=effective_closure + or str(frontmatter.get("closure_status", "open")) + or "open", + closure_reason=closure_reason or str(frontmatter.get("closure_reason", "")), + ) + + def get_opas( + self, + *, + target_uri: str = "", + status: str = "", + category: str = "", + reason_code: str = "", + scope: str = "", + source_chapter: str = "", + limit: int = 50, + include_superseded: bool = False, + build_id: str = "", + ) -> list[dict[str, object]]: + """读取 ``OP/OpA/`` 下匹配条件的 OPA 记录。. + + 可按 ``target_uri`` / ``status`` / ``category`` / ``reason_code`` / ``scope`` 过滤, + 返回结构化 frontmatter(含 ``uri`` 键,供证据链提取)。 + """ + out: list[dict[str, object]] = [] + for path, content in self._opa_files(target_uri=target_uri): + fm = self._record_from_content(content) + opa_id = str(fm.get("id", Path(path).stem)) + if not include_superseded and str(fm.get("status", "")).strip().lower() in { + "superseded", + "rejected", + }: + continue + if target_uri and str(fm.get("target_uri", "")) != target_uri: + continue + if status and str(fm.get("status", "")) != status: + continue + if category and str(fm.get("category", "")) != category: + continue + effective_reason_code = str( + fm.get("reason_code", infer_opa_reason_code(str(fm.get("category", "")))), + ) + if reason_code and effective_reason_code != reason_code: + continue + if scope and str(fm.get("scope", "")) != scope: + continue + if source_chapter and str(fm.get("source_chapter", "")) != source_chapter: + continue + if build_id and not self._opa_matches_build(path, fm, build_id): + continue + out.append( + { + "opa_id": opa_id, + "path": path, + **{ + key: fm.get(key, "") + for key in ( + "title", + "human_key", + "description", + "category", + "reason_code", + "scope", + "subtype", + "target_uri", + "target_path", + "target_section", + "source_chapter", + "status", + "solution", + "finding", + "missing", + "recommendation", + "report_count", + "dedupe_key", + "build_id", + "closure_status", + "closure_reason", + ) + }, + "evidence_uris": self._list_value(fm.get("evidence_uris")), + "related_uris": self._list_value(fm.get("related_uris")), + "uri": self._opa_uri(opa_id, self._opa_category_from_key(path)), + }, + ) + if len(out) >= max(limit, 1): + break + return out + + def get_expert_authority( + self, *, target_uri: str = "", limit: int = 50 + ) -> list[dict[str, object]]: + """Inspect sections protected by confirmed or applied expert knowledge. + + Expert authority comes from confirmed OPS recommendations and + applied OPL proposals persisted under ``OP/OpA/`` — the same + records OPA/OPS/OPL tickets are built from. + """ + authority: list[dict[str, object]] = [] + for path, content in self._op_files("OPL", target_uri=target_uri): + fm = parse_frontmatter(content) + if target_uri and str(fm.get("target_uri", "")) != target_uri: + continue + if str(fm.get("apply_status", "not_applied")) != "applied": + continue + opl_id = str(fm.get("id", Path(path).stem)) + authority.append( + { + "source": "opl", + "uri": self._op_uri("OPL", opl_id), + "target_uri": str(fm.get("target_uri", "")), + "target_section": str(fm.get("target_section", "")), + "status": str(fm.get("status", "")), + "apply_status": "applied", + "applied_at": str(fm.get("applied_at", "")), + "expert_id": str(fm.get("expert_id", "")), + }, + ) + if len(authority) >= max(1, limit): + return authority + confirmed = self.get_ops(status="confirmed", target_uri=target_uri, limit=limit) + authority.extend( + { + "source": "ops", + "uri": str(record.get("uri", "")), + "target_uri": str(record.get("target_uri", "")), + "target_section": str(record.get("target_section", "")), + "status": "confirmed", + "apply_status": str(record.get("apply_status", "")), + "applied_at": str(record.get("applied_at", "")), + "expert_id": str(record.get("expert_id", "")), + } + for record in confirmed + ) + return authority[: max(1, limit)] + + def get_wiki_change_events( + self, *, target_uri: str = "", limit: int = 50 + ) -> list[dict[str, object]]: + """Consume durable apply events from applied OPL proposals.""" + events: list[dict[str, object]] = [] + for path, content in self._op_files("OPL", target_uri=target_uri): + fm = parse_frontmatter(content) + if target_uri and str(fm.get("target_uri", "")) != target_uri: + continue + if str(fm.get("apply_status", "")) != "applied": + continue + opl_id = str(fm.get("id", Path(path).stem)) + events.append( + { + "event": "opl_applied", + "uri": self._op_uri("OPL", opl_id), + "target_uri": str(fm.get("target_uri", "")), + "applied_at": str(fm.get("applied_at", "")), + "expert_id": str(fm.get("expert_id", "")), + }, + ) + if len(events) >= max(1, limit): + break + return events diff --git a/src/wolfharness/capabilities/wiki/tools.py b/src/wolfharness/capabilities/wiki/tools.py new file mode 100644 index 000000000..2c9e3932f --- /dev/null +++ b/src/wolfharness/capabilities/wiki/tools.py @@ -0,0 +1,582 @@ +"""Tool functions for the WikiBuildCapability. + +Follows the same pattern as ``viking/tools.py``: a ``build_tools(cap)`` +function returns a list of async tool closures that capture the +capability instance. The capability class (``WikiBuildCapability``) +delegates to this module in ``get_toolset()``. + +Two categories of tools: + +1. **Method wrappers** — public ``WikiBuildTools`` methods wrapped as + async closures via ``_build_method_wrappers``. Filtered by the + role matrix (``ROLE_TOOLS``). +2. **Helper tools** — in-process navigation helpers (browse, read + chapter map, etc.) toggled by ``include_helpers``. + +External OP capabilities (OPA/OPS/OPL as tickets, the +``wiki_external_expert`` role) now live in :mod:`wolfharness.capabilities.wiki.tickets.ticket`; +this module routes that role to ``ticket.build_ticket_tools``. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +import inspect +from typing import TYPE_CHECKING, Any, cast, get_type_hints + +from pydantic_ai.tools import RunContext # runtime: needed by create_modified_signature +from pydantic_ai.toolsets import AbstractToolset, FilteredToolset + +from wolfharness.log import get_logger + + +if TYPE_CHECKING: + from wolfharness.capabilities.wiki.build import WikiBuildCapability + +logger = get_logger(__name__) + +# --------------------------------------------------------------------------- +# Tool inventory +# --------------------------------------------------------------------------- + +ALL_WIKI_TOOLS: frozenset[str] = frozenset( + { + "read_chapter", + "read_chapters_batch", + "write_entity", + "write_entities_batch", + "write_symptom_profile", + "patch_symptom_profile", + "diff_symptom_profile", + "patch_entity", + "patch_entities_batch", + "diff_entity", + "merge_entity", + "delete_entity", + "move_entity", + "plan_component_classification", + "get_bom_taxonomy", + "register_bom_component", + "register_bom_identity_batch", + "bom_enrichment_status", + "plan_bom_enrichment", + "register_model_mapping", + "get_model_mappings", + "model_mapping_report", + "create_subdir", + "wiki_read_resource", + "entity_uri", + "list_children", + "get_backlinks", + "get_related_resources", + "find_wiki", + "grep_wiki", + "audit_wiki", + "rebuild_backlinks", + "rebuild_all_backlinks", + "finalize_wiki", + "build_change_report", + "inspect_wiki_state", + "inspect_build_checkpoint", + "recover_build", + "checkpoint_build", + "plan_chapter_work", + "record_source_packet", + "plan_materialization_work", + "materialize_template_batch", + "record_materialization_receipt", + "plan_relation_work", + "plan_relation_shards", + "register_no_entity_chapters", + "auto_repair", + "get_schema", + "create_opa", + "create_ops", + "get_ops", + "get_opls", + "get_expert_authority", + "get_wiki_change_events", + "op_flow_status", + "op_flow_report", + "discover_opa", + "get_opas", + "resolve_opa", + "apply_opa", + "refine_opa_reason_code", + "ops_dispatch_plan", + "browse_chapters", + "browse", + "migrate_source_uri_references", + "sync_device_system_chapters", + }, +) + +_HELPER_TOOL_NAMES: frozenset[str] = frozenset( + { + "browse_chapters", + "browse", + }, +) + +# --------------------------------------------------------------------------- +# Role matrix +# --------------------------------------------------------------------------- + +_READ_TOOLS: frozenset[str] = frozenset( + { + "list_chapters", + "browse_chapters", + "browse", + "read_chapter", + "read_chapters_batch", + "wiki_read_resource", + "entity_uri", + "list_children", + "get_related_resources", + "find_wiki", + "grep_wiki", + "get_schema", + "inspect_wiki_state", + }, +) + +# Extraction is deliberately chapter-granular. ``read_chapters_batch`` is +# retained in the general read set for conductor/backward-compatible callers, +# but exposing it to an extraction worker lets a small task expand into an +# unbounded model context before the worker can persist a packet. +_EXTRACTION_READ_TOOLS: frozenset[str] = _READ_TOOLS - frozenset({"read_chapters_batch"}) + +_WRITE_TOOLS: frozenset[str] = frozenset( + { + "write_entity", + "write_entities_batch", + "write_symptom_profile", + "patch_entity", + "patch_entities_batch", + "patch_symptom_profile", + "merge_entity", + }, +) + +_DIFF_TOOLS: frozenset[str] = frozenset({"diff_entity", "diff_symptom_profile"}) + +_FILE_OP_TOOLS: frozenset[str] = frozenset( + { + "delete_entity", + "move_entity", + "plan_component_classification", + "create_subdir", + "migrate_source_uri_references", + "sync_device_system_chapters", + }, +) + +_FINALIZE_TOOLS: frozenset[str] = frozenset( + { + "rebuild_backlinks", + "rebuild_all_backlinks", + "finalize_wiki", + "build_change_report", + }, +) + +_LIFECYCLE_TOOLS: frozenset[str] = frozenset( + { + "inspect_wiki_state", + "inspect_build_checkpoint", + "recover_build", + "audit_wiki", + "auto_repair", + "build_change_report", + }, +) + +_OPA_READ_TOOLS: frozenset[str] = frozenset({"get_opas"}) +_OPA_WRITE_TOOLS: frozenset[str] = frozenset({"create_opa"}) +_OPS_READ_TOOLS: frozenset[str] = frozenset({"get_ops"}) +_OPS_WRITE_TOOLS: frozenset[str] = frozenset({"create_ops"}) +_OPL_READ_TOOLS: frozenset[str] = frozenset({"get_opls"}) +_OP_AUTHORITY_READ_TOOLS: frozenset[str] = frozenset( + {"get_expert_authority", "get_wiki_change_events"}, +) +_OP_FLOW_READ_TOOLS: frozenset[str] = frozenset({"op_flow_status", "op_flow_report"}) +_OPA_DISCOVERY_TOOLS: frozenset[str] = frozenset({"discover_opa"}) +_OPA_RESOLVE_TOOLS: frozenset[str] = frozenset({"resolve_opa"}) +_OPA_REFINE_TOOLS: frozenset[str] = frozenset({"refine_opa_reason_code"}) +_OPA_APPLY_TOOLS: frozenset[str] = frozenset({"apply_opa"}) +_OPS_DISPATCH_TOOLS: frozenset[str] = frozenset({"ops_dispatch_plan"}) +# OPS resolver closes relation_missed gaps only; materialization stays with file_operator. +_OPS_RESOLVE_WRITE_TOOLS: frozenset[str] = frozenset({"patch_entity", "rebuild_backlinks"}) + +ROLE_TOOLS: dict[str, frozenset[str]] = { + "wiki_conductor": ( + _READ_TOOLS + | _LIFECYCLE_TOOLS + | _FINALIZE_TOOLS + | _OPA_READ_TOOLS + | _OPA_DISCOVERY_TOOLS + | _OPS_READ_TOOLS + | _OPL_READ_TOOLS + | _OP_AUTHORITY_READ_TOOLS + | _OP_FLOW_READ_TOOLS + | _OPS_DISPATCH_TOOLS + | frozenset( + { + "get_bom_taxonomy", + "register_bom_component", + "register_bom_identity_batch", + "bom_enrichment_status", + "plan_bom_enrichment", + "register_model_mapping", + "get_model_mappings", + "model_mapping_report", + "record_source_packet", + "plan_materialization_work", + "materialize_template_batch", + "record_materialization_receipt", + "plan_relation_work", + "plan_relation_shards", + "register_no_entity_chapters", + "diff_entity", + "checkpoint_build", + "plan_chapter_work", + "migrate_source_uri_references", + "sync_device_system_chapters", + }, + ) + ), + "wiki_extraction_worker": ( + _EXTRACTION_READ_TOOLS + | _WRITE_TOOLS + | _DIFF_TOOLS + | _OPA_WRITE_TOOLS + | _OPA_READ_TOOLS + | _OPS_READ_TOOLS + | _OPL_READ_TOOLS + | _OP_AUTHORITY_READ_TOOLS + | _OP_FLOW_READ_TOOLS + | frozenset({"record_source_packet", "record_materialization_receipt"}) + | frozenset({"get_model_mappings", "model_mapping_report"}) + ), + "wiki_relation_worker": ( + _READ_TOOLS + | _DIFF_TOOLS + | _OPA_WRITE_TOOLS + | _OPA_READ_TOOLS + | _OP_AUTHORITY_READ_TOOLS + | frozenset( + { + "get_backlinks", + "patch_entity", + "patch_symptom_profile", + }, + ) + ), + "wiki_opa_worker": ( + _READ_TOOLS + | _OPA_WRITE_TOOLS + | _OPA_READ_TOOLS + | _OPS_READ_TOOLS + | _OPL_READ_TOOLS + | _OP_AUTHORITY_READ_TOOLS + ), + "wiki_ops_worker": ( + _READ_TOOLS + | _OPA_READ_TOOLS + | _OPS_READ_TOOLS + | _OPS_WRITE_TOOLS + | _OPL_READ_TOOLS + | _OP_AUTHORITY_READ_TOOLS + | _OPA_REFINE_TOOLS + | _OPA_RESOLVE_TOOLS + | _OPS_RESOLVE_WRITE_TOOLS + ), + "wiki_file_operator": ( + frozenset( + { + "list_chapters", + "wiki_read_resource", + "entity_uri", + "list_children", + "get_related_resources", + "find_wiki", + "grep_wiki", + "get_schema", + "inspect_wiki_state", + }, + ) + | _WRITE_TOOLS + | _DIFF_TOOLS + | _FILE_OP_TOOLS + | _OPA_READ_TOOLS + | _OPA_WRITE_TOOLS + | _OPA_RESOLVE_TOOLS + | _OPA_APPLY_TOOLS + | _OP_AUTHORITY_READ_TOOLS + | frozenset( + { + "auto_repair", + "get_bom_taxonomy", + "register_no_entity_chapters", + }, + ) + ), + "wiki_external_expert": ( + frozenset( + { + "browse", + "wiki_read_resource", + "find_wiki", + "get_schema", + "get_opas", + "get_ops", + "get_opls", + "get_expert_authority", + "get_wiki_change_events", + "diff_entity", + "plan_entity_move", + }, + ) + ), +} + +WIKI_AGENT_ROLES: tuple[str, ...] = ( + "wiki_conductor", + "wiki_extraction_worker", + "wiki_relation_worker", + "wiki_file_operator", + "wiki_opa_worker", + "wiki_ops_worker", + "wiki_external_expert", +) + +_REQUIRED_ROLE_TOOLS: dict[str, frozenset[str]] = { + "wiki_relation_worker": frozenset({"patch_entity", "find_wiki", "get_related_resources"}), +} + + +# --------------------------------------------------------------------------- +# RoleFilter +# --------------------------------------------------------------------------- + + +class RoleFilter: + """Role-aware wiki tool permission enforcement.""" + + def __init__(self, role: str = "wiki_conductor") -> None: + if role not in WIKI_AGENT_ROLES: + raise ValueError( + f"Unknown wiki agent role '{role}'. Must be one of: {', '.join(WIKI_AGENT_ROLES)}", + ) + self._role = role + self._allowed_tools = ROLE_TOOLS.get(role, frozenset()) + + @property + def role(self) -> str: + return self._role + + @property + def allowed_tools(self) -> frozenset[str]: + return self._allowed_tools + + @staticmethod + def _wiki_tool_name(tool_name: str) -> str | None: + if tool_name in ALL_WIKI_TOOLS: + return tool_name + matches = [candidate for candidate in ALL_WIKI_TOOLS if tool_name.endswith(f"_{candidate}")] + return max(matches, key=len) if matches else None + + def allows_tool(self, tool_name: str) -> bool: + wiki_tool_name = self._wiki_tool_name(tool_name) + return wiki_tool_name is None or wiki_tool_name in self._allowed_tools + + def get_wrapper_toolset( + self, + toolset: AbstractToolset[Any], + ) -> AbstractToolset[Any]: + return FilteredToolset( + wrapped=toolset, + filter_func=lambda _ctx, tool_def: self.allows_tool(tool_def.name), + ) + + +# --------------------------------------------------------------------------- +# Method wrapper builder (for WikiBuildTools methods) +# --------------------------------------------------------------------------- + + +# Tool name → bound method name. Some capability methods must keep their +# ``ResourceAccess`` protocol name (e.g. ``read_resource``); the agent-facing +# tool name is prefixed to avoid clashing with the generic ``resource_access`` +# toolset that a native agent may also host. +_TOOL_NAME_BY_METHOD_NAME: dict[str, str] = { + "wiki_read_resource": "read_resource", +} + + +def _build_method_wrappers( + tools: Any, + *, + tool_names: frozenset[str], + include_helpers: bool = True, +) -> list[Callable[..., Any]]: + """Wrap public ``WikiBuildTools`` methods as async tool fns. + + Each wrapper keeps an explicit ``ctx: RunContext`` first parameter so + wolfharness's tool wrapping recognises it and injects the run context + at call time. The wrapper's ``__signature__`` is rebuilt from the + bound method's own signature (``self`` stripped) with ``ctx`` injected + first. + """ + from wolfharness.utils.signatures import ( # lazy: avoids import at module load + create_modified_signature, + update_signature, + ) + + excluded = _HELPER_TOOL_NAMES if not include_helpers else frozenset() + names = sorted(tool_names - excluded) + tool_fns: list[Callable[..., Any]] = [] + + for name in names: + method_name = _TOOL_NAME_BY_METHOD_NAME.get(name, name) + attr: object = getattr(tools, method_name, None) + if attr is None or not callable(attr): + continue + method: Callable[..., Any] = cast(Callable[..., Any], attr) + if inspect.iscoroutinefunction(method) or inspect.isasyncgenfunction(method): + + async def _async_wrapper( + ctx: RunContext[Any], + *args: Any, + _m: Callable[..., Any] = method, + **kwargs: Any, + ) -> Any: + return await _m(*args, **kwargs) + + wrapper: Callable[..., Any] = _async_wrapper + else: + + async def _sync_wrapper( + ctx: RunContext[Any], + *args: Any, + _m: Callable[..., Any] = method, + **kwargs: Any, + ) -> Any: + return await asyncio.to_thread(_m, *args, **kwargs) + + wrapper = _sync_wrapper + + wrapper.__name__ = name + wrapper.__qualname__ = name + wrapper.__doc__ = inspect.getdoc(method) + new_sig = create_modified_signature( + inspect.signature(method), + inject={"ctx": RunContext[Any]}, + ) + try: + resolved_hints = get_type_hints(method, include_extras=True) + except (NameError, TypeError): + logger.warning("Could not resolve annotations for wiki tool %s", name) + resolved_hints = {} + resolved_parameters = [ + parameter.replace( + annotation=( + RunContext[Any] + if parameter.name == "ctx" + else resolved_hints.get(parameter.name, Any) + ), + ) + for parameter in new_sig.parameters.values() + ] + new_sig = new_sig.replace( + parameters=resolved_parameters, + return_annotation=resolved_hints.get("return", Any), + ) + update_signature(wrapper, new_sig) + wrapper.__annotations__ = { + parameter.name: parameter.annotation for parameter in new_sig.parameters.values() + } | {"return": new_sig.return_annotation} + tool_fns.append(wrapper) + return tool_fns + + +# --------------------------------------------------------------------------- +# Main entry point — like tools.py's build_tools(cap) +# --------------------------------------------------------------------------- + + +def build_tools(cap: WikiBuildCapability) -> list[Callable[..., Any]]: + """Build the list of tool functions for the WikiBuildCapability. + + Dispatches based on ``cap._config.role``: + + - ``wiki_external_expert`` → the ticket tools from + :mod:`wolfharness.capabilities.wiki.tickets.ticket` + (``build_ticket_tools``). + - Any other role (or no role) → method wrappers from + ``WikiBuildTools``. + + Args: + cap: The ``WikiBuildCapability`` instance that owns these tools. + + Returns: + A list of async tool functions suitable for ``FunctionToolset``. + """ + cap._ensure_tools() + tools = cap.tools + if tools is None: + return [] + + role = cap.config.role + + # External expert role: read method-wrappers + ticket tools. + if role == "wiki_external_expert": + from wolfharness.capabilities.wiki.tickets.ticket import build_ticket_tools + + read_names = ROLE_TOOLS["wiki_external_expert"] + if cap.config.tool_names: + # tool_names 白名单收窄只读工具集, ticket 写工具始终保留。 + read_names = read_names & frozenset(cap.config.tool_names) + read_tools = _build_method_wrappers( + tools, + tool_names=read_names, + include_helpers=True, + ) + return read_tools + build_ticket_tools(cap) + + # Standard roles: method wrappers filtered by role. + allowed = ALL_WIKI_TOOLS + if cap.config.tool_names: + allowed = frozenset(cap.config.tool_names) + + tool_fns = _build_method_wrappers( + tools, + tool_names=allowed, + include_helpers=cap.config.include_helpers, + ) + + available = {getattr(fn, "__name__", "") for fn in tool_fns} + missing = sorted(_REQUIRED_ROLE_TOOLS.get(role or "", frozenset()) - available) + if missing: + raise RuntimeError( + f"Wiki build capability for role {role!r} is incomplete; " + f"missing required tools: {', '.join(missing)}", + ) + + return tool_fns + + +def get_instructions(role: str | None) -> str | None: + """Return guidance for the agent on available tools. + + For the external expert role, returns the ticket flow instructions + from :mod:`wolfharness.capabilities.wiki.tickets.ticket`. For all other + roles, returns ``None`` (instructions come from the agent's system + prompt). + """ + if role == "wiki_external_expert": + from wolfharness.capabilities.wiki.tickets.ticket import get_ticket_instructions + + return get_ticket_instructions() + return None diff --git a/src/wolfharness/capabilities/wiki/validation.py b/src/wolfharness/capabilities/wiki/validation.py new file mode 100644 index 000000000..9ae971e14 --- /dev/null +++ b/src/wolfharness/capabilities/wiki/validation.py @@ -0,0 +1,105 @@ +"""Shared validation harness used by capabilities and promotion gates.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from wolfharness.capabilities.wiki.hooks.body_sections import BodySectionsHook +from wolfharness.capabilities.wiki.hooks.component_taxonomy import ComponentTaxonomyHook +from wolfharness.capabilities.wiki.hooks.diagnostic_closure import DiagnosticClosureHook +from wolfharness.capabilities.wiki.hooks.directory_structure import DirectoryStructureHook +from wolfharness.capabilities.wiki.hooks.frontmatter_schema import FrontmatterSchemaHook +from wolfharness.capabilities.wiki.hooks.reference_integrity import ReferenceIntegrityHook +from wolfharness.capabilities.wiki.hooks.relationship_completeness import ( + RelationshipCompletenessHook, +) +from wolfharness.capabilities.wiki.hooks.uri_integrity import URIIntegrityHook + + +if TYPE_CHECKING: + from collections.abc import Sequence + + from wolfharness.capabilities.wiki.hooks.base import BaseHook, HookResult + + +ENTITY_VALIDATION_HOOKS: tuple[BaseHook, ...] = ( + DirectoryStructureHook(), + BodySectionsHook(), + ComponentTaxonomyHook(), + FrontmatterSchemaHook(), + URIIntegrityHook(), + ReferenceIntegrityHook(), + RelationshipCompletenessHook(), + DiagnosticClosureHook(), +) + +# Hooks excluded from formal-write validation on confirmed/machine-validated +# content. Both the MCP server and the capability layer use this set so +# the write gate is consistent across layers. +FORMAL_WRITE_EXCLUDED_HOOKS: frozenset[str] = frozenset( + {"directory_structure", "body_sections", "diagnostic_closure"}, +) + + +def run_entity_validation( + *, + content: str, + concept: str, + class_name: str, + object_name: str, + hooks: Sequence[BaseHook] = ENTITY_VALIDATION_HOOKS, +) -> list[HookResult]: + """Run the deterministic validation chain.""" + return [ + hook.check( + content=content, + concept=concept, + class_name=class_name, + object_name=object_name, + ) + for hook in hooks + ] + + +def validation_feedback(results: Sequence[HookResult]) -> tuple[str, bool]: + """Format failed hook results and indicate whether errors are present.""" + failures = [result for result in results if not result.passed] + if not failures: + return "", False + + errors = [result for result in failures if result.severity == "error"] + warnings = [result for result in failures if result.severity != "error"] + lines: list[str] = [] + if errors: + lines.append(f"❌ {len(errors)} error(s) (write blocked):") + lines.extend( + f" [{result.severity}] {result.hook_name}: {result.message}" for result in errors + ) + if warnings: + lines.append(f"⚠ {len(warnings)} warning(s):") + lines.extend( + f" [{result.severity}] {result.hook_name}: {result.message}" for result in warnings + ) + return "\n".join(lines), bool(errors) + + +def require_valid_entity( + *, + content: str, + concept: str, + class_name: str, + object_name: str, + hooks: Sequence[BaseHook] = ENTITY_VALIDATION_HOOKS, +) -> None: + """Raise before a formal write when validation reports structural errors.""" + feedback, has_errors = validation_feedback( + run_entity_validation( + content=content, + concept=concept, + class_name=class_name, + object_name=object_name, + hooks=hooks, + ), + ) + if has_errors: + raise ValueError(f"Entity validation failed before formal write:\n{feedback}") diff --git a/src/wolfharness/capabilities/wiki/wiki_build_deps.py b/src/wolfharness/capabilities/wiki/wiki_build_deps.py new file mode 100644 index 000000000..61b74485e --- /dev/null +++ b/src/wolfharness/capabilities/wiki/wiki_build_deps.py @@ -0,0 +1,140 @@ +"""Shared dependency contract for WikiBuildTools mixins. + +The ``WikiBuildTools`` implementation behind the ``build_tools`` facade is split across several +mixins (:mod:`text_parsers`, :mod:`opa`, :mod:`drafts`, ...) purely for +file organization. Because the mixins call methods on one another through +``self`` at runtime, pyright needs the cross-mixin surface declared once. + +``WikiBuildDeps`` captures that contract: the shared instance fields plus +every method one mixin calls on a sibling. Each mixin subclasses +``WikiBuildDeps`` so pyright can resolve ``self.<sibling_method>()`` calls +within it. At runtime the real implementations come from composition into +``WikiBuildTools``. + +Methods are declared with an explicit ``self`` parameter as the callable +contract, matching how mixins invoke them (``self.method(...)``). The +concrete implementations may be instance methods, ``@staticmethod``, or +``@classmethod``; pyright treats a ``self``-style declaration as the common +callable shape. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol + + +if TYPE_CHECKING: + from pathlib import Path + + from wolfharness.capabilities.build.build_logger import WikiBuildLogger + from wolfharness.capabilities.storage.backend import FSBackend + from wolfharness.capabilities.storage.storage import WikiStore + from wolfharness.capabilities.wiki.quality import ( + BuildProfile, + SourceReadResult, + WikiAuditReport, + ) + + +class WikiBuildDeps(Protocol): + """Contract a WikiBuildTools mixin relies on from its siblings.""" + + store: WikiStore + _log: WikiBuildLogger | None + _audit_cache: Any + _case_root: Path | None + _faultannotated_root: Path | None + _raw_fs: FSBackend + _bom_fs: FSBackend | None + + # -- service skeleton (fields + early helpers) ------------------------- + def _invalidate_audit_cache(self) -> None: ... + def _reject_phantom_body_refs( + self, + content: str, + *, + extra_known: set[str] | None = None, + ) -> None: ... + + # -- TextParsersMixin / remaining pieces -------------------------------- + def _apply_operations(self, content: str, operations: list[dict]) -> str: ... + def read_resource(self, uri: str, line_numbers: bool = False) -> str | None: ... + def read_raw_source(self, uri: str) -> SourceReadResult: ... + def find_wiki( + self, + query: str, + *, + target_uri: str = "", + limit: int = 10, + deep: bool = False, + ) -> list[dict]: ... + def _sync_component_narrative_links( + self, + component_uri: str, + *, + fault_links: list[tuple[str, str]] | None = None, + procedure_links: list[tuple[str, str]] | None = None, + ) -> None: ... + def write_symptom_profile( + self, + symptom_uri: str, + profile_id: str, + content: str, + *, + expected_sha256: str = "", + ) -> str: ... + def merge_entity( + self, + concept: str, + class_name: str, + object_name: str, + content: str, + *, + expected_sha256: str = "", + ) -> str: ... + def list_symptom_profiles(self, symptom_uri: str) -> list[dict[str, str]]: ... + def trace_diagnostic_path( + self, + device_uri: str = "", + symptom_uri: str = "", + limit: int = 100, + ) -> dict[str, object]: ... + def diff_symptom_profile( + self, + symptom_uri: str, + profile_id: str, + candidate_content: str, + ) -> dict[str, object]: ... + def patch_symptom_profile( + self, + symptom_uri: str, + profile_id: str, + operations: list[dict], + *, + expected_sha256: str = "", + ) -> str: ... + + # -- AuditMixin / OPAMixin cross-contract ----------------------------- + def audit_wiki( + self, + *, + concept: str = "", + code: str = "", + offset: int = 0, + limit: int = 100, + profile: BuildProfile = "manual", + entity_uris: list[str] | None = None, + force_refresh: bool = False, + ) -> WikiAuditReport: ... + def get_opas( + self, + *, + target_uri: str = "", + status: str = "", + category: str = "", + reason_code: str = "", + scope: str = "", + source_chapter: str = "", + limit: int = 50, + ) -> list[dict]: ... + def _is_explicit_tracked_record(self, record: dict) -> bool: ... diff --git a/src/wolfharness/capabilities/wiki/wiki_build_tools.py b/src/wolfharness/capabilities/wiki/wiki_build_tools.py new file mode 100644 index 000000000..8a558096c --- /dev/null +++ b/src/wolfharness/capabilities/wiki/wiki_build_tools.py @@ -0,0 +1,810 @@ +"""WikiBuildTools — composition root for wiki build tools. + +Split from the original mcp_server.py into mixins. This module defines the +core class composing all mixins and containing init/lifecycle, hashing, +validation, and doc-prefix methods. +""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from datetime import UTC, datetime +from hashlib import sha256 +import logging +import os +from pathlib import Path +import re +from threading import RLock +import time +from urllib.parse import unquote, urlsplit + +from httpx import HTTPError +from openviking_sdk.errors import OpenVikingError + +from wolfharness.capabilities.wiki.auto_repair import batch_auto_repair +from wolfharness.capabilities.wiki.quality import ( + BuildProfile, + RawSourceKind, + SourceReadResult, + SourceReadStatus, + WikiAuditReport, + classify_raw_source_uri, + entity_status, + extract_malformed_wiki_uris, + extract_source_uris, + extract_wiki_uris, + parse_frontmatter, + set_raw_source_root_uri, +) +from wolfharness.capabilities.wiki.schema_loader import get_concept_schema, get_schema_version +from wolfharness.capabilities.wiki.storage import ( + FSBackend, + create_raw_reader, + create_wiki_store, + viking_read, +) +from wolfharness.capabilities.wiki.validation import ( + require_valid_entity, +) + +from .io.audit import AuditCache, AuditMixin +from .io.children import ChildrenMixin +from .io.model_mapping import ModelMappingMixin +from .io.text_parsers import TextParsersMixin +from .tickets.opa import OPAMixin + + +logger = logging.getLogger(__name__) + +from typing import TYPE_CHECKING + +from ._helpers import _FORMAL_WRITE_HOOKS, _io_worker_limit +from .entities.entities import EntityWriteMixin +from .entities.finalize import FinalizeMixin +from .entities.packets import PacketMixin +from .entities.patches import PatchMixin +from .io.migration import MigrationMixin +from .planning.bom import BomMixin +from .planning.chapters import ChapterMixin +from .planning.materialization import MaterializationMixin +from .planning.relations import RelationMixin + + +if TYPE_CHECKING: + from collections.abc import Callable + + from wolfharness.capabilities.wiki.build_logger import WikiBuildLogger + + +class WikiBuildTools( + # New mixins first to avoid WikiBuildDeps Protocol shadowing in MRO + PacketMixin, + MaterializationMixin, + ChapterMixin, + RelationMixin, + EntityWriteMixin, + PatchMixin, + BomMixin, + MigrationMixin, + FinalizeMixin, + # Existing mixins (inherit from WikiBuildDeps Protocol) + ModelMappingMixin, + AuditMixin, + ChildrenMixin, + OPAMixin, + TextParsersMixin, +): + """All wiki build I/O operations as callable methods. + + One instance owns a ``WikiStore`` (write target) and a library root + (chapter source). Every method is self-contained and can be trivially + exposed by an in-process capability or a compatibility adapter. + """ + + _relation_lock_registry_guard = RLock() + _relation_lock_registry: dict[str, RLock] = {} + + def __init__( + self, + wiki_root: str | Path, + library_root: str | Path, + *, + build_logger: WikiBuildLogger | None = None, + case_root: str | Path | None = None, + faultannotated_root: str | Path | None = None, + bom_root: str | Path | None = None, + ) -> None: + self.store = create_wiki_store(wiki_root) + # In Viking mode the raw library is a resource namespace, not a local + # filesystem path. Keeping the URI as a Path silently turns + # ``viking://resources/<raw_namespace>/`` into a local-looking path and + # makes manifest discovery report an empty library even though the + # remote chapters are available through ``_raw_fs``. + self._library_root_uri = str(library_root) + self._library_root = ( + None if self._library_root_uri.startswith("viking://") else Path(library_root).resolve() + ) + self._raw_fs = create_raw_reader(library_root) + set_raw_source_root_uri(self._raw_fs.root_uri) + # Global BOM registry (e.g. ``viking://resources/<bom_namespace>/bom/component/挖掘机部件_BOM_清单``) + # is a separate read-only namespace spanning every machine class; keep a + # dedicated reader so Phase 0 can scan the full remote BOM tree. + self._bom_fs: FSBackend | None = None + if bom_root is not None and str(bom_root).strip(): + self._bom_fs = create_raw_reader(bom_root) + # The runner exposes the build scope as WIKI_EXPECTED_DOCS. Treat it + # as the build source allowlist when the explicit source setting is + # absent, so unrelated manuals are not reported as missing coverage. + configured_docs = os.environ.get("WIKI_SOURCE_DOCS", "") or os.environ.get( + "WIKI_EXPECTED_DOCS", + "", + ) + self._source_doc_allowlist = tuple( + sorted({doc_id.strip() for doc_id in configured_docs.split(",") if doc_id.strip()}), + ) + self._log = build_logger + self._case_root = Path(case_root).resolve() if case_root is not None else None + self._faultannotated_root = ( + Path(faultannotated_root).resolve() if faultannotated_root is not None else None + ) + self._recover_finalize_transactions() + # Raw citations are resolved many times during a full audit. Keep the + # immutable chapter map and resolution result in-process so one bad + # model-generated URI cannot trigger a full JSON parse and chapter + # tree walk for every referencing entity. + self._chapter_map_cache: dict[str, dict[str, str]] = {} + self._chapter_path_aliases: dict[tuple[str, str], str] = {} + self._raw_doc_prefixes: dict[str, str] = {} + # Lazy doc_id → path-prefix index over the raw namespace. Built once + # by _build_doc_prefix_index; lets list_chapters resolve nested docs + # without blind DFS. + self._doc_prefix_index: dict[str, str] | None = None + self._root_is_doc: bool = False + self._chapters_cache: dict[str, list[dict[str, str]]] = {} + # Chapter batch plans are built once per build scope and then served + # from memory; the persisted copy is only the restart fallback. + self._chapter_plan_cache: dict[str, list[dict[str, object]]] = {} + self._library_doc_ids_cache: tuple[str, ...] | None = None + self._raw_resource_cache: dict[str, str | None] = {} + # ponytail: source_snapshot does N HTTP reads per doc; raw content + # is normally immutable during a build session, so cache by doc_ids + # tuple. The fingerprint map below keeps the cache correct when an + # incremental build observes a changed, added, or removed chapter. + self._source_snapshot_cache: dict[tuple[str, ...], dict[str, str]] = {} + self._source_snapshot_fingerprints: dict[ + tuple[str, ...], + dict[str, tuple[int | None, int | None]], + ] = {} + self._raw_source_hash_cache: dict[str, str | None] = {} + # ponytail: simple audit cache — invalidated on any write op. + # avoids re-scanning 1000+ entities when conductor paginates + # issues by different code/concept filters. + self._audit_cache: AuditCache | None = None + # Relation workers may run in parallel, but reverse narrative links + # are a read-modify-write against one Component page. Serialize that + # critical section so one worker cannot overwrite another worker's + # newly materialized links. + with self._relation_lock_registry_guard: + self._relation_sync_lock = self._relation_lock_registry.setdefault( + self.store.root_uri, + RLock(), + ) + + def _finalize_transaction_key(self, build_id: str) -> str: + """Return the legacy rollback key used by pre-atomic builds.""" + return f".staging/finalize/{build_id}" + + def _read_finalize_transaction(self, txn_key: str) -> dict[str, object] | None: + """Read one durable finalize transaction, ignoring malformed debris.""" + transaction = self.store.read_json(f"{txn_key}/transaction.json") + if transaction is None: + return None + return transaction if isinstance(transaction, dict) else None + + def _restore_finalize_transaction(self, txn_key: str, transaction: dict[str, object]) -> None: + """Restore all files captured by a prepared finalize transaction.""" + targets = transaction.get("targets") + if not isinstance(targets, list): + raise TypeError(f"Invalid finalize transaction targets: {txn_key}") + backup_key = f"{txn_key}/backup" + for raw_target in targets: + if not isinstance(raw_target, dict): + raise TypeError(f"Invalid finalize transaction target: {txn_key}") + relative = raw_target.get("path") + if not isinstance(relative, str) or not relative: + raise ValueError(f"Invalid finalize transaction path: {txn_key}") + backup = raw_target.get("backup") + if isinstance(backup, str) and backup: + backup_content = self.store.read_text(f"{backup_key}/{backup}") + if backup_content is None: + raise FileNotFoundError(f"Finalize rollback backup is missing: {backup}") + self.store.write_text(relative, backup_content) + elif self.store.exists(relative): + self.store.delete(relative) + + def _mark_finalize_transaction(self, txn_key: str, state: str, **extra: str) -> None: + """Update a transaction state atomically after commit or recovery.""" + transaction = self._read_finalize_transaction(txn_key) + if transaction is None: + return + transaction["state"] = state + transaction.update(extra) + self.store.write_json(f"{txn_key}/transaction.json", transaction) + + def _recover_finalize_transactions(self) -> int: + """Recover interrupted finalize operations before serving the wiki.""" + root_key = ".staging/finalize" + keys = self.store.list_dir(root_key, recursive=True) + transaction_keys = { + key.removesuffix("/transaction.json") + for key in keys + if key.endswith("/transaction.json") + } + recovered = 0 + for txn_key in sorted(transaction_keys): + transaction = self._read_finalize_transaction(txn_key) + if transaction is None or transaction.get("state") != "prepared": + continue + self._restore_finalize_transaction(txn_key, transaction) + self._mark_finalize_transaction(txn_key, "recovered", reason="interrupted_finalize") + raw_input_docs = transaction.get("input_docs", []) + recovered_input_docs = ( + tuple(str(doc_id) for doc_id in raw_input_docs if str(doc_id).strip()) + if isinstance(raw_input_docs, list) + else () + ) + self.checkpoint_build( + str(transaction.get("doc_id", "")), + str(transaction.get("device_id", "")), + str(transaction.get("series_id", "")), + "recovered", + build_id=str(transaction.get("build_id", "")), + input_hash=str(transaction.get("input_hash", "")), + config_hash=str(transaction.get("config_hash", "")), + snapshot_id=str(transaction.get("snapshot_id", "")), + source_snapshot_id=str(transaction.get("source_snapshot_id", "")), + audit_profile=self._validate_audit_profile( + str(transaction.get("audit_profile", "manual")), + ), + input_docs=recovered_input_docs, + schema_version=get_schema_version(), + ) + recovered += 1 + logger.warning("Recovered interrupted Wiki finalize transaction: %s", txn_key) + return recovered + + def recover_build(self) -> dict[str, int]: + """Recover interrupted finalize work and return the recovery count.""" + return {"recovered_transactions": self._recover_finalize_transactions()} + + def _invalidate_audit_cache(self) -> None: + self._audit_cache = None + + def _record_phase_timing(self, phase: str, started: float) -> None: + """Persist one measured Wiki-side phase duration when logging is enabled.""" + if self._log: + self._log.phase_timing(phase, (time.perf_counter() - started) * 1000) + + @staticmethod + def _validate_audit_profile(profile: str) -> BuildProfile: + """Return a supported build profile or reject configuration drift.""" + if profile == "manual": + return "manual" + if profile == "case": + return "case" + raise ValueError("audit_profile must be 'manual' or 'case'") + + def read_raw_source(self, uri: str) -> SourceReadResult: + """Read a supported provenance URI through its owning backend.""" + uri = self._canonicalize_local_raw_uri(uri) + kind = classify_raw_source_uri(uri, raw_root_uri=self._raw_fs.root_uri) + if kind is None: + return SourceReadResult( + uri=uri, + kind=None, + status=SourceReadStatus.INVALID_URI, + error_code="invalid_raw_source_uri", + ) + if kind is RawSourceKind.EXTERNAL: + return SourceReadResult( + uri=uri, + kind=kind, + status=SourceReadStatus.UNAVAILABLE, + error_code="external_source_not_locally_readable", + ) + content: str | None + if uri.startswith(self._raw_fs.root_uri.rstrip("/") + "/"): + key = unquote(uri.removeprefix(self._raw_fs.root_uri.rstrip("/") + "/")) + content = self._raw_fs.read_text(key) + elif uri.startswith("viking://resources/"): + try: + content = viking_read(uri, propagate_unavailable=True) + except (HTTPError, OpenVikingError) as exc: + return SourceReadResult( + uri=uri, + kind=kind, + status=SourceReadStatus.UNAVAILABLE, + error_code=type(exc).__name__, + ) + else: + content = None + if content is None: + return SourceReadResult( + uri=uri, + kind=kind, + status=SourceReadStatus.NOT_FOUND, + error_code="source_not_found", + ) + return SourceReadResult( + uri=uri, + kind=kind, + status=SourceReadStatus.OK, + content=content, + content_hash=sha256(content.encode("utf-8")).hexdigest(), + ) + + def _canonicalize_local_raw_uri(self, uri: str) -> str: + """Normalize equivalent local path spellings for raw-source URIs.""" + root_uri = self._raw_fs.root_uri.rstrip("/") + if not root_uri.startswith("file://") or not uri.startswith("file://"): + return uri + parsed = urlsplit(uri) + if parsed.scheme != "file" or parsed.netloc not in {"", "localhost"}: + return uri + try: + root = Path(self._raw_fs.root).resolve() + path = Path(unquote(parsed.path)).resolve() + relative = path.relative_to(root) + except (OSError, ValueError): + return uri + return f"{root_uri}/{relative.as_posix()}" + + def _source_hash(self, uri: str, *, refresh: bool = False) -> str | None: + """Read and cache one source hash for packet validation. + + Packet registration only needs the membership and hashes of the + packet's own sources. It must not rebuild the complete document + snapshot for every packet; explicit coverage/change checks perform the + full freshness scan when required. + """ + canonical = self._canonicalize_local_raw_uri(uri) + if not refresh and canonical in self._raw_source_hash_cache: + return self._raw_source_hash_cache[canonical] + result = self.read_raw_source(canonical) + value = result.content_hash if result.status is SourceReadStatus.OK else None + self._raw_source_hash_cache[canonical] = value + return value + + def _lookup_recorded_source_hash( + self, uri: str, *, coverage: dict[str, object] | None = None + ) -> str | None: + """Return the content hash recorded for *uri* in any source packet. + + Pass a pre-fetched ``coverage`` dict (from ``get_source_ledger()``) to + avoid re-reading all packets when called in a loop. + """ + if coverage is None: + ledger = self.get_source_ledger() + cov = ledger.get("coverage") + if not isinstance(cov, dict): + return None + coverage = cov + record = coverage.get(uri) + if isinstance(record, dict): + source_hash = record.get("source_hash") + if isinstance(source_hash, str) and source_hash: + return source_hash + return None + + def _current_entity_snapshot_id(self) -> str: + """Hash current formal files so finalize cannot publish a stale audit.""" + records = [ + f"{record['uri']}\x1f{record['content_hash']}" + for record in self._formal_entity_snapshot_records() + ] + return sha256("\n".join(sorted(records)).encode()).hexdigest() + + def _current_source_snapshot_id(self) -> str: + """Re-read every cited raw source and hash the publication snapshot.""" + source_hashes: dict[str, str] = {} + ledger_coverage = self.get_source_ledger().get("coverage") + if not isinstance(ledger_coverage, dict): + ledger_coverage = {} + for record in self._formal_entity_snapshot_records(): + if entity_status(record["content"]) == "deprecated": + continue + for uri in extract_source_uris(record["content"]): + kind = classify_raw_source_uri( + uri, + raw_root_uri=self._raw_fs.root_uri, + ) + if kind is None: + continue + result = self.read_raw_source(uri) + if result.status is SourceReadStatus.OK and result.content_hash is not None: + source_hashes[uri] = result.content_hash + elif kind is RawSourceKind.EXTERNAL: + recorded = self._lookup_recorded_source_hash(uri, coverage=ledger_coverage) + if recorded is not None: + source_hashes[uri] = recorded + else: + raise ValueError( + f"External source never registered in a source packet: {uri}", + ) + else: + # ponytail: skip unresolvable sources to match audit's behavior + # (audit drops them from the hash instead of raising). A + # truncated/stale source URI is a reference-quality issue, + # not a data-integrity blocker. + logger.warning( + "Skipping unresolvable source URI in snapshot: %s (%s)", + uri, + result.status.value, + ) + return sha256( + "\n".join( + f"{uri}\x1f{content_hash}" for uri, content_hash in sorted(source_hashes.items()) + ).encode(), + ).hexdigest() + + def _audit_all_pages( + self, *, profile: BuildProfile = "manual", limit: int = 500 + ) -> WikiAuditReport: + """Run one complete, snapshot-consistent audit for a quality gate. + + ``audit_wiki`` is intentionally paginated for capability callers. Finalize + is different: it must make a decision over every issue, not only the + first page. The audit cache keeps subsequent pages cheap while this + helper makes the completeness requirement explicit and verifies that + pagination never crosses an entity snapshot boundary. + """ + # Prime the raw-chapter URI registry (list_chapters only — cheap, + # no content reads) so raw-link audit checks pass consistently. + for doc_id in self._library_doc_ids(): + self.list_chapters(doc_id) + first = self.audit_wiki(profile=profile, offset=0, limit=limit) + pages = list(first["issues"]) + snapshot_id = str(first.get("snapshot_id", "")) + source_snapshot_id = str(first.get("source_snapshot_id", "")) + next_offset = int(first.get("next_offset", -1)) + while next_offset != -1: + page = self.audit_wiki(profile=profile, offset=next_offset, limit=limit) + if str(page.get("snapshot_id", "")) != snapshot_id: + raise ValueError( + "Wiki changed while audit pagination was in progress; rerun audit before finalize.", + ) + if str(page.get("source_snapshot_id", "")) != source_snapshot_id: + raise ValueError( + "Raw sources changed while audit pagination was in progress; rerun audit before finalize.", + ) + pages.extend(page["issues"]) + next_offset = int(page.get("next_offset", -1)) + complete: WikiAuditReport = first.copy() + complete["issues"] = pages + complete["returned_issue_count"] = len(pages) + complete["next_offset"] = -1 + return complete + + @staticmethod + def _requires_formal_write_validation(content: str) -> bool: + """Return whether a write claims to be a formal machine-valid page.""" + frontmatter = parse_frontmatter(content) + return ( + entity_status(content) in {"confirmed", "deprecated"} + or str(frontmatter.get("publication_state", "")).strip() == "published" + or str(frontmatter.get("validation_state", "")).strip() == "machine_validated" + ) + + def _validate_formal_write( + self, + *, + content: str, + concept: str, + class_name: str, + object_name: str, + skip_materialization: bool = False, + ) -> None: + """Enforce materialization policy and formal-page invariants.""" + if skip_materialization: + return + if self._requires_formal_write_validation(content): + require_valid_entity( + content=content, + concept=concept, + class_name=class_name, + object_name=object_name, + hooks=_FORMAL_WRITE_HOOKS, + ) + + def _reject_malformed_wiki_refs(self, content: str) -> None: + """Reject pseudo-links before any draft or formal page is stored. + + ``{root_uri}/.../open_gap: ...`` is a common LLM failure mode: gap + prose is accidentally wrapped in a URI-shaped Markdown link. It is + neither a resolvable entity nor an honest plain-text gap. Rejecting + it at the write boundary keeps the corpus convergent; the worker must + either use a real URI or write ``open_gap`` as ordinary prose. + """ + malformed = extract_malformed_wiki_uris(content) + if malformed: + shown = ", ".join(malformed[:5]) + suffix = "..." if len(malformed) > 5 else "" + raise ValueError( + "Entity write contains malformed wiki URI(s): " + f"{shown}{suffix}. Use a real {self.store.root_uri}/<Concept>/<Class>/<Object> " + "URI, or keep an unresolved gap as plain text.", + ) + + def _reject_wrong_raw_refs(self, content: str) -> None: + """Reject chapter links rooted at the writable Wiki namespace. + + Raw chapter citations must remain addressable in the read-only raw + namespace. A Wiki namespace URI containing ``/chapters/`` is always a + stale or fabricated source link, even when its tail looks plausible. + """ + if not self.store.root_uri.startswith("viking://"): + return + raw_prefix = self._raw_fs.root_uri + "/" + tokens = re.findall(r"viking://resources/[^\s\"'<>\[\]]+", content) + wrong = [ + token for token in tokens if "/chapters/" in token and not token.startswith(raw_prefix) + ] + if wrong: + shown = ", ".join(sorted(set(wrong))[:3]) + raise ValueError( + f"Entity write contains non-raw chapter URI(s): {shown}. Use the configured raw root {self._raw_fs.root_uri}/... .", + ) + + def _reject_phantom_body_refs( + self, + content: str, + *, + extra_known: set[str] | None = None, + ) -> None: + """Reject promote-from-draft content with unresolvable wiki hash URIs. + + A ``{root_uri}/<Concept>/<hash>`` reference is valid only if it + resolves to a registered entity or belongs to the batch being + promoted (``extra_known``, whose deterministic hashes are known). + Any other 24-hex hash is a phantom — hand-written at extraction + time — and must be fixed (via ``entity_uri``) or downgraded to + plain text before promotion. This closes the draft→formal gate + so phantoms never reach the formal library. + + Raises: + ValueError: listing the unresolvable hash URIs found. + """ + known: set[str] = set(extra_known or ()) + phantoms: list[str] = [] + for uri in sorted(extract_wiki_uris(content)): + stripped = uri.split("#")[0] + if stripped in known: + continue + # Resolve and confirm the target actually exists on disk. + if ( + self.store.lookup_by_uri(stripped) is not None + and self.store.read_entity_by_uri(stripped) is not None + ): + known.add(stripped) + continue + phantoms.append(stripped) + if phantoms: + shown = ", ".join(phantoms[:10]) + suffix = "..." if len(phantoms) > 10 else "" + raise ValueError( + "Promotion blocked: content references unresolvable URIs " + f"({len(phantoms)}): {shown}{suffix}. " + f"Write readable URIs {self.store.root_uri}/<Concept>/<Class>/<Object> " + "matching the target entity's identity, or downgrade the link " + "to plain text if the target entity does not exist.", + ) + + def _reject_nonexistent_raw_sources(self, content: str) -> None: + """Warn about source URIs that don't resolve to actual raw files. + + Downgraded from hard rejection to warning — a truncated or stale + source URI in frontmatter ``sources`` is a reference-quality issue, + not a data-integrity blocker. The 4 core-path content checks + (``confirmation_requirements``) are the real publish gate. + """ + source_uris = extract_source_uris(content) + nonexistent: list[str] = [] + for uri in source_uris: + kind = classify_raw_source_uri(uri, raw_root_uri=self._raw_fs.root_uri) + if kind is None or kind is RawSourceKind.EXTERNAL: + continue + result = self.read_raw_source(uri) + if result.status is SourceReadStatus.NOT_FOUND: + nonexistent.append(uri) + if nonexistent: + shown = ", ".join(nonexistent[:5]) + suffix = "..." if len(nonexistent) > 5 else "" + logger.warning( + "Entity write has unresolvable source URI(s): %s%s. " + "Non-blocking — entity content is unaffected.", + shown, + suffix, + ) + + def _root_doc_name(self) -> str: + """Return the library root's own name as the document id. + + Used when ``chapters`` sits directly under the library root (doc root + == library root): the root name is the only stable identifier in both + local (``file:///.../<name>``) and viking (``viking://.../<name>``) + backends. + """ + return self._raw_fs.root_uri.rstrip("/").rsplit("/", 1)[-1] + + def _build_doc_prefix_index(self) -> None: + """Walk the raw root once and index every doc_id → path prefix. + + Standard ``.../<doc_id>/chapters/`` layout is preferred. For + non-standard layouts, every directory segment in each ``.md`` path + becomes a candidate doc_id mapped to the longest prefix ending with + that segment. Result is cached in ``_doc_prefix_index`` and warmed + into ``_raw_doc_prefixes`` so ``list_chapters`` skips blind DFS. + """ + if self._doc_prefix_index is not None: + return + index: dict[str, str] = {} + for key in self._raw_fs.list_dir("", recursive=True): + if not key.endswith(".md"): + continue + parts = [p for p in key.split("/") if p] + if len(parts) < 2: + continue + # ``chapters`` is a document-internal marker, not a root. Match it + # at ANY position, including a leading ``chapters/`` (the doc root + # is the library root itself in that case). Everything before it + # names the document; non-``chapters`` layouts are documents of + # one directory deep, located by drilling down with ``browse``. + chapters_idx = -1 + for i, part in enumerate(parts): + if part == "chapters": + chapters_idx = i + break + if chapters_idx > 0: + prefix = "/".join(parts[:chapters_idx]) + index[parts[0]] = prefix # standard layout: doc root is the top segment + continue + if chapters_idx == 0 and len(parts) > 1: + # Doc root == library root: the library root's own name is + # the document id (``chapters`` is just its hierarchy). + self._root_is_doc = True + index[self._root_doc_name()] = "" + continue + # Non-standard layout (no ``chapters`` marker): the library is a + # set of top-level documents. ``browse`` drills down from the + # root; only the first directory segment identifies a document — + # intermediate segments are hierarchy, not document names. + index[parts[0]] = parts[0] + self._doc_prefix_index = index + for doc_id, prefix in index.items(): + self._raw_doc_prefixes.setdefault(doc_id, prefix) + + def _resolve_doc_prefix(self, doc_id: str, max_depth: int = 8) -> str: + """Find the shallowest directory whose name matches ``doc_id``. + + Iterative-deepening BFS over directories only, sibling expansion + parallelised. Returns the relative path-key of the first match, or + ``""`` if not found within ``max_depth``. Cheaper than + :meth:`_build_doc_prefix_index` for single-doc lookups: skips the + full-namespace walk when the caller only needs one doc. + """ + expected = doc_id.casefold() + backend_subdirs: Callable[[str], list[str]] | None = getattr( + self._raw_fs, "list_subdirs", None + ) + + def list_subdirs(k: str) -> list[str]: + if backend_subdirs is not None: + return [str(e) for e in backend_subdirs(k)] + return [ + e for e in self._raw_fs.list_entries(k, recursive=False) if self._raw_fs.is_dir(e) + ] + + current_level: list[str] = [""] + seen: set[str] = set() + with ThreadPoolExecutor(max_workers=_io_worker_limit()) as pool: + for _ in range(max_depth + 1): + if not current_level: + break + current_level = [d for d in current_level if d not in seen] + for d in current_level: + seen.add(d) + if not current_level: + break + results = list(pool.map(list_subdirs, current_level)) + next_level: list[str] = [] + for child_dirs in results: + for entry in child_dirs: + if entry in seen: + continue + name = entry.rsplit("/", 1)[-1] + if name.casefold() == expected: + return entry + next_level.append(entry) + current_level = next_level + return "" + + def _library_doc_ids(self, *, refresh: bool = False) -> tuple[str, ...]: + """Return all parsed document IDs available in the configured raw root. + + When ``WIKI_SOURCE_DOCS`` is set, the conductor drives document + discovery via :meth:`browse` and passes full doc-id paths to + ``list_chapters``, ``source_coverage_status``, etc. No recursive + scan is performed — returning an empty tuple here lets callers + fall back to the conductor-supplied ``doc_ids``. + """ + if self._source_doc_allowlist: + return () + if self._library_doc_ids_cache is not None and not refresh: + return self._library_doc_ids_cache + # Batch path: one full walk populates both the doc list and the + # prefix cache used by list_chapters. Single-doc callers don't go + # through here — they use _resolve_doc_prefix instead. + if refresh: + self._doc_prefix_index = None + self._raw_doc_prefixes.clear() + self._chapters_cache.clear() + self._library_doc_ids_cache = None + self._build_doc_prefix_index() + result = tuple(sorted(self._doc_prefix_index or {})) + self._library_doc_ids_cache = result + return result + + def get_schema(self, concept: str) -> dict[str, object]: + """Return the authoritative schema and version for one Concept.""" + return { + "version": get_schema_version(), + "concept": concept, + "schema": get_concept_schema(concept), + } + + def auto_repair(self, entity_uris: list[str] | None = None) -> dict[str, object]: + """Repair only the current build's changed entities by default.""" + if entity_uris is None: + checkpoint = self.store.read_json("index/build_checkpoint.json") + build_started_at = ( + str(checkpoint.get("started_at", "")) if isinstance(checkpoint, dict) else "" + ) + changed = self.build_change_report( + persist=False, + include_op_flow=False, + ).get("changed_entities", []) + scoped: list[str] = [] + for row in changed if isinstance(changed, list) else []: + if not isinstance(row, dict): + continue + event_time = str(row.get("timestamp", "")) + if build_started_at and (not event_time or event_time < build_started_at): + continue + uri = str(row.get("uri", "")).strip() + if uri: + scoped.append(uri) + if not scoped and build_started_at and not self._log: + try: + started = datetime.fromisoformat(build_started_at) + if started.tzinfo is None: + started = started.replace(tzinfo=UTC) + started_ns = int(started.timestamp() * 1_000_000_000) + except ValueError: + started_ns = 0 + if started_ns: + for concept, class_name, object_name, uri in self.store.list_entities(): + path = self.store.entity_path(concept, class_name or None, object_name) + modified_ns = self.store._fs.mtime_ns(self.store._key_of(path)) + if modified_ns is not None and modified_ns >= started_ns: + scoped.append(uri) + entity_uris = list(dict.fromkeys(scoped)) + report = batch_auto_repair(self, entity_uris=entity_uris) + if report.entities_modified: + self._invalidate_audit_cache() + return report.to_dict() + + def input_snapshot_hash(self, doc_ids: tuple[str, ...] = (), *, refresh: bool = True) -> str: + """Hash every exact source chapter in the selected raw documents.""" + snapshots = self.source_snapshot(doc_ids, refresh=refresh) + records = [f"{uri}\x1f{content_hash}" for uri, content_hash in snapshots.items()] + return sha256("\n".join(sorted(records)).encode()).hexdigest() diff --git a/src/wolfharness/host/factory.py b/src/wolfharness/host/factory.py index f6056dcab..6e1901bf6 100644 --- a/src/wolfharness/host/factory.py +++ b/src/wolfharness/host/factory.py @@ -39,6 +39,7 @@ import asyncio from typing import TYPE_CHECKING, Any +from wolfharness.capabilities.resource_protocols import UriSchemeConflictError from wolfharness.log import get_logger @@ -210,11 +211,20 @@ def register_config_capabilities( # URI scheme registry (e.g. VikingCapability owns "viking"). owned: frozenset[str] = getattr(cap, "owned_schemes", frozenset()) if owned: - self._pool.extension_registry.scheme_registry.register( - provider_name=type(cap).__name__, - schemes=owned, - provider=cap, - ) + try: + self._pool.extension_registry.scheme_registry.register( + provider_name=type(cap).__name__, + schemes=owned, + provider=cap, + ) + except UriSchemeConflictError: + logger.debug( + "Skipping duplicate agent-scoped URI scheme registration", + agent_name=agent_name, + capability=type(cap).__name__, + owned_schemes=sorted(owned), + exc_info=True, + ) @staticmethod def _build_agent_descriptions( diff --git a/src/wolfharness/mcp_server/manager.py b/src/wolfharness/mcp_server/manager.py index e44b3cec7..7088bc5d0 100644 --- a/src/wolfharness/mcp_server/manager.py +++ b/src/wolfharness/mcp_server/manager.py @@ -414,8 +414,9 @@ async def setup_server( return None # De-duplicate the model-visible tool prefix across servers sharing - # a display_name so prefixed tool names never collide (RFC-0058). - base_prefix = config.display_name + # a configured prefix/display_name so prefixed tool names never collide + # (RFC-0058). + base_prefix = config.tool_prefix or config.display_name candidate = base_prefix n = 2 while candidate in self._used_tool_prefixes: @@ -722,6 +723,7 @@ async def get_capabilities( # noqa: PLR0915 """ from pydantic_ai.capabilities import MCP from pydantic_ai.mcp import MCPToolset + from pydantic_ai.toolsets import PrefixedToolset from wolfharness_config.mcp_server import ( SSEMCPServerConfig, @@ -783,22 +785,38 @@ async def _make_capability( client_id = server.client_id toolset = toolset_cache.get(client_id) if toolset is None: - toolset = MCPToolset(client=transport, **_make_kwargs(server)) + raw = MCPToolset(client=transport, **_make_kwargs(server)) try: - await toolset.__aenter__() + await raw.__aenter__() except Exception: # Eager enter failed — don't cache a broken toolset. # Subsequent calls can retry. - del toolset # Help GC + del raw # Help GC raise + # Wrap in PrefixedToolset when a tool_prefix is configured so + # model-visible tool names are namespaced (RFC-0058), matching + # the McpServerCap provider path. + toolset = ( + PrefixedToolset(wrapped=raw, prefix=server.tool_prefix) + if server.tool_prefix + else raw + ) toolset_cache[client_id] = toolset + # Apply tool_prefix to allowed_tools so the capability advertises + # the namespaced names the model will actually see. + prefix = server.tool_prefix + if prefix and server.enabled_tools: + allowed: list[str] | None = [f"{prefix}_{t}" for t in server.enabled_tools] + else: + allowed = server.enabled_tools + return MCP( url=_derive_url(server), - local=toolset, + local=toolset_cache[client_id], native=False, id=server.name or server.client_id, - allowed_tools=server.enabled_tools, + allowed_tools=allowed, ) async def _process_global_configs( diff --git a/src/wolfharness/messaging/compaction.py b/src/wolfharness/messaging/compaction.py index 496f1f4a3..79b314e39 100644 --- a/src/wolfharness/messaging/compaction.py +++ b/src/wolfharness/messaging/compaction.py @@ -384,6 +384,68 @@ async def apply(self, messages: MessageSequence) -> list[ModelMessage]: return result +@dataclass +class TruncateToolCallInputs(CompactionStep): + """Truncate large tool-call input arguments in responses. + + Tool calls carry the request arguments the model sent (``ToolCallPart.args``). + Over a long build these inputs accumulate and can dwarf the pruned output + placeholders, so the context still grows unboundedly even when DCP turns + tool *outputs* into ``[pruned]``. This step shortens tool-input args while + preserving the tool name and call id. String args are truncated in place; + dict args have their large string values truncated (recursively) and None + args are left untouched. + """ + + max_length: int = 1500 + """Maximum length for a tool-call input string value.""" + + suffix: str = "\n... [input truncated]" + """Suffix appended to a truncated string value.""" + + async def apply(self, messages: MessageSequence) -> list[ModelMessage]: + result: list[ModelMessage] = [] + for msg in messages: + match msg: + case ModelResponse(parts=parts): + new_parts: list[ModelRequestPart | ModelResponsePart] = [] + for part in parts: + if isinstance(part, ToolCallPart): + new_parts.append(self._truncate_call(part)) + else: + new_parts.append(part) + result.append(replace(msg, parts=cast(Sequence[Any], new_parts))) + case _: + result.append(msg) + return result + + def _truncate_call(self, part: ToolCallPart) -> ToolCallPart: + args = part.args + if isinstance(args, str): + if len(args) > self.max_length: + truncated = args[: self.max_length - len(self.suffix)] + return replace(part, args=truncated + self.suffix) + return part + if isinstance(args, dict): + new_args = self._truncate_dict(args) + return replace(part, args=new_args) + return part + + def _truncate_value(self, value: Any) -> Any: + if isinstance(value, str): + if len(value) > self.max_length: + return value[: self.max_length - len(self.suffix)] + self.suffix + return value + if isinstance(value, dict): + return self._truncate_dict(value) + if isinstance(value, list): + return [self._truncate_value(v) for v in value] + return value + + def _truncate_dict(self, data: dict[str, Any]) -> dict[str, Any]: + return {k: self._truncate_value(v) for k, v in data.items()} + + # ============================================================================= # Selection Steps - Keep subsets of messages # ============================================================================= diff --git a/src/wolfharness/models/model_configs.py b/src/wolfharness/models/model_configs.py index e310a4a86..994926369 100644 --- a/src/wolfharness/models/model_configs.py +++ b/src/wolfharness/models/model_configs.py @@ -8,9 +8,10 @@ from collections.abc import Callable import contextlib +import os from typing import TYPE_CHECKING, Annotated, Any, Literal -from pydantic import ConfigDict, Field, ImportString +from pydantic import ConfigDict, Field, ImportString, field_validator from pydantic_ai import ModelSettings as PyAIModelSettings from pydantic_ai.models.test import TestModel from schemez import Schema @@ -22,6 +23,9 @@ from wolfharness_config.model_capabilities import ModelCapabilities +ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh"] + + if TYPE_CHECKING: from pydantic_ai.models import Model from pydantic_ai.models.anthropic import AnthropicModelSettings @@ -194,6 +198,18 @@ class StringModelConfig(BaseModelConfig): if not set. """ + api_key_env: str | None = Field( + default=None, + pattern=r"^[A-Z_][A-Z0-9_]*$", + title="API key environment variable", + ) + """Environment variable containing the endpoint API key. + + This allows multiple OpenAI-compatible endpoints to use independent + credentials without placing secret values in a manifest. It takes + precedence over ``api_key`` when configured. + """ + max_tokens: int | None = Field( default=None, ge=1, @@ -285,6 +301,12 @@ class StringModelConfig(BaseModelConfig): ) """Extra body to send to the model.""" + reasoning_effort: ReasoningEffort | None = Field( + default=None, + title="Reasoning effort", + ) + """Constrains reasoning effort for OpenAI-compatible reasoning models.""" + def get_model_settings(self) -> PyAIModelSettings: """Get model settings in pydantic-ai format.""" from pydantic_ai.settings import ModelSettings @@ -302,6 +324,7 @@ def get_model_settings(self) -> PyAIModelSettings: "stop_sequences": self.stop_sequences, "extra_headers": self.extra_headers, "extra_body": self.extra_body, + "openai_reasoning_effort": self.reasoning_effort, } return ModelSettings(**{k: v for k, v in settings.items() if v is not None}) # type: ignore[typeddict-item, no-any-return] @@ -312,10 +335,16 @@ def get_model(self) -> Model: # model pointing to that endpoint — this is the most common pattern # for custom/private model providers. if self.base_url: + api_key = self.api_key + if self.api_key_env is not None: + api_key = os.environ.get(self.api_key_env) + if not api_key: + msg = f"Required model credential {self.api_key_env} is not set" + raise ValueError(msg) return _get_openai_based_model( str(self.identifier), base_url=self.base_url, - api_key=self.api_key, + api_key=api_key, ) return infer_model(self.identifier) @@ -411,6 +440,28 @@ class FallbackModelConfig(BaseModelConfig): ) """Ordered list of models to try in sequence.""" + @field_validator("models", mode="before") + @classmethod + def parse_nested_model_configs(cls, value: object) -> object: + """Parse discriminated config dictionaries inside fallback chains.""" + if not isinstance(value, list): + return value + parsed: list[object] = [] + for model in value: + if not isinstance(model, dict): + parsed.append(model) + continue + model_type = model.get("type") + if not isinstance(model_type, str): + parsed.append(model) + continue + config_class = _MODEL_CONFIG_TYPES.get(model_type) + if config_class is None: + parsed.append(model) + continue + parsed.append(config_class.model_validate(model)) + return parsed + def get_model(self) -> FallbackModel: from pydantic_ai.models.fallback import FallbackModel @@ -600,7 +651,7 @@ class OpenAIModelConfig(BaseModelConfig): ) """Extra body to send to the model.""" - reasoning_effort: Literal["none", "minimal", "low", "medium", "high", "xhigh"] | None = Field( + reasoning_effort: ReasoningEffort | None = Field( default=None, title="Reasoning effort", ) @@ -1256,3 +1307,15 @@ def to_dict(self) -> dict[str, Any]: | GeminiModelConfig, Field(discriminator="type"), ] + +_MODEL_CONFIG_TYPES: dict[str, type[BaseModelConfig]] = { + "fallback": FallbackModelConfig, + "function": FunctionModelConfig, + "import": ImportModelConfig, + "input": InputModelConfig, + "string": StringModelConfig, + "test": TestModelConfig, + "openai": OpenAIModelConfig, + "anthropic": AnthropicModelConfig, + "gemini": GeminiModelConfig, +} diff --git a/src/wolfharness/orchestrator/event_mapper.py b/src/wolfharness/orchestrator/event_mapper.py index c230ede25..9db56ef50 100644 --- a/src/wolfharness/orchestrator/event_mapper.py +++ b/src/wolfharness/orchestrator/event_mapper.py @@ -569,19 +569,21 @@ def _extract_first_json_object(s: str) -> str | None: def sanitize_tool_call_args_in_messages( messages: list[Any], ) -> None: - """Repair duplicated tool call arguments in message history in-place. + """Repair corrupted tool call arguments in message history in-place. - Some inference backends (vLLM with the ``glm47`` parser, SGLang with GLM - detectors) have a known streaming bug where tool call arguments are emitted - twice, producing concatenated JSON like:: + Two distinct corruption modes are handled:: - {"path": "/foo"}{"path": "/foo"} + {"path": "/foo"}{"path": "/foo"} # duplicated (vLLM glm47 parser) + {"path": "abc"def} # invalid JSON (truncated/unescaped) - This corrupts downstream model requests (HTTP 400 "Extra data") and tool - execution. This function walks every ``ModelResponse`` in the list, checks - each ``BaseToolCallPart`` whose ``args`` is a ``str``, and — when the string - contains concatenated JSON objects — replaces it with just the first valid - object. + * Duplicated concatenated JSON objects (vLLM ``glm47`` parser, SGLang GLM + detectors) are replaced with just the first valid object. + * String args that fail ``json.loads`` entirely are replaced with ``{}`` + so the raw garbage never reaches ``function.arguments`` on the next + model request (vLLM rejects it with HTTP 400 "must be valid JSON") and + never reaches tool execution. The tool then fails argument validation, + pydantic-ai injects a ``ModelRetry``, and the model regenerates a valid + call — a recoverable detour instead of a hard request failure. This is idempotent: parts with valid JSON args, ``dict`` args, or ``None`` args are left unchanged. @@ -607,18 +609,37 @@ def sanitize_tool_call_args_in_messages( continue if not isinstance(part.args, str): continue + try: + json.loads(part.args) + except json.JSONDecodeError: + pass + else: + # Already valid JSON — leave untouched. + continue + # Try extracting the first complete JSON object (duplicated-args + # bug); fall back to an empty object for irreparable args. repaired = _extract_first_json_object(part.args) if repaired is None: - continue - _logger.warning( - "Repaired duplicated tool call arguments", - extra={ - "tool_name": part.tool_name, - "tool_call_id": part.tool_call_id, - "original_len": len(part.args), - "repaired_len": len(repaired), - }, - ) + repaired = "{}" + _logger.warning( + "Replaced invalid tool call arguments with empty object", + extra={ + "tool_name": part.tool_name, + "tool_call_id": part.tool_call_id, + "original_len": len(part.args), + "original_preview": part.args[:200], + }, + ) + else: + _logger.warning( + "Repaired duplicated tool call arguments", + extra={ + "tool_name": part.tool_name, + "tool_call_id": part.tool_call_id, + "original_len": len(part.args), + "repaired_len": len(repaired), + }, + ) needs_repair = True new_parts[i] = dataclasses.replace(part, args=repaired) if needs_repair: diff --git a/src/wolfharness/orchestrator/run.py b/src/wolfharness/orchestrator/run.py index fe6f97d08..bc974c419 100644 --- a/src/wolfharness/orchestrator/run.py +++ b/src/wolfharness/orchestrator/run.py @@ -541,6 +541,8 @@ async def _execute_turn( # noqa: PLR0915 if event_bus is not None and not comm.publishes_to_event_bus: await event_bus.publish(self.session_id, run_started) await self._safe_publish(comm, run_started) + with contextlib.suppress(ValueError, TypeError, RuntimeError, KeyError, AttributeError): + await agent.event_handler(agent.get_context(run_ctx=self.run_ctx), run_started) # Set _current_input_provider ContextVar so MCP elicitation can # access it during turn execution. if session.input_provider is not None: @@ -581,6 +583,13 @@ async def _execute_turn( # noqa: PLR0915 try: async with contextlib.aclosing(turn.execute()) as event_gen: async for event in event_gen: + with contextlib.suppress( + ValueError, TypeError, RuntimeError, KeyError, AttributeError + ): + await agent.event_handler( + agent.get_context(run_ctx=self.run_ctx), + event, + ) if event_bus is not None and not comm.publishes_to_event_bus: await event_bus.publish(self.session_id, event) await self._safe_publish(comm, event) diff --git a/src/wolfharness/orchestrator/session_controller_agent.py b/src/wolfharness/orchestrator/session_controller_agent.py index b562633df..5cb9a1deb 100644 --- a/src/wolfharness/orchestrator/session_controller_agent.py +++ b/src/wolfharness/orchestrator/session_controller_agent.py @@ -17,9 +17,11 @@ if TYPE_CHECKING: import asyncio + from collections.abc import Callable from wolfharness.agents.base_agent import BaseAgent from wolfharness.delegation import AgentPool + from wolfharness.orchestrator.run import RunHandle from wolfharness.orchestrator.runtime_registry import RuntimeAgentRegistry from wolfharness_storage.protocols import SessionPersistence @@ -59,6 +61,11 @@ class SessionControllerAgentMixin: def _increment_mcp_count(self, _agent: Any) -> None: ... + # Attribute annotation, NOT a method stub: this mixin precedes + # SessionControllerRunsMixin in the MRO, so a stub body would shadow + # the real get_live_run and silently return None at runtime. + get_live_run: Callable[[str], RunHandle | None] + async def get_or_create_session( self, session_id: str, @@ -448,7 +455,7 @@ def list_sessions(self) -> list[SessionInfo]: created_at=s.created_at, last_active_at=s.last_active_at, is_per_session_agent=s.is_per_session_agent, - status="busy" if s.current_run_id is not None else "idle", + status="busy" if self.get_live_run(s.session_id) is not None else "idle", ) for s in self._sessions.values() ] diff --git a/src/wolfharness/orchestrator/session_controller_runs.py b/src/wolfharness/orchestrator/session_controller_runs.py index c5b44ee55..29306ad5b 100644 --- a/src/wolfharness/orchestrator/session_controller_runs.py +++ b/src/wolfharness/orchestrator/session_controller_runs.py @@ -62,6 +62,28 @@ class SessionControllerRunsMixin: def get_session(self, session_id: str) -> SessionState | None: ... + def get_live_run(self, session_id: str) -> RunHandle | None: + """Return the live run for a session and repair stale run metadata. + + ``SessionState.current_run_id`` is a pointer, not the run registry. + A cancelled or failed background task can leave that pointer behind + briefly, so callers that use it as the busy signal can strand a team + member forever. The registered handle and its completion event are + the authoritative runtime state. + """ + session = self._sessions.get(session_id) + if session is None: + return None + run_id = session.current_run_id + if run_id is None: + return None + run = self._runs.get(run_id) + if run is not None and not run.complete_event.is_set(): + return run + if session.current_run_id == run_id: + session.set_current_run_id(None) + return None + async def _emit_user_message_inserted( self, session_id: str, @@ -125,10 +147,12 @@ async def _emit_user_message_inserted( comm_channel = self._get_protocol_channel(session_id) if comm_channel is not None: await comm_channel.publish(event) + await self._deliver_to_event_handler(session_id, event) return # Fall back: direct EventBus publish (idle session). await self._event_bus.publish(session_id, event) + await self._deliver_to_event_handler(session_id, event) except Exception: logger.warning( "Failed to emit UserMessageInsertedEvent", @@ -157,6 +181,37 @@ def _get_protocol_channel(self, session_id: str) -> ProtocolChannel | None: return comm_channel return None + async def _deliver_to_event_handler( + self, + session_id: str, + event: Any, + ) -> None: + """Deliver an event to the active run's agent event handler. + + Events published outside ``_execute_turn()`` (e.g. + ``UserMessageInsertedEvent`` for steers/followups) would otherwise + never reach the agent's ``event_handler`` (which writes the + per-session log files). Routes to the active RunHandle's agent when + one is available; silently skips for idle sessions or missing agents. + + Args: + session_id: The session to deliver the event to. + event: The event to deliver. + """ + try: + session = self.get_session(session_id) + if session is None or session.current_run_id is None: + return + run = self._runs.get(session.current_run_id) + if run is None or run.agent is None: + return + await run.agent.event_handler(run.agent.get_context(run_ctx=run.run_ctx), event) + except Exception: + logger.debug( + "Failed to deliver event to agent event handler", + exc_info=True, + ) + async def _consume_run(self, run_handle: RunHandle, initial_prompt: str | list[Any]) -> None: # noqa: PLR0915 """Drive RunHandle execution to completion, chaining prompts. @@ -537,10 +592,7 @@ async def _route_message( return None # Stale-run detection: if current_run_id points to a missing # or completed run, clear it and start a new run. - if session.current_run_id is not None: - existing_run = self._runs.get(session.current_run_id) - if existing_run is None or existing_run.complete_event.is_set(): - session.set_current_run_id(None) + self.get_live_run(session_id) if session.current_run_id is None: # Idle session — initial delivery. inferred_delivery = delivery or "initial" diff --git a/src/wolfharness/orchestrator/session_pool_runs.py b/src/wolfharness/orchestrator/session_pool_runs.py index 4da37e9a4..b482dbab4 100644 --- a/src/wolfharness/orchestrator/session_pool_runs.py +++ b/src/wolfharness/orchestrator/session_pool_runs.py @@ -56,10 +56,7 @@ def _get_active_run_handle(self, session_id: str) -> RunHandle | None: Returns: The RunHandle, or None if no active run exists. """ - session = self.sessions.get_session(session_id) - if session is None or session.current_run_id is None: - return None - return self.sessions._runs.get(session.current_run_id) + return self.sessions.get_live_run(session_id) def _create_run_handle( self, @@ -114,11 +111,11 @@ def _create_run_handle( """ from wolfharness.orchestrator.session_controller import SessionBusyError - # Staleness check: prevent silently overwriting an active run. - if session.current_run_id is not None and session.current_run_id in self.sessions._runs: - existing = self.sessions._runs[session.current_run_id] - if not existing.complete_event.is_set(): - raise SessionBusyError(session_id, session.current_run_id) + # Staleness check: prevent silently overwriting an active run while + # clearing a completed or missing run pointer. + existing = self.sessions.get_live_run(session_id) + if existing is not None: + raise SessionBusyError(session_id, existing.run_id) event_bus = self.event_bus run_ctx = AgentRunContext(session_id=session_id, event_bus=event_bus, deps=deps) @@ -310,12 +307,10 @@ async def _run_stream_run_turn( # noqa: PLR0915 case _: content = flatten_prompts(prompts) - run_id = session.current_run_id - if run_id is not None: + run_handle = self._get_active_run_handle(session_id) + if run_handle is not None: # Active run — steer and use EventBus - run_handle = self.sessions._runs.get(run_id) - if run_handle is not None: - run_handle.steer(content) + run_handle.steer(content) queue = await self.event_bus.subscribe(session_id, scope=scope) try: while True: @@ -338,11 +333,10 @@ async def _run_stream_run_turn( # noqa: PLR0915 async with session._request_lock: # Re-check current_run_id inside the lock — another caller # may have created a run while we waited. - if session.current_run_id is not None: + run_handle = self._get_active_run_handle(session_id) + if run_handle is not None: # Active run appeared — steer and use EventBus - run_handle = self.sessions._runs.get(session.current_run_id) - if run_handle is not None: - run_handle.steer(content) + run_handle.steer(content) queue = await self.event_bus.subscribe(session_id, scope=scope) try: while True: diff --git a/src/wolfharness_cli/serve_opencode.py b/src/wolfharness_cli/serve_opencode.py index 7281a0209..b0f2c5a98 100644 --- a/src/wolfharness_cli/serve_opencode.py +++ b/src/wolfharness_cli/serve_opencode.py @@ -14,6 +14,8 @@ from __future__ import annotations import asyncio +import os +from pathlib import Path from typing import Annotated, Any from platformdirs import user_log_path @@ -53,7 +55,12 @@ def _configure_observability_and_logging( registry.configure_observability(manifest.observability) - log_dir = user_log_path("wolfharness", appauthor=False) + configured_log_dir = os.environ.get("WOLFHARNESS_LOG_DIR", "").strip() + log_dir = ( + Path(configured_log_dir).expanduser() + if configured_log_dir + else user_log_path("wolfharness", appauthor=False) + ) log_dir.mkdir(parents=True, exist_ok=True) log_file = log_dir / "opencode.log" ap_log.configure_logging(level=log_level.upper(), force=True, log_file=str(log_file)) diff --git a/src/wolfharness_config/__init__.py b/src/wolfharness_config/__init__.py index f7f996c01..4b99f8df3 100644 --- a/src/wolfharness_config/__init__.py +++ b/src/wolfharness_config/__init__.py @@ -14,7 +14,12 @@ from wolfharness_config.capabilities import CapabilityConfig from wolfharness_config.forward_targets import ForwardingTarget from wolfharness_config.session import SessionQuery -from wolfharness_config.session_pool import ACPConfig, OpenCodeConfig, SessionPoolConfig +from wolfharness_config.session_pool import ( + ACPConfig, + ACPVikingArchiveConfig, + OpenCodeConfig, + SessionPoolConfig, +) from wolfharness_config.teams import TeamConfig, TeamMemberConfig from wolfharness_config.durable import CheckpointConfig, DeferredToolConfig from wolfharness_config.mcp_server import ( @@ -85,6 +90,7 @@ __all__ = [ "DEFAULT_SKILLS_PATHS", "ACPConfig", + "ACPVikingArchiveConfig", "AnyToolConfig", "AttachmentImageConfig", "BaseEventHandlerConfig", diff --git a/src/wolfharness_config/capabilities.py b/src/wolfharness_config/capabilities.py index ef4e2f7e3..616226908 100644 --- a/src/wolfharness_config/capabilities.py +++ b/src/wolfharness_config/capabilities.py @@ -16,9 +16,10 @@ from __future__ import annotations +import os from typing import Annotated, Any, Literal, Self -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator KNOWN_CAPABILITY_TYPES: frozenset[str] = frozenset({ @@ -129,6 +130,9 @@ class DCPCapabilityConfig(BaseModel): """Glob patterns matching tool names that should never be pruned.""" protected_tools: set[str] = Field(default_factory=set) """Literal tool names that should never be pruned.""" + auto_compact_on_critical: bool = Field(default=False) + """Whether to run the manifest ``compaction`` pipeline on the persistent + conversation when the watermark reaches CRITICAL (fires once per episode).""" class SkillActivationCapabilityConfig(BaseModel): @@ -209,6 +213,11 @@ class VikingCapabilityConfig(BaseModel): """Viking user ID. If None, SDK resolves from env vars.""" timeout: float | None = None """Request timeout in seconds. If None, SDK uses default (60s).""" + tool_prefix: str | None = None + """Optional namespace prefix for Viking tool names. + + When unset, legacy ``viking_*`` names are kept. + """ skills_uri: str | None = None """Override for skills URI. Default: viking://user/{user or 'default'}/skills/""" resources_uri: str | None = None @@ -233,6 +242,25 @@ class VikingCapabilityConfig(BaseModel): """ uploads_uri: str | None = None """Override for uploads URI.""" + + @field_validator( + "url", + "api_key", + "account", + "user", + "skills_uri", + "resources_uri", + "sessions_uri", + "uploads_uri", + mode="before", + ) + @classmethod + def expand_env_vars(cls, value: object) -> object: + """Expand shell-style environment variables in Viking string fields.""" + if isinstance(value, str): + return os.path.expandvars(value) + return value + public_download_base_url: str | None = None """Base URL for public download links.""" enable_link: bool = False @@ -317,18 +345,14 @@ class VikingCapabilityConfig(BaseModel): these tools are blocked from accessing viking:// URIs. Customize to add or remove tools from the protected list.""" allowed_uri_prefixes: list[str] = Field(default_factory=list) - """URI prefix allowlist covering all viking:// namespaces. When - non-empty: - - knowledge-base access (all ``viking_*`` tools + the @-mention flow) - rejects URIs outside the listed prefixes; - - memory paths — auto-recall, profile injection, compaction, and - multimodal-bridge uploads — are only active when their target URI - (e.g. ``viking://user/{user}/memories/``) is inside the list; - - skill discovery (``list_skills``/``read_skill``/``skill_exists``) is - only active when the skills URI is inside the list. - Since one list governs everything, include both the intended resource - prefixes and the memory/skill prefixes when those features are needed. - Empty list (default) means unrestricted — backward compatible.""" + """Read/search URI prefix allowlist for viking://resources/. When + non-empty, retrieval, listing, resource reads, and @-mention discovery + reject resource URIs outside the listed prefixes. This does not constrain + write tools or provider-owned ticket submission scopes.""" + write_allowed_uri_prefixes: list[str] = Field(default_factory=list) + """Optional write URI prefix allowlist for viking://resources/. When + empty, writes are not constrained by URI prefix. Configure this only when + a deployment needs an explicit write-side guard.""" compaction_enabled: bool = False """When True, archive old conversation messages to Viking before context overflow. Disabled by default.""" @@ -354,6 +378,18 @@ class VikingCapabilityConfig(BaseModel): """When True (default), profile injection runs only on the first turn of a session (message count <= 2). When False, injection runs on every before_model_request call where _profile_injected is False.""" + index_enabled: bool = False + """Enable first-turn resource-namespace index injection. When True, the + capability lists live ``viking://resources/<namespace>`` namespaces on + the first turn and injects them as an <openviking-index> XML block.""" + index_max_tokens: int = 1000 + """Maximum token budget for the injected index block. Content exceeding + this budget is truncated with a [... truncated] indicator.""" + index_limit: int = 20 + """Maximum number of namespace names to include in the index block.""" + index_uri: str | None = None + """Namespace URI to list. When None, resolves to ``viking://resources/``.""" + enabled_tools: list[str] | None = Field( default=None, examples=[["viking_ls", "viking_read", "viking_grep"]], diff --git a/src/wolfharness_config/compaction.py b/src/wolfharness_config/compaction.py index 4839a9eba..f9206da96 100644 --- a/src/wolfharness_config/compaction.py +++ b/src/wolfharness_config/compaction.py @@ -96,6 +96,19 @@ def build(self) -> CompactionStep: return TruncateTextParts(max_length=self.max_length, suffix=self.suffix) +class TruncateToolCallInputsConfig(Schema): + """Configuration for TruncateToolCallInputs step.""" + + type: Literal["truncate_tool_inputs"] = "truncate_tool_inputs" + max_length: int = 1500 + suffix: str = "\n... [input truncated]" + + def build(self) -> CompactionStep: + from wolfharness.messaging.compaction import TruncateToolCallInputs + + return TruncateToolCallInputs(max_length=self.max_length, suffix=self.suffix) + + class KeepLastMessagesConfig(Schema): """Configuration for KeepLastMessages step.""" @@ -191,6 +204,7 @@ def build(self) -> CompactionStep: | FilterEmptyMessagesConfig | TruncateToolOutputsConfig | TruncateTextPartsConfig + | TruncateToolCallInputsConfig | KeepLastMessagesConfig | KeepFirstMessagesConfig | KeepFirstAndLastConfig diff --git a/src/wolfharness_config/mcp_server.py b/src/wolfharness_config/mcp_server.py index 3661c61dc..af4200360 100644 --- a/src/wolfharness_config/mcp_server.py +++ b/src/wolfharness_config/mcp_server.py @@ -173,6 +173,13 @@ class BaseMCPServerConfig(Schema): ) """Tools to exclude from this server (blacklist). Mutually exclusive with enabled_tools.""" + tool_prefix: str | None = Field( + default=None, + examples=["kb_main", "manuals"], + title="Tool prefix", + ) + """Optional namespace prefix for tool names exposed by this MCP server.""" + @model_validator(mode="after") def _validate_tool_filters(self) -> Self: """Validate that enabled_tools and disabled_tools are mutually exclusive.""" diff --git a/src/wolfharness_config/session_pool.py b/src/wolfharness_config/session_pool.py index 8560ecfae..639491068 100644 --- a/src/wolfharness_config/session_pool.py +++ b/src/wolfharness_config/session_pool.py @@ -8,6 +8,43 @@ from wolfharness_config.durable import CheckpointConfig +class ACPVikingArchiveConfig(Schema): + """Configuration for ACP event archiving to Viking.""" + + enabled: bool = Field(default=False, title="Enable ACP Viking archive") + """Whether ACP session updates are archived to Viking.""" + + url: str | None = Field(default=None, title="Viking URL") + """Viking server URL. Falls back to ``VIKING_MCP_URL``.""" + + api_key: str | None = Field(default=None, title="Viking API key") + """Viking API key. Environment variables are expanded at runtime.""" + + user: str | None = Field(default=None, title="Viking user") + """Optional Viking user namespace.""" + + session_prefix: str = Field(default="iroot-acp-session", title="Session prefix") + """Prefix used for archived ACP session IDs.""" + + batch_size: int = Field(default=25, ge=1, title="Archive batch size") + """Number of events to buffer before writing to Viking.""" + + flush_on_turn_complete: bool = Field(default=True, title="Flush on turn complete") + """Whether to flush pending events when a turn_complete update is observed.""" + + transcript_enabled: bool = Field(default=True, title="Enable ACP transcript archive") + """Whether ACP session updates are also written as readable Viking session messages.""" + + transcript_keep_recent_count: int = Field( + default=0, + ge=0, + title="Transcript keep recent count", + ) + """Recent transcript messages retained in the live session after commit. 0 keeps all.""" + + model_config = ConfigDict(frozen=True) + + class SessionPoolConfig(Schema): """Configuration for the SessionPool orchestration layer. @@ -41,6 +78,12 @@ class SessionPoolConfig(Schema): When set, enables persistence of agent state for recovery. """ + acp_viking_archive: ACPVikingArchiveConfig = Field( + default_factory=ACPVikingArchiveConfig, + title="ACP Viking archive", + ) + """Configuration for full ACP session update archiving to Viking.""" + model_config = ConfigDict(frozen=True) diff --git a/src/wolfharness_config/storage.py b/src/wolfharness_config/storage.py index bb10c8af4..01e756a5f 100644 --- a/src/wolfharness_config/storage.py +++ b/src/wolfharness_config/storage.py @@ -33,8 +33,20 @@ def get_database_path() -> str: - """Get the database file path, creating directories if needed.""" - db_path = DATA_DIR / DEFAULT_DB_NAME + """Get the configured database URL, creating local directories if needed. + + ``WOLFHARNESS_DATABASE_URL`` is intended for deployments that already + provide a database URL. ``WOLFHARNESS_DATA_DIR`` provides a portable + writable location for the default SQLite history database, which is + useful for isolated runners and restricted desktop environments. + """ + configured_url = os.environ.get("WOLFHARNESS_DATABASE_URL", "").strip() + if configured_url: + return configured_url + + configured_dir = os.environ.get("WOLFHARNESS_DATA_DIR", "").strip() + data_dir = Path(configured_dir).expanduser() if configured_dir else DATA_DIR + db_path = data_dir / DEFAULT_DB_NAME db_path.parent.mkdir(parents=True, exist_ok=True) return f"sqlite:///{db_path}" diff --git a/src/wolfharness_config/team_mode.py b/src/wolfharness_config/team_mode.py index 87e15e80a..ae74cce51 100644 --- a/src/wolfharness_config/team_mode.py +++ b/src/wolfharness_config/team_mode.py @@ -42,6 +42,8 @@ 2. Use `task_create` and `task_create_batch` to track work items. 3. Use `read_blackboard` and `write_blackboard` to share context. 4. Use `team_status` to check the status of all members. + To see all work assigned to you, use `task_list(mine_only=True)`; + this includes subtasks under another member's parent task. 5. If you are the lead, you may use `team_create` and `team_delete`. ## Communication Channels @@ -50,8 +52,14 @@ - Use `task_create` and `task_create_batch` to define work items with clear \ deliverables. +- A worker must start by calling `task_list(mine_only=True)` and use the exact \ +returned task ID for `task_get`/`task_update`; do not infer IDs from a message \ +or from another worker's status snapshot. - Use `task_update(status=...)` to change task lifecycle state only \ (pending → in_progress → completed/failed). +- The first `task_update(status="in_progress")` claims a server-side lease. \ +Keep the returned `lease_token` for completion; technical-note/progress updates \ +renew the lease, and stale tokens are rejected. - Use `task_update(technical_note=...)` ONLY for technical notes that future \ readers of the task need. Do NOT use task notes for progress chatter. - Use `task_update(progress_current=..., progress_total=...)` to report \ @@ -78,6 +86,10 @@ - Messages are ephemeral — they arrive as notifications but are NOT persisted \ in team state. If the information needs to be durable, write it to the \ blackboard instead (or use `persist_to_blackboard` parameter). +- Every message carries a `sent_at` timestamp. Treat status claims as an \ +observation made at that time, not as current truth. Before reassigning work, \ +shutting down a member, or taking another destructive coordination action, \ +re-read `task_list`/`team_status` and prefer the newer authoritative state. - Use `message_type` to categorize: "question", "escalation", "handoff", \ "notification". """ @@ -249,6 +261,19 @@ class TeamModeConfig(Schema): default=True, title="Auto-broadcast on member creation", ) + member_can_create_subtasks: bool = Field( + default=True, + title="Allow members to create subtasks", + description=( + "Whether non-lead members may create child tasks. Disable this " + "for workflows whose conductor owns the task graph." + ), + ) + lease_ttl_seconds: int = Field( + default=300, + ge=1, + title="Task lease TTL (seconds)", + ) max_watch_timeout: int = Field( default=120, ge=1, diff --git a/src/wolfharness_server/acp_server/acp_agent.py b/src/wolfharness_server/acp_server/acp_agent.py index d211b0f24..51c7f1a36 100644 --- a/src/wolfharness_server/acp_server/acp_agent.py +++ b/src/wolfharness_server/acp_server/acp_agent.py @@ -244,6 +244,9 @@ class AgentPoolACPAgent(ACPAgent): ``close_all_sessions_for_connection()``. """ + viking_archive: Any | None = None + """Shared ACP Viking archive for raw protocol frames and session updates.""" + _mcp_manager: AcpMcpConnectionManager = field(init=False) """Manager for MCP-over-ACP connection lifecycle.""" @@ -300,6 +303,7 @@ def __post_init__(self) -> None: ), client=self.client, client_capabilities=self.client_capabilities, + viking_archive=self.viking_archive, ) logger.info("ACPProtocolHandler initialized for SessionPool mode") diff --git a/src/wolfharness_server/acp_server/handler.py b/src/wolfharness_server/acp_server/handler.py index a4e7f5a18..76d9424d0 100644 --- a/src/wolfharness_server/acp_server/handler.py +++ b/src/wolfharness_server/acp_server/handler.py @@ -24,6 +24,7 @@ from wolfharness.log import get_logger from wolfharness_server.acp_server.event_converter import ACPEventConverter, SubagentContext from wolfharness_server.acp_server.input_provider import ACPInputProvider +from wolfharness_server.acp_server.viking_archive import ACPVikingEventArchive from wolfharness_server.mixins import ConsumerShutdown, ProtocolEventConsumerMixin @@ -49,6 +50,19 @@ def _make_image_normalizer(host_context: HostContext) -> Any | None: return ImageNormalizer(manifest.attachment) +def _content_items_to_text(contents: Sequence[Any]) -> str: + """Best-effort text view of ACP prompt content for capability metadata.""" + text_parts: list[str] = [] + for item in contents: + if isinstance(item, str): + text_parts.append(item) + continue + text = getattr(item, "text", None) + if isinstance(text, str): + text_parts.append(text) + return "\n".join(part for part in text_parts if part.strip()).strip() + + class ACPProtocolHandler(ProtocolEventConsumerMixin): """ACP protocol handler backed by SessionPool. @@ -75,6 +89,7 @@ def __init__( client: Client, client_capabilities: ClientCapabilities | None = None, acp_agent: Any = None, + viking_archive: ACPVikingEventArchive | None = None, ) -> None: """Initialize the protocol handler.""" super().__init__() @@ -87,6 +102,17 @@ def __init__( self._parent_of: dict[str, str] = {} self.acp_agent = acp_agent self._elicitation_tasks: dict[str, set[asyncio.Task[Any]]] = {} + if viking_archive is not None: + self._viking_archive = viking_archive + else: + session_pool_config = getattr( + getattr(host_context, "manifest", None), + "session_pool", + None, + ) + self._viking_archive = ACPVikingEventArchive.from_config( + getattr(session_pool_config, "acp_viking_archive", None) + ) @property def event_bus(self) -> EventBus: @@ -197,7 +223,12 @@ async def _notify_completed(self, parent_sid: str, child_sid: str) -> None: session_id=parent_sid, update=update, ) - await self.client.session_update(notification) + await self._archive_and_send_update( + notification, + consumer_session_id=parent_sid, + source_session_id=parent_sid, + event_id=f"subagent-completed:{child_sid}", + ) except (ConnectionResetError, BrokenPipeError): logger.debug( "Client disconnected during completion notification", @@ -311,7 +342,12 @@ def _discard_task(t: asyncio.Task[Any], sid: str = effective_sid) -> None: update=update, field_meta=converter.subagent_meta, ) - await self.client.session_update(notification) + await self._archive_and_send_update( + notification, + consumer_session_id=session_id, + source_session_id=effective_sid, + event_id=envelope.event_id, + ) except (ConnectionResetError, BrokenPipeError) as e: logger.debug( "Client connection closed gracefully", @@ -479,6 +515,7 @@ async def _after_consumer_loop(self, session_id: str) -> None: Args: session_id: The session whose consumer has stopped. """ + await self._viking_archive.flush_session(session_id) self._converters.pop(session_id, None) self._parent_of.pop(session_id, None) @@ -596,6 +633,7 @@ async def handle_prompt( # noqa: PLR0915 # Convert ACP content blocks to agent prompts normalizer = _make_image_normalizer(self._host_context) contents = [from_acp_content(block, fs=None, normalizer=normalizer) for block in prompt] + raw_user_prompt_text = _content_items_to_text(contents) # Split slash commands from content and execute local commands. # Commands inject expanded prompts into the SessionPool per-session @@ -662,6 +700,9 @@ async def handle_prompt( # noqa: PLR0915 # sole publication point — the ACPEventConverter reconstructs # the content blocks from meta. message_id = str(uuid.uuid4()) + if acp_session is not None: + acp_session.latest_user_prompt_text = raw_user_prompt_text + acp_session.latest_user_prompt_message_id = message_id # Build ACP meta from content blocks for the EventBus event. from wolfharness_server.acp_server.event_converter import ACPUserMessageMeta @@ -680,6 +721,7 @@ async def handle_prompt( # noqa: PLR0915 mode=delivery_mode, input_provider=input_provider, message_id=message_id, + deps=acp_session, meta=acp_meta, ) # Legacy clients (no turn_complete support) block until the run finishes @@ -826,6 +868,7 @@ async def close_session(self, session_id: str) -> None: # Stop the event consumer (mixin's stop handles cancellation + unsubscribe) await self.stop_event_consumer(session_id) + await self._viking_archive.flush_session(session_id) # Signal EventBus to close session if session_pool is not None: @@ -888,13 +931,35 @@ async def _emit_user_message_chunks( update=chunk, ) try: - await self.client.session_update(notification) + await self._archive_and_send_update( + notification, + consumer_session_id=session_id, + source_session_id=session_id, + event_id=f"direct-user-message:{message_id or 'unknown'}", + ) except (ConnectionResetError, BrokenPipeError, anyio.ClosedResourceError): logger.debug( "Failed to send user_message_chunk", session_id=session_id, ) + async def _archive_and_send_update( + self, + notification: Any, + *, + consumer_session_id: str, + source_session_id: str, + event_id: Any, + ) -> None: + """Best-effort archive before forwarding an ACP update to the client.""" + await self._viking_archive.record_update( + consumer_session_id=consumer_session_id, + source_session_id=source_session_id, + event_id=event_id, + update=notification.update, + ) + await self.client.session_update(notification) + class _ACPSessionProxy: """Lightweight proxy providing the subset of ACPSession that ACPInputProvider needs. diff --git a/src/wolfharness_server/acp_server/server.py b/src/wolfharness_server/acp_server/server.py index a1d37036e..9e7e3ffb8 100644 --- a/src/wolfharness_server/acp_server/server.py +++ b/src/wolfharness_server/acp_server/server.py @@ -20,6 +20,7 @@ from wolfharness_server import BaseServer from wolfharness_server.acp_server.acp_agent import AgentPoolACPAgent from wolfharness_server.acp_server.session_manager import ACPSessionManager +from wolfharness_server.acp_server.viking_archive import ACPVikingEventArchive if TYPE_CHECKING: @@ -304,6 +305,8 @@ async def _start_async(self) -> None: # Create a shared session manager so WebSocket disconnects can clean up # all sessions for the dropped connection via close_all_sessions_for_connection(). session_manager = ACPSessionManager(pool=self.pool) + archive_config = getattr(self.pool.manifest.session_pool, "acp_viking_archive", None) + viking_archive = ACPVikingEventArchive.from_config(archive_config) create_acp_agent = functools.partial( AgentPoolACPAgent, default_agent=default_agent, @@ -313,6 +316,7 @@ async def _start_async(self) -> None: subagent_display_mode=self.subagent_display_mode, raw_input_mode=self.raw_input_mode, session_manager=session_manager, + viking_archive=viking_archive, ) async def on_disconnect(conn: AgentSideConnection) -> None: @@ -322,9 +326,9 @@ async def on_disconnect(conn: AgentSideConnection) -> None: await session_manager.close_all_sessions_for_connection(connection_id) debug_file = self.debug_file if self.debug_messages else None - observers = None + observers = [] if self.show_events or self.show_events_detailed: - observers = [_acp_event_observer(show_detailed=self.show_events_detailed)] + observers.append(_acp_event_observer(show_detailed=self.show_events_detailed)) self.log.info("ACP server started") try: await serve( @@ -333,7 +337,7 @@ async def on_disconnect(conn: AgentSideConnection) -> None: shutdown_event=self._shutdown_event, debug_file=debug_file, on_disconnect=on_disconnect, - observers=observers, + observers=observers or None, ) except asyncio.CancelledError: self.log.info("ACP server shutdown requested") @@ -342,6 +346,8 @@ async def on_disconnect(conn: AgentSideConnection) -> None: self.log.info("ACP server shutdown requested") except Exception: self.log.exception("ACP server error") + finally: + await viking_archive.close() def stop(self) -> None: """Stop the ACP server. diff --git a/src/wolfharness_server/acp_server/session.py b/src/wolfharness_server/acp_server/session.py index d4dc219c5..ea8943b13 100644 --- a/src/wolfharness_server/acp_server/session.py +++ b/src/wolfharness_server/acp_server/session.py @@ -211,6 +211,12 @@ class ACPSession( elicitation requests for crash recovery. """ + latest_user_prompt_text: str = "" + """Latest raw ACP user prompt text for capability-level ingestion fallback.""" + + latest_user_prompt_message_id: str = "" + """Message id associated with ``latest_user_prompt_text``.""" + def __post_init__(self) -> None: """Initialize session state and set up providers.""" self.mcp_servers = self.mcp_servers or [] diff --git a/src/wolfharness_server/acp_server/viking_archive.py b/src/wolfharness_server/acp_server/viking_archive.py new file mode 100644 index 000000000..b54543b05 --- /dev/null +++ b/src/wolfharness_server/acp_server/viking_archive.py @@ -0,0 +1,384 @@ +"""Viking archive for ACP session updates.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +import os +import re +from typing import Any + +import logfire + +from wolfharness.log import get_logger + + +logger = get_logger(__name__) + +_ERROR_TYPES = (RuntimeError, OSError, TimeoutError, ValueError, TypeError, ImportError) +_MIN_SIGNAL_CJK_CHARS = 4 +_MIN_SIGNAL_ALNUM_CHARS = 6 +_MIN_SIGNAL_TOTAL_CHARS = 12 +_ACK_RE = re.compile( + r"^(?:ok|okay|k|yes|yep|no|nope|thanks|thank you|thx|done|" + r"收到|好的|好|嗯|可以|继续|不用|不需要|没了|好了)[.!?\u3002\uff01\uff1f\s]*$", + re.IGNORECASE, +) + + +@dataclass +class ACPVikingEventArchive: + """Asynchronous best-effort archive for ACP ``SessionUpdate`` payloads.""" + + enabled: bool = False + url: str | None = None + api_key: str | None = None + user: str | None = None + session_prefix: str = "iroot-acp-session" + batch_size: int = 25 + flush_on_turn_complete: bool = True + transcript_enabled: bool = True + transcript_keep_recent_count: int = 0 + _client: Any = field(default=None, init=False, repr=False) + _pending_update_count: dict[str, int] = field(default_factory=dict, init=False, repr=False) + _transcript_pending: dict[str, list[dict[str, Any]]] = field( + default_factory=dict, + init=False, + repr=False, + ) + _chunk_buffers: dict[str, dict[tuple[str, str], dict[str, Any]]] = field( + default_factory=dict, + init=False, + repr=False, + ) + _tasks: set[asyncio.Task[None]] = field(default_factory=set, init=False, repr=False) + _lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False) + + @classmethod + def from_config(cls, config: Any) -> ACPVikingEventArchive: + """Build an archive from manifest config, expanding env vars lazily.""" + if config is None or getattr(config, "enabled", False) is not True: + return cls(enabled=False) + api_key = _expand(getattr(config, "api_key", None) or os.getenv("VIKING_MCP_API_KEY")) + url = _expand(getattr(config, "url", None) or os.getenv("VIKING_MCP_URL")) + if not url: + logger.warning("ACP Viking archive disabled: missing Viking URL") + return cls(enabled=False) + user = _expand(getattr(config, "user", None) or os.getenv("VIKING_MCP_USER")) + return cls( + enabled=True, + url=url, + api_key=api_key, + user=user, + session_prefix=str(getattr(config, "session_prefix", "iroot-acp-session")), + batch_size=int(getattr(config, "batch_size", 25)), + flush_on_turn_complete=bool(getattr(config, "flush_on_turn_complete", True)), + transcript_enabled=bool(getattr(config, "transcript_enabled", True)), + transcript_keep_recent_count=int(getattr(config, "transcript_keep_recent_count", 0)), + ) + + async def record_update( + self, + *, + consumer_session_id: str, + source_session_id: str, + event_id: Any, + update: Any, + ) -> None: + """Buffer an ACP update for readable transcript archival.""" + if not self.enabled: + return + _ = event_id + try: + payload = _dump_update(update) + event_type = str(payload.get("sessionUpdate") or payload.get("session_update") or "") + session_id = source_session_id or consumer_session_id + async with self._lock: + if self.transcript_enabled: + self._buffer_transcript_update_locked(session_id, payload, event_type) + pending_count = self._pending_update_count.get(session_id, 0) + 1 + self._pending_update_count[session_id] = pending_count + should_flush = pending_count >= self.batch_size or ( + self.flush_on_turn_complete and event_type == "turn_complete" + ) + if should_flush: + self.schedule_flush(session_id) + except _ERROR_TYPES: + logger.warning("ACP Viking archive record failed", exc_info=True) + + def schedule_flush(self, session_id: str) -> None: + """Schedule a background flush for one session.""" + if not self.enabled: + return + task = asyncio.create_task(self._flush_session_with_span(session_id)) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + async def _flush_session_with_span(self, session_id: str) -> None: + with logfire.span("acp_viking_archive.flush_session", session_id=session_id): + await self.flush_session(session_id) + + async def flush_session(self, session_id: str) -> None: + """Flush buffered transcript messages for one session to Viking.""" + async with self._lock: + pending_count = self._pending_update_count.pop(session_id, 0) + transcript_messages = self._pop_transcript_locked(session_id) + if not transcript_messages: + return + try: + client = await self._ensure_client() + await self._write_transcript( + client, + session_id, + transcript_messages, + ) + except _ERROR_TYPES: + async with self._lock: + if pending_count: + self._pending_update_count[session_id] = ( + self._pending_update_count.get(session_id, 0) + pending_count + ) + if transcript_messages: + self._transcript_pending.setdefault(session_id, transcript_messages[:0])[:0] = ( + transcript_messages + ) + logger.warning("ACP Viking archive flush failed", session_id=session_id, exc_info=True) + + async def flush_all(self) -> None: + """Flush all buffered events and await in-flight writes.""" + session_ids = set(self._pending_update_count) | set(self._transcript_pending) + await asyncio.gather(*(self.flush_session(session_id) for session_id in session_ids)) + if self._tasks: + await asyncio.gather(*self._tasks, return_exceptions=True) + + async def close(self) -> None: + """Flush buffered events and close the Viking client.""" + await self.flush_all() + if self._client is not None: + close = getattr(self._client, "close", None) + if close is not None: + await close() + self._client = None + + async def _ensure_client(self) -> Any: + if self._client is not None: + return self._client + from openviking_sdk import AsyncHTTPClient + + client = AsyncHTTPClient(url=self.url, api_key=self.api_key, user=self.user) + await client.initialize() + self._client = client + if self.user is None: + await self._resolve_user_from_health(client) + return client + + async def _resolve_user_from_health(self, client: Any) -> None: + """Best-effort user resolution for user-scoped archive URIs.""" + try: + response = await client._request("GET", "/health") + data = response.json() if hasattr(response, "json") else response + if isinstance(data, dict): + user_id = data.get("user_id") + if isinstance(user_id, str) and user_id.strip(): + self.user = user_id.strip() + except _ERROR_TYPES: + logger.debug("ACP Viking archive user resolution failed", exc_info=True) + + def _archive_session_id(self, session_id: str) -> str: + return f"{self.session_prefix}-{_safe_identifier(session_id)}" + + def _archive_base_uri(self, session_id: str) -> str: + user_id = _safe_identifier(self.user or "default") + return f"viking://user/{user_id}/sessions/{self._archive_session_id(session_id)}" + + def _buffer_transcript_update_locked( + self, + session_id: str, + payload: dict[str, Any], + event_type: str, + ) -> None: + text = _content_text(payload) + message_id = str( + payload.get("messageId") + or payload.get("message_id") + or f"{event_type}:{self._pending_update_count.get(session_id, 0)}" + ) + if event_type == "user_message_chunk": + self._append_chunk_locked(session_id, "user", message_id, text) + return + if event_type in ("agent_message_chunk", "agent_thought_chunk"): + self._append_chunk_locked(session_id, "assistant", message_id, text) + return + if event_type in ("tool_call", "tool_call_update"): + self._flush_chunk_buffers_locked(session_id) + tool_part = _tool_part_from_update(payload) + if tool_part: + self._append_transcript_locked(session_id, "assistant", [tool_part]) + return + if event_type == "turn_complete": + self._flush_chunk_buffers_locked(session_id) + + def _append_chunk_locked( + self, + session_id: str, + role: str, + message_id: str, + text: str, + ) -> None: + if not text: + return + buffers = self._chunk_buffers.setdefault(session_id, {}) + key = (role, message_id) + buffer = buffers.get(key) + if buffer is None: + buffer = {"role": role, "parts": [{"type": "text", "text": ""}]} + buffers[key] = buffer + buffer["parts"][0]["text"] += text + + def _flush_chunk_buffers_locked(self, session_id: str) -> None: + buffers = self._chunk_buffers.pop(session_id, {}) + for message in buffers.values(): + parts = message.get("parts") + if isinstance(parts, list) and parts: + text = parts[0].get("text") if isinstance(parts[0], dict) else "" + if isinstance(text, str) and _should_capture_text(text): + self._transcript_pending.setdefault(session_id, []).append(message) + + def _append_transcript_locked( + self, + session_id: str, + role: str, + parts: list[dict[str, Any]], + ) -> None: + if parts: + self._transcript_pending.setdefault(session_id, []).append({ + "role": role, + "parts": parts, + }) + + def _pop_transcript_locked(self, session_id: str) -> list[dict[str, Any]]: + self._flush_chunk_buffers_locked(session_id) + return self._transcript_pending.pop(session_id, []) + + async def _write_transcript( + self, + client: Any, + session_id: str, + messages: list[dict[str, Any]], + ) -> Any: + archive_session_id = self._archive_session_id(session_id) + try: + await client.create_session( + session_id=archive_session_id, + source_type=self.session_prefix, + ) + except TypeError: + try: + await client.create_session(session_id=archive_session_id) + except _ERROR_TYPES: + logger.debug( + "ACP Viking transcript create_session skipped", + session_id=archive_session_id, + exc_info=True, + ) + except _ERROR_TYPES: + logger.debug( + "ACP Viking transcript create_session skipped", + session_id=archive_session_id, + exc_info=True, + ) + + for message in messages: + if "parts" in message: + await client.add_message( + archive_session_id, + message["role"], + parts=message["parts"], + ) + else: + await client.add_message( + archive_session_id, + message["role"], + content=str(message.get("content", "")), + ) + + commit_kwargs: dict[str, Any] = {} + if self.transcript_keep_recent_count > 0: + commit_kwargs["keep_recent_count"] = self.transcript_keep_recent_count + return await client.commit_session(archive_session_id, **commit_kwargs) + + +def _expand(value: str | None) -> str | None: + return os.path.expandvars(value) if isinstance(value, str) else value + + +def _safe_identifier(value: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]+", "-", str(value)).strip("-") or "unknown" + + +def _dump_update(update: Any) -> dict[str, Any]: + if hasattr(update, "model_dump"): + dumped = update.model_dump(mode="json", by_alias=True, exclude_none=True) + return dumped if isinstance(dumped, dict) else {"value": dumped} + if isinstance(update, dict): + return update + return {"value": str(update)} + + +def _content_text(payload: dict[str, Any]) -> str: + content = payload.get("content") + if isinstance(content, dict): + text = content.get("text") + return text if isinstance(text, str) else "" + text = payload.get("text") + return text if isinstance(text, str) else "" + + +def _should_capture_text(text: str) -> bool: + compact = re.sub(r"\s+", " ", str(text or "")).strip() + if not compact: + return False + if compact.lower().startswith("[openviking-memory]"): + return False + if _ACK_RE.match(compact): + return False + if not re.search(r"[a-z0-9\u3400-\u9fff]", compact, re.IGNORECASE): + return False + cjk = len(re.findall(r"[\u3400-\u9fff]", compact)) + alnum = len(re.findall(r"[a-z0-9]", compact, re.IGNORECASE)) + return ( + cjk >= _MIN_SIGNAL_CJK_CHARS + or alnum >= _MIN_SIGNAL_ALNUM_CHARS + or len(compact) >= _MIN_SIGNAL_TOTAL_CHARS + ) + + +def _tool_part_from_update(payload: dict[str, Any]) -> dict[str, Any]: + tool_id = payload.get("toolCallId") or payload.get("tool_call_id") + tool_name = payload.get("toolName") or payload.get("tool_name") or payload.get("title") + if not isinstance(tool_name, str) or not tool_name.strip(): + kind = payload.get("kind") + tool_name = kind if isinstance(kind, str) and kind.strip() else "tool" + + part: dict[str, Any] = { + "type": "tool", + "tool_name": tool_name, + "tool_status": _tool_status(payload.get("status")), + } + if isinstance(tool_id, str) and tool_id.strip(): + part["tool_id"] = tool_id + raw_input = payload.get("rawInput") or payload.get("raw_input") + if raw_input is not None: + part["tool_input"] = raw_input + raw_output = payload.get("rawOutput") or payload.get("raw_output") + if raw_output is not None: + part["tool_output"] = raw_output + return part + + +def _tool_status(status: Any) -> str: + if status in ("completed", "success", "succeeded"): + return "completed" + if status in ("failed", "error"): + return "error" + return "running" diff --git a/tests/capabilities/dcp/test_integration.py b/tests/capabilities/dcp/test_integration.py index 2a944e559..95762ef71 100644 --- a/tests/capabilities/dcp/test_integration.py +++ b/tests/capabilities/dcp/test_integration.py @@ -1677,3 +1677,179 @@ async def test_dcp_disabled_returns_request_context_unchanged() -> None: state_before = cap._fallback_state.current_turn await cap.before_run(ctx) assert cap._fallback_state.current_turn == state_before + + +# ============================================================================= +# Phase 5 — auto-compact on CRITICAL watermark +# ============================================================================= + + +async def test_auto_compact_critical_compacts_persistent_conversation() -> None: + """Auto-compaction runs the manifest pipeline on the persistent conversation. + + Given: auto_compact_on_critical=True, a manifest compaction pipeline, + and a native agent conversation attached to the run context. + When: before_model_request reaches CRITICAL watermark. + Then: compact_conversation is invoked on the agent conversation and the + critical_compacted flag is set (fires only once). + """ + from wolfharness.messaging.compaction import KeepLastMessages + + cap = _make_capability( + auto_compact_on_critical=True, + max_context_tokens=100, + info_threshold=0.10, + warning_threshold=0.20, + critical_threshold=0.50, + ) + + session_pool = MagicMock() + ctx = _make_run_context(session_data=_make_session_data(), session_pool=session_pool) + manifest = MagicMock() + manifest.get_compaction_pipeline.return_value = KeepLastMessages(count=2) + ctx.deps.node.host_context.manifest = manifest + conversation = MagicMock() + conversation.get_history.return_value = [] + ctx.deps.native_agent.conversation = conversation + + messages = [_make_request([UserPromptPart(content="Prompt")]) for _ in range(60)] + req_ctx = _make_request_context(messages) + + result = await cap.before_model_request(ctx, req_ctx) + + state = _get_dcp_state(cap, ctx) + assert state.watermark_level >= WatermarkLevel.CRITICAL + assert state.critical_compacted is True + conversation.get_history.assert_called() + assert isinstance(result, ModelRequestContext) + + +async def test_auto_compact_critical_rearms_after_pressure_drops() -> None: + """Auto-compaction re-arms once pressure falls below CRITICAL. + + Given: auto_compact_on_critical=True and a CRITICAL watermark that + fires a compaction. + When: a later turn's watermark drops below CRITICAL. + Then: critical_compacted resets to False, so a second CRITICAL episode + compacts again (fixes compact-only-once-per-session). + """ + from wolfharness.messaging.compaction import KeepLastMessages + + cap = _make_capability( + auto_compact_on_critical=True, + max_context_tokens=100, + info_threshold=0.10, + warning_threshold=0.20, + critical_threshold=0.50, + ) + + session_pool = MagicMock() + manifest = MagicMock() + manifest.get_compaction_pipeline.return_value = KeepLastMessages(count=2) + + # One shared SessionData keeps the per-session DCPState across turns. + session_data = _make_session_data() + + # Turn 1: cumulative usage 60/100 -> CRITICAL -> compacts, flag set. + ctx1 = _make_run_context( + session_data=session_data, + session_pool=session_pool, + usage_input_tokens=60, + ) + ctx1.deps.node.host_context.manifest = manifest + conversation1 = MagicMock() + conversation1.get_history.return_value = [] + ctx1.deps.native_agent.conversation = conversation1 + req_ctx1 = _make_request_context([ + _make_request([UserPromptPart(content="Prompt")]) for _ in range(60) + ]) + await cap.before_model_request(ctx1, req_ctx1) + state = _get_dcp_state(cap, ctx1) + assert state.watermark_level >= WatermarkLevel.CRITICAL + assert state.critical_compacted is True + + # Turn 2: cumulative usage drops to 10/100 -> NORMAL -> re-arms. + ctx2 = _make_run_context( + session_data=session_data, + session_pool=session_pool, + usage_input_tokens=10, + ) + req_ctx2 = _make_request_context([_make_request([UserPromptPart(content="Prompt")])]) + await cap.before_model_request(ctx2, req_ctx2) + assert state.watermark_level < WatermarkLevel.CRITICAL + assert state.critical_compacted is False + + # Turn 3: cumulative usage back to 60/100 -> CRITICAL -> compacts again. + ctx3 = _make_run_context( + session_data=session_data, + session_pool=session_pool, + usage_input_tokens=70, + ) + ctx3.deps.node.host_context.manifest = manifest + conversation3 = MagicMock() + conversation3.get_history.return_value = [] + ctx3.deps.native_agent.conversation = conversation3 + req_ctx3 = _make_request_context([ + _make_request([UserPromptPart(content="Prompt")]) for _ in range(60) + ]) + await cap.before_model_request(ctx3, req_ctx3) + assert state.watermark_level >= WatermarkLevel.CRITICAL + assert state.critical_compacted is True + conversation3.get_history.assert_called() + + +async def test_auto_compact_critical_skipped_when_disabled() -> None: + """Auto-compaction does not fire when auto_compact_on_critical=False (default). + + Given: a CRITICAL watermark but auto_compact_on_critical not enabled. + When: before_model_request runs. + Then: the manifest pipeline is never fetched and critical_compacted stays False. + """ + cap = _make_capability( + max_context_tokens=100, + info_threshold=0.10, + warning_threshold=0.20, + critical_threshold=0.50, + ) + + ctx = _make_run_context(session_data=_make_session_data(), session_pool=MagicMock()) + ctx.deps.node.host_context.manifest = MagicMock() + + messages = [_make_request([UserPromptPart(content="Prompt")]) for _ in range(60)] + req_ctx = _make_request_context(messages) + + await cap.before_model_request(ctx, req_ctx) + + state = _get_dcp_state(cap, ctx) + assert state.watermark_level >= WatermarkLevel.CRITICAL + assert state.critical_compacted is False + + +async def test_auto_compact_critical_skipped_without_manifest_pipeline() -> None: + """Auto-compaction is skipped when the manifest has no compaction pipeline. + + Given: auto_compact_on_critical=True at CRITICAL watermark but no manifest + compaction pipeline configured. + When: before_model_request runs. + Then: no crash, critical_compacted stays False. + """ + cap = _make_capability( + auto_compact_on_critical=True, + max_context_tokens=100, + info_threshold=0.10, + warning_threshold=0.20, + critical_threshold=0.50, + ) + + ctx = _make_run_context(session_data=_make_session_data(), session_pool=MagicMock()) + ctx.deps.node.host_context.manifest = MagicMock() + ctx.deps.node.host_context.manifest.get_compaction_pipeline.return_value = None + + messages = [_make_request([UserPromptPart(content="Prompt")]) for _ in range(60)] + req_ctx = _make_request_context(messages) + + result = await cap.before_model_request(ctx, req_ctx) + + state = _get_dcp_state(cap, ctx) + assert state.critical_compacted is False + assert isinstance(result, ModelRequestContext) diff --git a/tests/capabilities/test_mcp_server_cap.py b/tests/capabilities/test_mcp_server_cap.py index 3af125d41..c4ea74706 100644 --- a/tests/capabilities/test_mcp_server_cap.py +++ b/tests/capabilities/test_mcp_server_cap.py @@ -80,6 +80,10 @@ async def __aenter__(self) -> Self: async def __aexit__(self, *args: object) -> None: self._connected = False + @property + def connected(self) -> bool: + return self._connected + async def list_tools(self) -> list[Any]: if not self._connected: raise RuntimeError("Not connected") diff --git a/tests/capabilities/test_review_fixes.py b/tests/capabilities/test_review_fixes.py index b2a7ee454..80fb16c70 100644 --- a/tests/capabilities/test_review_fixes.py +++ b/tests/capabilities/test_review_fixes.py @@ -55,6 +55,10 @@ async def __aexit__(self, *args: object) -> None: self._connected = False self._exited = True + @property + def connected(self) -> bool: + return self._connected + async def list_tools(self) -> list[Any]: if not self._connected: raise RuntimeError("Not connected") diff --git a/tests/capabilities/test_tool_schema_overlap.py b/tests/capabilities/test_tool_schema_overlap.py index 40ec0b90c..f570f79ec 100644 --- a/tests/capabilities/test_tool_schema_overlap.py +++ b/tests/capabilities/test_tool_schema_overlap.py @@ -175,6 +175,11 @@ class FakeMcpClient: def __init__(self, raw_tools: list[RawMcpTool]) -> None: self._raw_tools = raw_tools + @property + def connected(self) -> bool: + """Mirror ``MCPClient.connected`` — the double is always ready.""" + return True + async def list_tools(self) -> list[RawMcpTool]: return list(self._raw_tools) diff --git a/tests/capabilities/test_tool_schema_overlap_integration.py b/tests/capabilities/test_tool_schema_overlap_integration.py index c22002034..cbef3ca42 100644 --- a/tests/capabilities/test_tool_schema_overlap_integration.py +++ b/tests/capabilities/test_tool_schema_overlap_integration.py @@ -136,6 +136,11 @@ def __init__(self, raw_tools: list[RawMcpTool]) -> None: self._raw_tools = raw_tools self.calls: list[tuple[str, dict[str, Any]]] = [] + @property + def connected(self) -> bool: + """Mirror ``MCPClient.connected`` — the double is always ready.""" + return True + async def list_tools(self) -> list[RawMcpTool]: return list(self._raw_tools) @@ -236,8 +241,10 @@ async def test_two_servers_independent_overrides(self) -> None: # prefixed, web-b is not. Identity metadata carries the raw names. client_a = RecordingFakeMcpClient([RawMcpTool(name="search", description="A search")]) client_b = RecordingFakeMcpClient([RawMcpTool(name="search", description="B search")]) - cap_a = McpServerCap(MagicMock(), name="web-a", client=client_a, tool_prefix="web_a") # type: ignore[arg-type] - cap_b = McpServerCap(MagicMock(), name="web-b", client=client_b) # type: ignore[arg-type] + cap_a = McpServerCap( # type: ignore[arg-type] + MagicMock(tool_prefix=None), name="web-a", client=client_a, tool_prefix="web_a" + ) + cap_b = McpServerCap(MagicMock(tool_prefix=None), name="web-b", client=client_b) # type: ignore[arg-type] inner_a = await cap_a.get_toolset()(_ctx()) inner_b = await cap_b.get_toolset()(_ctx()) assert inner_a is not None @@ -355,7 +362,9 @@ def _override() -> dict[str, Any]: async def _drive(self, prefix: str | None) -> tuple[RecordingFakeMcpClient, ToolsetTool[Any]]: raw = RawMcpTool(name="get_weather", description="Get the weather") client = RecordingFakeMcpClient([raw]) - server_cap = McpServerCap(MagicMock(), name="weather", client=client, tool_prefix=prefix) # type: ignore[arg-type] + server_cap = McpServerCap( # type: ignore[arg-type] + MagicMock(tool_prefix=None), name="weather", client=client, tool_prefix=prefix + ) inner = await server_cap.get_toolset()(_ctx()) assert inner is not None diff --git a/tests/capabilities/viking/test_live_e2e_manual.py b/tests/capabilities/viking/test_live_e2e_manual.py new file mode 100644 index 000000000..59fe28964 --- /dev/null +++ b/tests/capabilities/viking/test_live_e2e_manual.py @@ -0,0 +1,84 @@ +"""Manual live end-to-end: real Viking server, real capability tools. + +Run explicitly (requires live server): + uv run pytest tests/capabilities/viking/test_live_e2e_manual.py -q +""" + +import json +import pathlib +import tempfile +import uuid + +import pytest + +from wolfharness.capabilities.viking import VikingCapability +from wolfharness.capabilities.viking.tools import build_tools + + +def _get_tool(tools: list, name: str): + for t in tools: + if getattr(t, "__name__", "") == name: + return t + raise AssertionError(f"tool {name} not found") + + +class _Ctx: + deps = None + + +@pytest.mark.asyncio +@pytest.mark.real_mcp +async def test_live_upload_tree_then_link_relations(allow_model_requests: None) -> None: + conf = json.loads(pathlib.Path.home().joinpath(".openviking/ovcli.conf").read_text()) + ns = f"e2e_live_{uuid.uuid4().hex[:6]}" + root = pathlib.Path(tempfile.mkdtemp(prefix="ov_live_")) + (root / "Fault").mkdir() + (root / "Timeout.md").write_text("# 超时故障", encoding="utf-8") + (root / "Fault" / "NoStart.md").write_text("# 无法启动", encoding="utf-8") + (root / "Fault" / "BlackSmoke.md").write_text("# 冒黑烟", encoding="utf-8") + rel_file = root / "backlinks_index.json" + rel_file.write_text( + json.dumps({ + f"viking://resources/{ns}/Fault/NoStart.md": [ + f"viking://resources/{ns}/Fault/BlackSmoke.md" + ], + f"viking://resources/{ns}/Timeout.md": [f"viking://resources/{ns}/Fault/NoStart.md"], + }), + encoding="utf-8", + ) + + cap = VikingCapability( + mode="all", + url=conf["url"], + api_key=conf["api_key"], + enable_link=True, + timeout=180.0, + ) + async with cap: + tools = build_tools(cap) + upload = _get_tool(tools, "viking_upload_tree") + up_res = await upload(_Ctx(), path=str(root), to=f"viking://resources/{ns}") + print("\nLIVE upload FULL:", up_res.return_value) + assert not up_res.return_value.lower().startswith("viking_upload_tree error") + + links = _get_tool(tools, "viking_link_relations") + lk_res = await links( + _Ctx(), relations_file=str(rel_file), namespace_base=f"viking://resources/{ns}" + ) + print("LIVE link_relations FULL:", lk_res.return_value) + assert not lk_res.return_value.lower().startswith("viking_link_relations error") + + client = await cap._ensure_client() + r1 = await client.relations(f"viking://resources/{ns}/Fault/NoStart/NoStart.md") + r2 = await client.relations(f"viking://resources/{ns}/Timeout/Timeout.md") + r3 = await client.relations(f"viking://resources/{ns}/Fault/BlackSmoke/BlackSmoke.md") + print("LIVE relations NoStart:", json.dumps(r1, ensure_ascii=False)) + print("LIVE relations Timeout:", json.dumps(r2, ensure_ascii=False)) + print("LIVE relations BlackSmoke:", json.dumps(r3, ensure_ascii=False)) + assert r1, f"missing edges r1={r1}" + assert r2, f"missing edges r2={r2}" + assert r3, f"missing edges r3={r3}" + assert "linked=3, failed=0" in lk_res.return_value, f"link failures: {lk_res.return_value}" + + await client.rm(f"viking://resources/{ns}", recursive=True) + print("LIVE E2E PASSED") diff --git a/tests/capabilities/viking/test_resource_protocol.py b/tests/capabilities/viking/test_resource_protocol.py new file mode 100644 index 000000000..ad6c30f79 --- /dev/null +++ b/tests/capabilities/viking/test_resource_protocol.py @@ -0,0 +1,529 @@ +"""Unit tests for the ResourceAccess + ResourceTemplateAccess surface of WikiBuildCapability. + +OPA/OPS/OPL tickets are exposed as MCP resources. + +``xeno_adp_agentic`` is a separate package that is not installed in the +agentpool environment, so the lazy imports inside ``_ensure_tools()`` are +faked via ``sys.modules`` — the tested surface is the capability's resource +protocol mapping, not the wiki storage layer. +""" + +from __future__ import annotations + +from pathlib import Path +import types +from typing import Any, ClassVar + +import pytest + +from wolfharness.capabilities.resource_protocols import ( + CompletionArgument, + ResourceAccess, + ResourceTemplateAccess, + TextResourceContent, +) +from wolfharness.capabilities.viking.wiki_build import WikiBuildCapability + + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# Fake xeno_adp_agentic surface (mirrors the real WikiBuildTools contract) +# --------------------------------------------------------------------------- + + +def _parse_frontmatter(content: str) -> dict[str, str]: + """Read ``key: value`` lines from a leading YAML frontmatter block.""" + frontmatter: dict[str, str] = {} + if not content.startswith("---"): + return frontmatter + end = content.find("\n---", 3) + if end < 0: + return frontmatter + for line in content[3:end].splitlines(): + key, separator, value = line.partition(":") + if separator: + frontmatter[key.strip()] = value.strip() + return frontmatter + + +class FakeWikiBuildTools: + """In-process stand-in for ``WikiBuildTools`` backed by a temp wiki root. + + Mirrors the record/URI shapes of ``get_opas`` / ``get_ops`` / ``get_opls`` + / ``read_resource`` from ``xeno_adp_agentic.wiki.serve.opa``. + """ + + root_uri = "viking://resources/test_ns" + _DIRS: ClassVar[dict[str, str]] = {"OPA": "OP/OpA", "OPS": "OP/OpS", "OPL": "OP/OpL"} + + def __init__( + self, + wiki_root: str, + library_root: str, + *, + case_root: str | None = None, + faultannotated_root: str | None = None, + bom_root: str | None = None, + build_logger: Any = None, + ) -> None: + """Create the fake, preparing the ``OP/`` directory tree.""" + del case_root, faultannotated_root, bom_root, build_logger + self.root = Path(wiki_root) + self.library = Path(library_root) + for rel_dir in self._DIRS.values(): + (self.root / rel_dir).mkdir(parents=True, exist_ok=True) + + def write_ticket( + self, + kind: str, + record_id: str, + *, + title: str, + status: str = "pending", + category: str = "", + ) -> str: + """Seed one ticket markdown file and return its URI.""" + rel_dir = Path(self._DIRS[kind]) + if category: + rel_dir = rel_dir / category + path = self.root / rel_dir + path.mkdir(parents=True, exist_ok=True) + target = path / f"{record_id}.md" + category_line = f"category: {category}\n" if category else "" + target.write_text( + f"---\nid: {record_id}\ntitle: {title}\nstatus: {status}\n{category_line}---\n" + f"# {title}\n\n正文内容。\n", + encoding="utf-8", + ) + return f"{self.root_uri}/{target.relative_to(self.root).as_posix()}" + + def update_ops( + self, + ops_id: str, + *, + title: str | None = None, + analysis: str | None = None, + solution: str | None = None, + evidence_uris: list[str] | None = None, + related_uris: list[str] | None = None, + candidate_content: str | None = None, + candidate_operations: list[dict[str, object]] | None = None, + expected_sha256: str | None = None, + status: str | None = None, + reviewed_by: str = "", + review_notes: str = "", + ) -> dict[str, str]: + """Record a patch call and return a shaped OPS result.""" + self.last_update_ops: dict[str, object] = { + "ops_id": ops_id, + "title": title, + "analysis": analysis, + "solution": solution, + "evidence_uris": evidence_uris, + "related_uris": related_uris, + "candidate_content": candidate_content, + "candidate_operations": candidate_operations, + "expected_sha256": expected_sha256, + "status": status, + "reviewed_by": reviewed_by, + "review_notes": review_notes, + } + return { + "ops_id": ops_id, + "uri": f"{self.root_uri}/OP/OpS/{ops_id}.md", + "parent_opa": "opa-001", + "target_uri": f"{self.root_uri}/Component/001", + "status": status or "unconfirmed", + } + + def _scan(self, rel_dir: str, recursive: bool) -> list[tuple[Path, str]]: + base = self.root / rel_dir + if not base.exists(): + return [] + pattern = "**/*.md" if recursive else "*.md" + return [(p, p.read_text(encoding="utf-8")) for p in sorted(base.glob(pattern))] + + def get_opas( + self, + *, + target_uri: str = "", + status: str = "", + category: str = "", + limit: int = 50, + **_: object, + ) -> list[dict[str, str]]: + """Return OPA records matching the filters, mirroring the real tool.""" + records: list[dict[str, str]] = [] + for path, content in self._scan("OP/OpA", recursive=True): + fm = _parse_frontmatter(content) + if status and fm.get("status", "") != status: + continue + if category and fm.get("category", "") != category: + continue + if target_uri and fm.get("target_uri", "") != target_uri: + continue + records.append( + { + "opa_id": str(fm.get("id", path.stem)), + "title": str(fm.get("title", "")), + "status": str(fm.get("status", "pending")), + "category": str(fm.get("category", "")), + "uri": f"{self.root_uri}/{path.relative_to(self.root).as_posix()}", + }, + ) + if len(records) >= limit: + break + return records + + def get_ops( + self, + *, + parent_opa: str = "", + status: str = "", + limit: int = 50, + **_: object, + ) -> list[dict[str, str]]: + """Return OPS records, mirroring the real tool.""" + del parent_opa + records: list[dict[str, str]] = [] + for path, content in self._scan("OP/OpS", recursive=False): + fm = _parse_frontmatter(content) + if status and fm.get("status", "") != status: + continue + records.append( + { + "ops_id": str(fm.get("id", path.stem)), + "title": str(fm.get("title", "")), + "status": str(fm.get("status", "unconfirmed")), + "uri": f"{self.root_uri}/{path.relative_to(self.root).as_posix()}", + }, + ) + if len(records) >= limit: + break + return records + + def get_opls( + self, + *, + parent_opa: str = "", + status: str = "", + limit: int = 50, + **_: object, + ) -> list[dict[str, str]]: + """Return OPL records, mirroring the real tool.""" + del parent_opa + records: list[dict[str, str]] = [] + for path, content in self._scan("OP/OpL", recursive=False): + fm = _parse_frontmatter(content) + if status and fm.get("status", "") != status: + continue + records.append( + { + "opl_id": str(fm.get("id", path.stem)), + "title": str(fm.get("title", "")), + "status": str(fm.get("status", "unconfirmed")), + "uri": f"{self.root_uri}/{path.relative_to(self.root).as_posix()}", + }, + ) + if len(records) >= limit: + break + return records + + def read_resource(self, uri: str, line_numbers: bool = False) -> str | None: + """Read a ticket file by its ``viking://`` URI.""" + del line_numbers + relative = uri.removeprefix(self.root_uri + "/") + path = self.root / relative + if path.exists() and path.is_file(): + return path.read_text(encoding="utf-8") + return None + + +def _fake_xeno_modules() -> dict[str, types.ModuleType]: + """Build the ``xeno_adp_agentic`` module chain exposing the fakes. + + ``_ensure_tools()`` imports ``WikiBuildLogger`` and ``WikiBuildTools`` + from these paths; the parent packages must be present so the leaf + ``from ... import X`` statements resolve. + """ + + def _package(name: str) -> types.ModuleType: + module = types.ModuleType(name) + module.__path__ = [] # type: ignore[attr-defined] + return module + + build_logger = types.ModuleType("xeno_adp_agentic.wiki.build.build_logger") + build_logger.__dict__["WikiBuildLogger"] = type( # type: ignore[attr-defined] + "WikiBuildLogger", + (), + {"__init__": lambda self, log_dir: setattr(self, "log_dir", log_dir)}, + ) + build_tools = types.ModuleType("xeno_adp_agentic.wiki.serve.build_tools") + build_tools.__dict__["WikiBuildTools"] = FakeWikiBuildTools # type: ignore[attr-defined] + return { + "xeno_adp_agentic": _package("xeno_adp_agentic"), + "xeno_adp_agentic.wiki": _package("xeno_adp_agentic.wiki"), + "xeno_adp_agentic.wiki.build": _package("xeno_adp_agentic.wiki.build"), + "xeno_adp_agentic.wiki.serve": _package("xeno_adp_agentic.wiki.serve"), + "xeno_adp_agentic.wiki.build.build_logger": build_logger, + "xeno_adp_agentic.wiki.serve.build_tools": build_tools, + } + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def wiki_cap(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> WikiBuildCapability: + """Build a WikiBuildCapability whose lazy tools resolve to the fakes.""" + monkeypatch.setenv("WIKI_STORAGE_BACKEND", "local") + cap = WikiBuildCapability( + wiki_root=str(tmp_path / "wiki"), + library_root=str(tmp_path / "library"), + ) + cap._tools = FakeWikiBuildTools( + wiki_root=str(tmp_path / "wiki"), + library_root=str(tmp_path / "library"), + ) + assert isinstance(cap.tools, FakeWikiBuildTools) + return cap + + +# --------------------------------------------------------------------------- +# Protocol conformance +# --------------------------------------------------------------------------- + + +def test_implements_resource_protocols(wiki_cap: WikiBuildCapability) -> None: + """The capability satisfies both runtime-checkable resource protocols.""" + assert isinstance(wiki_cap, ResourceAccess) + assert isinstance(wiki_cap, ResourceTemplateAccess) + + +# --------------------------------------------------------------------------- +# ResourceAccess +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_resources_returns_opa_ops_opl_entries( + wiki_cap: WikiBuildCapability, +) -> None: + """list_resources surfaces one entry per seeded ticket, all kinds.""" + tools = wiki_cap.tools + assert isinstance(tools, FakeWikiBuildTools) + tools.write_ticket("OPA", "opa-001", title="压力判据缺失", category="conflict") + tools.write_ticket("OPA", "opa-002", title="散热器检查", category="gap") + tools.write_ticket("OPS", "ops-001", title="补充压力上限说明") + tools.write_ticket("OPL", "opl-001", title="提交共轨压力提案") + + entries = await wiki_cap.list_resources() + + assert len(entries) == 4 + by_uri = {e.uri: e for e in entries} + opa_uri = "viking://resources/test_ns/OP/OpA/conflict/opa-001.md" + assert opa_uri in by_uri + opa = by_uri[opa_uri] + assert opa.name == "OPA opa-001: 压力判据缺失" + assert "status=pending" in opa.description + assert "category=conflict" in opa.description + assert opa.mime_type == "text/markdown" + assert by_uri["viking://resources/test_ns/OP/OpS/ops-001.md"].name.startswith("OPS ops-001") + assert by_uri["viking://resources/test_ns/OP/OpL/opl-001.md"].name.startswith("OPL opl-001") + + +@pytest.mark.asyncio +async def test_list_resources_bounded_per_category(wiki_cap: WikiBuildCapability) -> None: + """list_resources caps each ticket kind at 50 entries.""" + tools = wiki_cap.tools + assert isinstance(tools, FakeWikiBuildTools) + for index in range(60): + tools.write_ticket("OPA", f"opa-{index:03d}", title=f"ticket {index}") + + entries = await wiki_cap.list_resources() + + assert len(entries) == 50 + + +@pytest.mark.asyncio +async def test_read_resource_returns_ticket_content( + wiki_cap: WikiBuildCapability, +) -> None: + """read_resource returns a TextResourceContent for a seeded ticket URI.""" + tools = wiki_cap.tools + assert isinstance(tools, FakeWikiBuildTools) + uri = tools.write_ticket("OPA", "opa-001", title="压力判据缺失") + + contents = await wiki_cap.read_resource(uri) + + assert contents is not None + assert len(contents) == 1 + item = contents[0] + assert isinstance(item, TextResourceContent) + assert item.uri == uri + assert item.mime_type == "text/markdown" + assert "压力判据缺失" in item.text + + +@pytest.mark.asyncio +async def test_read_resource_unknown_uri_returns_none( + wiki_cap: WikiBuildCapability, +) -> None: + """read_resource returns None for a URI that is not a ticket.""" + contents = await wiki_cap.read_resource( + "viking://resources/test_ns/OP/OpA/does-not-exist.md", + ) + assert contents is None + + +@pytest.mark.asyncio +async def test_resource_exists_true_and_false(wiki_cap: WikiBuildCapability) -> None: + """resource_exists mirrors read_resource success.""" + tools = wiki_cap.tools + assert isinstance(tools, FakeWikiBuildTools) + uri = tools.write_ticket("OPS", "ops-001", title="提案", status="unconfirmed") + + assert await wiki_cap.resource_exists(uri) is True + assert await wiki_cap.resource_exists("viking://resources/test_ns/OP/OpS/nope.md") is False + + +# --------------------------------------------------------------------------- +# ResourceTemplateAccess +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_list_resource_templates_declares_tree_templates( + wiki_cap: WikiBuildCapability, +) -> None: + """list_resource_templates declares OPA/OPS/OPL id templates.""" + templates = await wiki_cap.list_resource_templates() + + assert len(templates) == 3 + by_template = {t.uri_template: t for t in templates} + assert by_template["viking://resources/{namespace}/OP/OpA/{id}"].title == "OPA ticket by id" + assert by_template["viking://resources/{namespace}/OP/OpS/{id}"].title == "OPS ticket by id" + assert by_template["viking://resources/{namespace}/OP/OpL/{id}"].title == "OPL ticket by id" + assert all(t.mime_type == "text/markdown" for t in templates) + + +@pytest.mark.asyncio +async def test_complete_resource_template_returns_pending_opas( + wiki_cap: WikiBuildCapability, +) -> None: + """Completion on the OPA template suggests pending OPA ids, not resolved ones.""" + tools = wiki_cap.tools + assert isinstance(tools, FakeWikiBuildTools) + tools.write_ticket("OPA", "opa-101", title="共轨压力判据", status="pending") + tools.write_ticket("OPA", "opa-102", title="已闭环问题", status="resolved") + + result = await wiki_cap.complete_resource_template( + "viking://resources/{namespace}/OP/OpA/{id}", + CompletionArgument("id", ""), + context={"namespace": "test_ns"}, + ) + + assert result.values == ["opa-101 共轨压力判据"] + assert result.total == 1 + assert result.has_more is False + + +@pytest.mark.asyncio +async def test_complete_resource_template_filters_by_argument_value( + wiki_cap: WikiBuildCapability, +) -> None: + """Completion narrows suggestions by the current argument value.""" + tools = wiki_cap.tools + assert isinstance(tools, FakeWikiBuildTools) + tools.write_ticket("OPA", "opa-201", title="主泵压力异常", status="pending") + tools.write_ticket("OPA", "opa-202", title="液压油温过高", status="pending") + + result = await wiki_cap.complete_resource_template( + "viking://resources/{namespace}/OP/OpA/{id}", + CompletionArgument("id", "202"), + ) + + assert result.values == ["opa-202 液压油温过高"] + + +@pytest.mark.asyncio +async def test_complete_resource_template_ops_and_opl_suggest_own_ids( + wiki_cap: WikiBuildCapability, +) -> None: + """OPS/OPL templates complete their own record ids.""" + tools = wiki_cap.tools + assert isinstance(tools, FakeWikiBuildTools) + tools.write_ticket("OPS", "ops-301", title="建议一", status="unconfirmed") + tools.write_ticket("OPL", "opl-301", title="提案一", status="unconfirmed") + + ops_result = await wiki_cap.complete_resource_template( + "viking://resources/{namespace}/OP/OpS/{id}", + CompletionArgument("id", ""), + ) + opl_result = await wiki_cap.complete_resource_template( + "viking://resources/{namespace}/OP/OpL/{id}", + CompletionArgument("id", ""), + ) + + assert ops_result.values == ["ops-301 建议一"] + assert opl_result.values == ["opl-301 提案一"] + + +@pytest.mark.asyncio +async def test_complete_resource_template_unsupported_raises( + wiki_cap: WikiBuildCapability, +) -> None: + """Completion raises NotImplementedError for unknown templates/arguments.""" + with pytest.raises(NotImplementedError): + await wiki_cap.complete_resource_template( + "viking://resources/{namespace}/something/{id}", + CompletionArgument("id", ""), + ) + with pytest.raises(NotImplementedError): + await wiki_cap.complete_resource_template( + "viking://resources/{namespace}/OP/OpA/{id}", + CompletionArgument("status", ""), + ) + + +@pytest.mark.asyncio +async def test_update_ops_ticket_patches_in_place( + wiki_cap: WikiBuildCapability, +) -> None: + """update_ops_ticket is a patch: only passed fields reach engines.update_ops. + + Untouched fields (title/solution/evidence/…) are forwarded as ``None`` + so the engine preserves their current values, and the ops id is derived + from the given ``viking://`` URI. + """ + from wolfharness.capabilities.viking.ticket import build_ticket_tools + + tools = wiki_cap.tools + assert isinstance(tools, FakeWikiBuildTools) + tools.write_ticket("OPA", "opa-001", title="主泵压力判据") + ops_uri = tools.write_ticket("OPS", "ops-001", title="原始建议", status="unconfirmed") + + update_ops_ticket = next( + fn for fn in build_ticket_tools(wiki_cap) if fn.__name__ == "update_ops_ticket" + ) + + result = await update_ops_ticket( + object(), + ops_uri=ops_uri, + status="confirmed", + reviewed_by="expert-7", + ) + + assert result["ops_id"] == "ops-001" + assert result["status"] == "confirmed" + assert tools.last_update_ops["ops_id"] == "ops-001" + assert tools.last_update_ops["status"] == "confirmed" + assert tools.last_update_ops["reviewed_by"] == "expert-7" + # patch semantics — untouched fields stay None so the engine keeps them. + assert tools.last_update_ops["title"] is None + assert tools.last_update_ops["solution"] is None + assert tools.last_update_ops["candidate_operations"] is None diff --git a/tests/capabilities/viking/test_viking.py b/tests/capabilities/viking/test_viking.py index 907acf568..80b27be95 100644 --- a/tests/capabilities/viking/test_viking.py +++ b/tests/capabilities/viking/test_viking.py @@ -9,9 +9,12 @@ import asyncio import contextlib import dataclasses -from typing import Any +from pathlib import Path +import tempfile +from typing import Any, get_type_hints from unittest.mock import AsyncMock, MagicMock +from openviking_sdk.errors import OpenVikingError from pydantic import ValidationError from pydantic_ai.messages import BinaryImage from pydantic_ai.models import ModelRequestContext, ModelRequestParameters @@ -20,6 +23,7 @@ from wolfharness.capabilities.viking import VikingCapability, _normalize_search_results from wolfharness.capabilities.viking.identity import VikingIdentity, _try_decode_api_key +from wolfharness.capabilities.viking.index import _format_index_block from wolfharness.capabilities.viking.profile import ( _derive_context_hint, _format_profile_block, @@ -38,6 +42,10 @@ is_viking_uri, truncate_text, ) +from wolfharness.capabilities.viking.wiki_build import WikiBuildCapability, _build_tool_fns +from wolfharness.capabilities.viking.wiki_index import ( + _format_index_block as _format_wiki_index_block, +) from wolfharness_config.capabilities import VikingCapabilityConfig, build_capability @@ -99,6 +107,8 @@ def test_default_config(self) -> None: assert cfg.uploads_uri is None assert cfg.public_download_base_url is None assert cfg.resource_read_level == "overview" + assert cfg.allowed_uri_prefixes == [] + assert cfg.write_allowed_uri_prefixes == [] def test_default_support_vision_none(self) -> None: """support_vision defaults to None (auto-detect from model capabilities).""" @@ -191,6 +201,50 @@ def test_discriminator_works(self) -> None: member_types = typing.get_args(union_type) assert VikingCapabilityConfig in member_types + def test_index_config_defaults(self) -> None: + """Index fields default to disabled state in VikingCapabilityConfig.""" + cfg = VikingCapabilityConfig() + assert cfg.index_enabled is False + assert cfg.index_max_tokens == 1000 + assert cfg.index_limit == 20 + assert cfg.index_uri is None + + def test_index_config_populated(self) -> None: + """All four index fields can be set via the config model.""" + cfg = VikingCapabilityConfig( + index_enabled=True, + index_max_tokens=500, + index_limit=5, + index_uri="viking://resources/custom/", + ) + assert cfg.index_enabled is True + assert cfg.index_max_tokens == 500 + assert cfg.index_limit == 5 + assert cfg.index_uri == "viking://resources/custom/" + + def test_index_config_reaches_capability(self) -> None: + """Index config survives the config → capability build (no silent drop). + + Regression for the yaml ``viking_kb_capability.index_enabled`` wiring: + the config model must declare the index fields so ``build_capability`` + passes them to ``VikingCapability`` instead of dropping them at parse. + """ + from wolfharness_config.capabilities import build_capability + + cfg = VikingCapabilityConfig( + mode="all", + index_enabled=True, + index_max_tokens=500, + index_limit=5, + index_uri="viking://resources/custom/", + ) + cap = build_capability(cfg) + assert isinstance(cap, VikingCapability) + assert cap.index_enabled is True + assert cap.index_max_tokens == 500 + assert cap.index_limit == 5 + assert cap.index_uri == "viking://resources/custom/" + # --------------------------------------------------------------------------- # support_vision — _should_return_image_bytes tri-state matrix @@ -1137,11 +1191,291 @@ async def test_viking_add_resource( assert call_args[0] == "/local/file.txt" assert call_kwargs["to"] == "viking://user/alice/files/" assert call_kwargs["parent"] == "viking://user/alice/" - # processing_mode is NOT passed to SDK (not a supported kwarg) - assert "processing_mode" not in call_kwargs + assert call_kwargs["processing_mode"] == "auto" + assert call_kwargs["wait"] is False assert call_kwargs["watch_interval"] == 5.0 assert "Added resource" in result.return_value + @pytest.mark.asyncio + async def test_viking_add_resource_wait_true( + self, viking_cap: VikingCapability, mock_client: AsyncMock + ) -> None: + """viking_add_resource passes wait=True through to the SDK.""" + mock_client.add_resource = AsyncMock(return_value={"status": "ok"}) + tools = build_tools(viking_cap) + add_tool = _get_tool(tools, "viking_add_resource") + + ctx = _make_ctx() + await add_tool( + ctx, + path="/local/file.txt", + processing_mode="semantic_and_vectors", + wait=True, + ) + + assert mock_client.add_resource.call_args.kwargs["wait"] is True + assert ( + mock_client.add_resource.call_args.kwargs["processing_mode"] == "semantic_and_vectors" + ) + + @pytest.mark.asyncio + async def test_viking_upload_tree_single_wait_true_call( + self, viking_cap: VikingCapability, mock_client: AsyncMock, tmp_path: Path + ) -> None: + """viking_upload_tree uploads one temp tree, skips build dirs, cleans up.""" + captured: dict[str, list[str]] = {} + + async def fake_add_resource(path: str, **kwargs: Any) -> dict[str, Any]: + captured["children"] = sorted(p.name for p in Path(path).iterdir()) + return {"status": "ok"} + + mock_client.add_resource.side_effect = fake_add_resource + (tmp_path / "Fault" / "X").mkdir(parents=True) + (tmp_path / "Fault" / "X" / "e.md").write_text("# Entity", encoding="utf-8") + (tmp_path / "index").mkdir() + (tmp_path / "index" / "backlinks_index.json").write_text("{}", encoding="utf-8") + (tmp_path / "source_packets").mkdir() + (tmp_path / "source_packets" / "p.md").write_text("p", encoding="utf-8") + (tmp_path / ".hidden").mkdir() + (tmp_path / ".hidden" / "h.md").write_text("h", encoding="utf-8") + (tmp_path / "resource.json").write_text('{"v": 2}', encoding="utf-8") + + tools = build_tools(viking_cap) + upload_tool = _get_tool(tools, "viking_upload_tree") + ctx = _make_ctx() + result = await upload_tool(ctx, path=str(tmp_path), to="viking://resources/test_ns") + + mock_client.add_resource.assert_called_once() + args = mock_client.add_resource.call_args.kwargs + assert captured["children"] == ["Fault", "resource.json"] + assert args["wait"] is True + assert args["processing_mode"] == "semantic_and_vectors" + assert args["timeout"] == 900.0 + assert "Uploaded tree" in result.return_value + leftovers = [ + p for p in Path(tempfile.gettempdir()).iterdir() if p.name.startswith("ov_up_tree_") + ] + assert leftovers == [] + + @pytest.mark.asyncio + async def test_viking_upload_tree_error_cleans_up( + self, viking_cap: VikingCapability, mock_client: AsyncMock, tmp_path: Path + ) -> None: + """viking_upload_tree surfaces add_resource failures and cleans the temp dir.""" + mock_client.add_resource.side_effect = RuntimeError("boom") + (tmp_path / "Fault").mkdir() + (tmp_path / "Fault" / "e.md").write_text("# Entity", encoding="utf-8") + + tools = build_tools(viking_cap) + upload_tool = _get_tool(tools, "viking_upload_tree") + ctx = _make_ctx() + result = await upload_tool(ctx, path=str(tmp_path), to="viking://resources/test_ns") + + assert "viking_upload_tree error" in result.return_value + leftovers = [ + p for p in Path(tempfile.gettempdir()).iterdir() if p.name.startswith("ov_up_tree_") + ] + assert leftovers == [] + + @pytest.mark.asyncio + async def test_viking_link_relations_bidirectional( + self, viking_cap: VikingCapability, mock_client: AsyncMock, tmp_path: Path + ) -> None: + """viking_link_relations pushes both directions per backlink edge.""" + rel = tmp_path / "backlinks_index.json" + rel.write_text( + '{"viking://resources/ns/Profile/A": ' + '["viking://resources/ns/Fault/X", "viking://resources/ns/Fault/Y"], ' + '"viking://resources/ns/Fault/X": ["viking://resources/ns/Component/Z"]}', + encoding="utf-8", + ) + tools = build_tools(viking_cap) + link_tool = _get_tool(tools, "viking_link_relations") + ctx = _make_ctx() + result = await link_tool( + ctx, + relations_file=str(rel), + reason_prefix="wiki", + namespace_base="viking://resources/ns", + ) + + calls = [ + (c.args[0], list(c.args[1]), c.kwargs.get("reason")) + for c in mock_client.link.call_args_list + ] + assert any( + frm == "viking://resources/ns/Profile/A" + and set(tos) == {"viking://resources/ns/Fault/X", "viking://resources/ns/Fault/Y"} + and reason == "wiki:referenced-by" + for frm, tos, reason in calls + ) + assert ( + "viking://resources/ns/Fault/Y", + ["viking://resources/ns/Profile/A"], + "wiki:references", + ) in calls + assert ( + "viking://resources/ns/Component/Z", + ["viking://resources/ns/Fault/X"], + "wiki:references", + ) in calls + # Aggregated: A->[X,Y], X->[Z], Y->[A], Z->[X] (each node written once) + assert "linked=4, failed=0" in result.return_value + + @pytest.mark.asyncio + async def test_viking_link_relations_skips_out_of_namespace( + self, viking_cap: VikingCapability, mock_client: AsyncMock, tmp_path: Path + ) -> None: + """viking_link_relations never links URIs outside namespace_base.""" + rel = tmp_path / "backlinks_index.json" + rel.write_text( + '{"viking://resources/ns/Profile/A": ["viking://resources/other/X"], ' + '"viking://resources/other/Y": ["viking://resources/ns/Fault/X"]}', + encoding="utf-8", + ) + tools = build_tools(viking_cap) + link_tool = _get_tool(tools, "viking_link_relations") + ctx = _make_ctx() + result = await link_tool( + ctx, relations_file=str(rel), namespace_base="viking://resources/ns" + ) + + assert mock_client.link.call_count == 0 + assert "linked=0, failed=0" in result.return_value + + @pytest.mark.asyncio + async def test_viking_link_relations_skips_outside_write_prefix( + self, mock_client: AsyncMock, tmp_path: Path + ) -> None: + """viking_link_relations respects the explicit write-side allowlist.""" + rel = tmp_path / "backlinks_index.json" + rel.write_text( + '{"viking://resources/wiki/Profile/A": ["viking://resources/wiki/Fault/X"], ' + '"viking://resources/raw/Profile/B": ["viking://resources/wiki/Fault/Y"], ' + '"viking://resources/wiki/Profile/C": ["viking://resources/raw/Fault/Z"]}', + encoding="utf-8", + ) + cap = VikingCapability( + mode="all", + enable_link=True, + write_allowed_uri_prefixes=["viking://resources/wiki/"], + ) + cap._client = mock_client + tools = build_tools(cap) + link_tool = _get_tool(tools, "viking_link_relations") + + result = await link_tool( + _make_ctx(), + relations_file=str(rel), + namespace_base="viking://resources/", + ) + + calls = [ + (c.args[0], list(c.args[1]), c.kwargs.get("reason")) + for c in mock_client.link.call_args_list + ] + assert calls == [ + ( + "viking://resources/wiki/Profile/A", + ["viking://resources/wiki/Fault/X"], + "wiki:referenced-by", + ), + ( + "viking://resources/wiki/Fault/X", + ["viking://resources/wiki/Profile/A"], + "wiki:references", + ), + ] + assert "linked=2, failed=0, skipped=2" in result.return_value + + @pytest.mark.asyncio + async def test_viking_link_relations_retries_on_busy( + self, + viking_cap: VikingCapability, + mock_client: AsyncMock, + tmp_path: Path, + monkeypatch: Any, + ) -> None: + """viking_link_relations retries busy rejections then succeeds.""" + import wolfharness.capabilities.viking.tools as viking_tools + + monkeypatch.setattr(viking_tools.asyncio, "sleep", AsyncMock()) + rel = tmp_path / "backlinks_index.json" + rel.write_text( + '{"viking://resources/ns/Profile/A": ["viking://resources/ns/Fault/X"]}', + encoding="utf-8", + ) + calls = {"n": 0} + + async def flaky_link(from_uri: str, to_uris: Any, reason: str = "") -> None: + calls["n"] += 1 + if calls["n"] <= 2: + raise OpenVikingError("resource busy", code="RESOURCE_BUSY") + + mock_client.link.side_effect = flaky_link + tools = build_tools(viking_cap) + link_tool = _get_tool(tools, "viking_link_relations") + ctx = _make_ctx() + result = await link_tool(ctx, relations_file=str(rel)) + + # 2 busy failures + 1 success (target direction), then 1 success (source direction). + assert calls["n"] == 4 + assert "linked=2, failed=0" in result.return_value + + @pytest.mark.asyncio + async def test_viking_link_relations_resolves_md_wrapper( + self, viking_cap: VikingCapability, mock_client: AsyncMock, tmp_path: Path + ) -> None: + """Links use the server's actual md-wrapper path when the literal URI is missing.""" + rel = tmp_path / "backlinks_index.json" + rel.write_text( + '{"viking://resources/ns/Fault/X.md": ["viking://resources/ns/Fault/Y.md"]}', + encoding="utf-8", + ) + wrapped = "viking://resources/ns/Fault/X/X.md" + other_wrapped = "viking://resources/ns/Fault/Y/Y.md" + + async def fake_stat(uri: str) -> dict: + if uri in (wrapped, other_wrapped): + return {"uri": uri} + raise OpenVikingError("not found", code="NOT_FOUND") + + mock_client.stat.side_effect = fake_stat + tools = build_tools(viking_cap) + link_tool = _get_tool(tools, "viking_link_relations") + result = await link_tool( + ctx=_make_ctx(), relations_file=str(rel), namespace_base="viking://resources/ns" + ) + + assert "linked=2, failed=0, skipped=0" in result.return_value + calls = [(c.args[0], list(c.args[1])) for c in mock_client.link.call_args_list] + assert (wrapped, [other_wrapped]) in calls # referenced-by at resolved target + assert (other_wrapped, [wrapped]) in calls # references at resolved source + + @pytest.mark.asyncio + async def test_viking_link_relations_skips_unresolvable( + self, viking_cap: VikingCapability, mock_client: AsyncMock, tmp_path: Path + ) -> None: + """URIs that resolve to no server node are skipped, never linked.""" + rel = tmp_path / "backlinks_index.json" + rel.write_text( + '{"viking://resources/ns/Fault/Ghost.md": ["viking://resources/ns/Fault/Other.md"]}', + encoding="utf-8", + ) + + async def fake_stat(uri: str) -> dict: + raise OpenVikingError("not found", code="NOT_FOUND") + + mock_client.stat.side_effect = fake_stat + tools = build_tools(viking_cap) + link_tool = _get_tool(tools, "viking_link_relations") + result = await link_tool( + ctx=_make_ctx(), relations_file=str(rel), namespace_base="viking://resources/ns" + ) + + assert "linked=0, failed=0, skipped=1" in result.return_value + mock_client.link.assert_not_called() + @pytest.mark.asyncio async def test_viking_forget( self, viking_cap: VikingCapability, mock_client: AsyncMock @@ -2198,26 +2532,27 @@ def test_retrieve_mode_8_tools_with_memory(self) -> None: names = {t.__name__ for t in tools} assert "viking_recall" in names - def test_write_mode_4_tools_default(self) -> None: - """Write mode exposes 4 tools (forget gated by enable_forget, remember by enable_memory).""" + def test_write_mode_5_tools_default(self) -> None: + """Write mode exposes 5 tools (forget gated by enable_forget, remember by enable_memory).""" cap = VikingCapability(mode="write") cap._client = AsyncMock() tools = build_tools(cap) - assert len(tools) == 4 + assert len(tools) == 5 names = {t.__name__ for t in tools} assert names == { "viking_write", "viking_edit", "viking_mkdir", "viking_add_resource", + "viking_upload_tree", } - def test_write_mode_6_tools_with_memory_and_forget(self) -> None: - """Write mode exposes 6 tools when enable_memory=True and enable_forget=True.""" + def test_write_mode_7_tools_with_memory_and_forget(self) -> None: + """Write mode exposes 7 tools when enable_memory=True and enable_forget=True.""" cap = VikingCapability(mode="write", enable_memory=True, enable_forget=True) cap._client = AsyncMock() tools = build_tools(cap) - assert len(tools) == 6 + assert len(tools) == 7 names = {t.__name__ for t in tools} assert "viking_remember" in names assert "viking_forget" in names @@ -2231,28 +2566,28 @@ def test_graph_mode_1_tool_default(self) -> None: names = {t.__name__ for t in tools} assert names == {"viking_set_tags"} - def test_graph_mode_2_tools_with_link(self) -> None: - """Graph mode exposes 2 tools when enable_link=True.""" + def test_graph_mode_3_tools_with_link(self) -> None: + """Graph mode exposes 3 tools when enable_link=True.""" cap = VikingCapability(mode="graph", enable_link=True) cap._client = AsyncMock() tools = build_tools(cap) - assert len(tools) == 2 + assert len(tools) == 3 names = {t.__name__ for t in tools} - assert names == {"viking_link", "viking_set_tags"} + assert names == {"viking_link", "viking_link_relations", "viking_set_tags"} - def test_all_mode_12_tools_default(self) -> None: - """All mode exposes 12 tools by default (link, memory, and forget gated off).""" + def test_all_mode_13_tools_default(self) -> None: + """All mode exposes 13 tools by default (link_relations, memory, and forget gated off).""" cap = VikingCapability(mode="all") cap._client = AsyncMock() tools = build_tools(cap) - assert len(tools) == 12 + assert len(tools) == 13 - def test_all_mode_16_tools_with_flags(self) -> None: - """All mode exposes 16 tools when enable_link + enable_memory + enable_forget.""" + def test_all_mode_18_tools_with_flags(self) -> None: + """All mode exposes 18 tools when enable_link + enable_memory + enable_forget.""" cap = VikingCapability(mode="all", enable_link=True, enable_memory=True, enable_forget=True) cap._client = AsyncMock() tools = build_tools(cap) - assert len(tools) == 16 + assert len(tools) == 18 def test_disabled_tools_excludes_search_find(self) -> None: """disabled_tools blacklist removes viking_search and viking_find.""" @@ -2297,7 +2632,7 @@ def test_get_toolset_retrieve(self) -> None: assert len(tool_names) == 7 def test_get_toolset_write(self) -> None: - """get_toolset() returns a FunctionToolset with 4 tools for write mode (default).""" + """get_toolset() returns a FunctionToolset with 5 tools for write mode (default).""" from pydantic_ai.toolsets import FunctionToolset cap = VikingCapability(mode="write") @@ -2306,7 +2641,7 @@ def test_get_toolset_write(self) -> None: assert toolset is not None assert isinstance(toolset, FunctionToolset) tool_names = list(toolset.tools.keys()) # type: ignore[attr-defined] - assert len(tool_names) == 4 + assert len(tool_names) == 5 def test_get_toolset_graph(self) -> None: """get_toolset() returns a FunctionToolset with 1 tool for graph mode (default).""" @@ -2321,7 +2656,7 @@ def test_get_toolset_graph(self) -> None: assert len(tool_names) == 1 def test_get_toolset_all(self) -> None: - """get_toolset() returns a FunctionToolset with 12 tools for all mode (default).""" + """get_toolset() returns a FunctionToolset with 13 tools for all mode (default).""" from pydantic_ai.toolsets import FunctionToolset cap = VikingCapability(mode="all") @@ -2330,7 +2665,7 @@ def test_get_toolset_all(self) -> None: assert toolset is not None assert isinstance(toolset, FunctionToolset) tool_names = list(toolset.tools.keys()) # type: ignore[attr-defined] - assert len(tool_names) == 12 + assert len(tool_names) == 13 def test_get_toolset_id_is_viking(self) -> None: """get_toolset() returns a FunctionToolset with id='viking'.""" @@ -2412,10 +2747,10 @@ def test_instructions_no_prefix_block_when_unrestricted(self) -> None: cap = VikingCapability(mode="all") instructions = cap.get_instructions() assert instructions is not None - assert "Allowed URI Prefixes" not in instructions + assert "Read URI Prefixes" not in instructions def test_instructions_include_prefix_block_when_restricted(self) -> None: - """get_instructions() lists the allowed prefixes. + """get_instructions() lists the read allowed prefixes. So the model can pass a target_uri and skip discovery probing. """ @@ -2428,7 +2763,7 @@ def test_instructions_include_prefix_block_when_restricted(self) -> None: ) instructions = cap.get_instructions() assert instructions is not None - assert "Allowed URI Prefixes" in instructions + assert "Read URI Prefixes" in instructions assert "viking://resources/wiki/" in instructions assert "viking://resources/raw/" in instructions @@ -3944,7 +4279,7 @@ def test_format_recall_block_multiple_hits(self) -> None: def test_inject_system_message_inserts_before_user(self) -> None: """System message is inserted before the latest user message.""" - from pydantic_ai.messages import ModelRequest, SystemPromptPart, UserPromptPart + from pydantic_ai.messages import ModelRequest, UserPromptPart msg1 = ModelRequest(parts=[UserPromptPart(content="first")]) msg2 = ModelRequest(parts=[UserPromptPart(content="second")]) @@ -3956,7 +4291,7 @@ def test_inject_system_message_inserts_before_user(self) -> None: sys_msg = result.messages[1] assert isinstance(sys_msg, ModelRequest) sys_part = sys_msg.parts[0] - assert isinstance(sys_part, SystemPromptPart) + assert isinstance(sys_part, UserPromptPart) assert "recall block text" in sys_part.content def test_inject_system_message_no_user_prompt(self) -> None: @@ -4073,11 +4408,11 @@ async def test_handle_auto_recall_search_method(self, mock_client: AsyncMock) -> # Verify recall block was injected assert result is not rc assert len(result.messages) == 2 - from pydantic_ai.messages import SystemPromptPart + from pydantic_ai.messages import UserPromptPart sys_msg = result.messages[0] sys_part = sys_msg.parts[0] - assert isinstance(sys_part, SystemPromptPart) + assert isinstance(sys_part, UserPromptPart) assert "<openviking-recall>" in sys_part.content assert "viking://user/alice/memories/doc.md" in sys_part.content @@ -4124,10 +4459,10 @@ async def test_handle_auto_recall_find_method(self, mock_client: AsyncMock) -> N # Verify recall block injected assert result is not rc - from pydantic_ai.messages import SystemPromptPart + from pydantic_ai.messages import UserPromptPart sys_part = result.messages[0].parts[0] - assert isinstance(sys_part, SystemPromptPart) + assert isinstance(sys_part, UserPromptPart) assert "<openviking-recall>" in sys_part.content @pytest.mark.asyncio @@ -4210,10 +4545,10 @@ async def test_handle_auto_recall_session_context_failure_fallback( mock_client.search.assert_called_once() # Recall block should still be injected (without session context) assert result is not rc - from pydantic_ai.messages import SystemPromptPart + from pydantic_ai.messages import UserPromptPart sys_part = result.messages[0].parts[0] - assert isinstance(sys_part, SystemPromptPart) + assert isinstance(sys_part, UserPromptPart) assert "<openviking-recall>" in sys_part.content # No <session-context> section assert "<session-context>" not in sys_part.content @@ -4542,7 +4877,8 @@ def test_non_matching_prefix_blocked(self) -> None: cap = VikingCapability(allowed_uri_prefixes=["viking://resources/wiki/"]) err = cap._check_uri_allowed("viking://resources/raw/engine.md") assert err is not None - assert "outside the allowed prefixes" in err + assert "read_scope_denied" in err + assert "read allowed prefixes" in err assert "viking://resources/raw/engine.md" in err def test_multiple_prefixes(self) -> None: @@ -4610,7 +4946,7 @@ async def test_viking_read_blocks_outside_prefix(self, mock_client: AsyncMock) - ctx = _make_ctx() result = await read_tool(ctx, uris="viking://resources/raw/engine.md") - assert "outside the allowed prefixes" in result.return_value + assert "read_scope_denied" in result.return_value mock_client.read.assert_not_called() @pytest.mark.asyncio @@ -4641,12 +4977,15 @@ async def test_viking_read_multi_uri_blocks_first_outside(self, mock_client: Asy ctx, uris=["viking://resources/wiki/a.md", "viking://resources/raw/b.md"] ) - assert "outside the allowed prefixes" in result.return_value + assert "read_scope_denied" in result.return_value mock_client.read.assert_not_called() @pytest.mark.asyncio - async def test_viking_write_blocks_outside_prefix(self, mock_client: AsyncMock) -> None: - """viking_write rejects URIs outside the allowed prefixes.""" + async def test_viking_write_ignores_read_prefixes_by_default( + self, + mock_client: AsyncMock, + ) -> None: + """viking_write is not constrained by read-side allowed_uri_prefixes.""" cap = VikingCapability(mode="all", allowed_uri_prefixes=["viking://resources/wiki/"]) cap._client = mock_client tools = build_tools(cap) @@ -4655,9 +4994,83 @@ async def test_viking_write_blocks_outside_prefix(self, mock_client: AsyncMock) ctx = _make_ctx() result = await write_tool(ctx, uri="viking://resources/raw/note.md", content="hi") - assert "outside the allowed prefixes" in result.return_value + assert "Wrote" in result.return_value + mock_client.write.assert_called_once() + assert mock_client.write.call_args.args[0] == "viking://resources/raw/note.md" + + @pytest.mark.asyncio + async def test_viking_write_blocks_outside_explicit_write_prefix( + self, + mock_client: AsyncMock, + ) -> None: + """viking_write uses write_allowed_uri_prefixes when explicitly configured.""" + cap = VikingCapability( + mode="all", + allowed_uri_prefixes=["viking://resources/wiki/"], + write_allowed_uri_prefixes=["viking://resources/tickets/"], + ) + cap._client = mock_client + tools = build_tools(cap) + write_tool = _get_tool(tools, "viking_write") + + ctx = _make_ctx() + result = await write_tool(ctx, uri="viking://resources/raw/note.md", content="hi") + + assert "write_scope_denied" in result.return_value + assert "write allowed prefixes" in result.return_value mock_client.write.assert_not_called() + @pytest.mark.asyncio + async def test_viking_add_resource_ignores_read_prefixes_by_default( + self, + mock_client: AsyncMock, + ) -> None: + """viking_add_resource is not constrained by read-side prefixes.""" + mock_client.add_resource = AsyncMock(return_value={"status": "ok"}) + cap = VikingCapability(mode="all", allowed_uri_prefixes=["viking://resources/wiki/"]) + cap._client = mock_client + tools = build_tools(cap) + add_tool = _get_tool(tools, "viking_add_resource") + + ctx = _make_ctx() + result = await add_tool( + ctx, + path="/local/file.txt", + to="viking://resources/810test/tickets/", + ) + + assert "Added resource" in result.return_value + assert mock_client.add_resource.call_args.kwargs["to"] == ( + "viking://resources/810test/tickets/" + ) + + @pytest.mark.asyncio + async def test_viking_add_resource_blocks_outside_explicit_write_prefix( + self, + mock_client: AsyncMock, + ) -> None: + """viking_add_resource uses write_allowed_uri_prefixes when configured.""" + mock_client.add_resource = AsyncMock(return_value={"status": "ok"}) + cap = VikingCapability( + mode="all", + allowed_uri_prefixes=["viking://resources/wiki/"], + write_allowed_uri_prefixes=["viking://resources/tickets/"], + ) + cap._client = mock_client + tools = build_tools(cap) + add_tool = _get_tool(tools, "viking_add_resource") + + ctx = _make_ctx() + result = await add_tool( + ctx, + path="/local/file.txt", + to="viking://resources/810test/tickets/", + ) + + assert "write_scope_denied" in result.return_value + assert "write allowed prefixes" in result.return_value + mock_client.add_resource.assert_not_called() + @pytest.mark.asyncio async def test_viking_search_defaults_to_first_prefix(self, mock_client: AsyncMock) -> None: """viking_search passes the single allowed prefix as a one-element list. @@ -4732,7 +5145,7 @@ async def test_viking_search_blocks_outside_target(self, mock_client: AsyncMock) ctx = _make_ctx() result = await search_tool(ctx, query="hydraulic", target_uri="viking://resources/raw/") - assert "outside the allowed prefixes" in result.return_value + assert "read_scope_denied" in result.return_value mock_client.search.assert_not_called() @pytest.mark.asyncio @@ -5169,13 +5582,13 @@ async def test_handle_compaction_above_threshold(self, mock_client: AsyncMock) - # Result should have fewer messages than original (archived replaced with summary) assert len(result.messages) < len(messages) - # First message should be the archive summary (SystemPromptPart) + # First message should be the archive summary (UserPromptPart) first_msg = result.messages[0] assert isinstance(first_msg, ModelRequest) - from pydantic_ai.messages import SystemPromptPart + from pydantic_ai.messages import UserPromptPart sys_part = first_msg.parts[0] - assert isinstance(sys_part, SystemPromptPart) + assert isinstance(sys_part, UserPromptPart) assert archive_uri in sys_part.content @pytest.mark.asyncio @@ -5588,8 +6001,8 @@ def test_extract_conversation_pairs_multiple_turns(self) -> None: assert result[4]["content"] == "Q3" assert result[5]["content"] == "A3" - def test_extract_conversation_pairs_skips_non_text_content(self) -> None: - """Skips UserPromptPart with list (multimodal) content.""" + def test_extract_conversation_pairs_extracts_list_user_text(self) -> None: + """Extracts text from structured/list user content.""" from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart from wolfharness.capabilities.viking.ingest import _extract_conversation_pairs @@ -5600,10 +6013,10 @@ def test_extract_conversation_pairs_skips_non_text_content(self) -> None: ModelRequest(parts=[UserPromptPart(content="plain text")]), ] result = _extract_conversation_pairs(messages, start_idx=0) - # First user prompt is skipped (list content), assistant text is included - assert len(result) == 2 - assert result[0] == {"role": "assistant", "content": "response"} - assert result[1] == {"role": "user", "content": "plain text"} + assert len(result) == 3 + assert result[0] == {"role": "user", "content": "image_data\ntext"} + assert result[1] == {"role": "assistant", "content": "response"} + assert result[2] == {"role": "user", "content": "plain text"} def test_extract_conversation_pairs_empty_messages(self) -> None: """Empty messages list returns empty list.""" @@ -5837,7 +6250,7 @@ async def test_handle_auto_ingest_async_mode_creates_task(self, mock_client: Asy @pytest.mark.asyncio async def test_handle_auto_ingest_graceful_failure(self, mock_client: AsyncMock) -> None: - """Ingestion failure does not block and still updates cursor.""" + """Ingestion failure does not block and keeps the batch retryable.""" from pydantic_ai.messages import ModelRequest, ModelResponse, TextPart, UserPromptPart mock_client.create_session = AsyncMock(side_effect=RuntimeError("server unreachable")) @@ -5859,8 +6272,8 @@ async def test_handle_auto_ingest_graceful_failure(self, mock_client: AsyncMock) # Should not raise result = await cap._handle_auto_ingest(ctx, rc) - # Cursor should still be updated (to avoid retrying) - assert cap._last_ingested_idx == 2 + assert cap._last_ingested_idx == 0 + assert len(cap._failed_ingest_batches) == 1 # Result should be unchanged assert result is rc @@ -6207,7 +6620,7 @@ def test_derive_context_hint_empty_when_nothing_available(self) -> None: @pytest.mark.asyncio async def test_handle_profile_inject_first_turn(self, mock_client: AsyncMock) -> None: """_handle_profile_inject injects profile on first turn.""" - from pydantic_ai.messages import ModelRequest, SystemPromptPart, UserPromptPart + from pydantic_ai.messages import ModelRequest, UserPromptPart mock_client.find = AsyncMock( return_value={ @@ -6238,11 +6651,11 @@ async def test_handle_profile_inject_first_turn(self, mock_client: AsyncMock) -> assert call_kwargs["limit"] == 5 # default profile_limit assert call_kwargs["context_type"] == "memory" assert "viking://user/alice/memories/" in call_kwargs["target_uri"] - # Check that a SystemPromptPart was injected before the user message + # Check that a UserPromptPart was injected before the user message assert len(result.messages) == 2 first_msg = result.messages[0] assert isinstance(first_msg, ModelRequest) - assert any(isinstance(p, SystemPromptPart) for p in first_msg.parts) + assert any(isinstance(p, UserPromptPart) for p in first_msg.parts) @pytest.mark.asyncio async def test_handle_profile_inject_skips_when_already_injected( @@ -6328,7 +6741,7 @@ async def test_handle_profile_inject_disabled_is_noop(self, mock_client: AsyncMo @pytest.mark.asyncio async def test_handle_profile_inject_token_budget(self, mock_client: AsyncMock) -> None: """_handle_profile_inject truncates profile to profile_max_tokens.""" - from pydantic_ai.messages import ModelRequest, SystemPromptPart, UserPromptPart + from pydantic_ai.messages import ModelRequest, UserPromptPart long_content = "x" * 5000 mock_client.find = AsyncMock( @@ -6357,9 +6770,9 @@ async def test_handle_profile_inject_token_budget(self, mock_client: AsyncMock) result = await cap._handle_profile_inject(ctx, rc) - # The injected SystemPromptPart content should be truncated + # The injected UserPromptPart content should be truncated first_msg = result.messages[0] - sys_parts = [p for p in first_msg.parts if isinstance(p, SystemPromptPart)] + sys_parts = [p for p in first_msg.parts if isinstance(p, UserPromptPart)] assert len(sys_parts) == 1 assert len(sys_parts[0].content) < 600 assert "truncated" in sys_parts[0].content @@ -6501,6 +6914,558 @@ async def test_for_run_preserves_profile_config(self, mock_client: AsyncMock) -> assert copy_cap.profile_first_turn_only is False +# --------------------------------------------------------------------------- +# 6.8 — Test Index Injection (Dynamic Namespaces) +# --------------------------------------------------------------------------- + + +class TestIndexInjection: + """Tests for dynamic resource-namespace index injection. + + Covers _format_index_block, _handle_index_inject, config fields, + and for_run() reset. + """ + + # ---- _format_index_block (pure function) ---- + + def test_format_index_block_exact_format(self) -> None: + """_format_index_block renders the exact <openviking-index> block.""" + block = _format_index_block(["wiki", "raw", "730"], max_tokens=1000, limit=20) + assert block == ( + "<openviking-index>\n" + "Viking resources are addressed by viking:// URIs. Live resource " + "namespaces under viking://resources/:\n" + "wiki, raw, 730\n" + 'Use viking_ls("<uri>") to enumerate deeper, ' + "viking_search/viking_find to locate content, " + "viking_read to fetch.\n" + "</openviking-index>" + ) + + def test_format_index_block_limit(self) -> None: + """_format_index_block keeps at most `limit` namespace names.""" + namespaces = [f"ns{i}" for i in range(10)] + block = _format_index_block(namespaces, max_tokens=1000, limit=3) + assert "ns0, ns1, ns2" in block + assert "ns3" not in block + + def test_format_index_block_empty(self) -> None: + """_format_index_block returns empty string for empty namespaces.""" + assert _format_index_block([], max_tokens=1000, limit=20) == "" + + def test_format_index_block_token_budget(self) -> None: + """_format_index_block truncates content exceeding token budget.""" + namespaces = [f"namespace-{i}" for i in range(200)] + block = _format_index_block(namespaces, max_tokens=50, limit=20) + # max_tokens=50 → max_chars=200 budget + assert "truncated" in block + assert len(block) > 200 + + # ---- _handle_index_inject (using mock_client + _make_request_context) ---- + + @pytest.mark.asyncio + async def test_handle_index_inject_first_turn(self, mock_client: AsyncMock) -> None: + """_handle_index_inject injects an index block on the first turn.""" + from pydantic_ai.messages import ModelRequest, UserPromptPart + + mock_client.ls = AsyncMock( + return_value=[ + {"uri": "viking://resources/wiki", "name": "wiki", "isDir": True}, + {"uri": "viking://resources/raw", "name": "raw", "isDir": True}, + {"uri": "viking://resources/notes.md", "name": "notes.md", "isDir": False}, + ] + ) + cap = VikingCapability(mode="all", index_enabled=True) + cap._client = mock_client + + ctx = _make_ctx() + msg = ModelRequest(parts=[UserPromptPart(content="hello")]) + rc = _make_request_context([msg]) + + result = await cap._handle_index_inject(ctx, rc) + + assert cap._index_injected is True + mock_client.ls.assert_called_once_with("viking://resources/") + # Only isDir entries are included; files are skipped + assert len(result.messages) == 2 + first_msg = result.messages[0] + assert isinstance(first_msg, ModelRequest) + sys_parts = [p for p in first_msg.parts if isinstance(p, UserPromptPart)] + assert len(sys_parts) == 1 + assert "<openviking-index>" in sys_parts[0].content + assert "wiki, raw" in sys_parts[0].content + assert "notes.md" not in sys_parts[0].content + + @pytest.mark.asyncio + async def test_handle_index_inject_disabled_by_default(self, mock_client: AsyncMock) -> None: + """_handle_index_inject is a no-op when index_enabled=False (default).""" + cap = VikingCapability(mode="all") # index_enabled defaults to False + cap._client = mock_client + + ctx = _make_ctx() + rc = _make_request_context([]) + + result = await cap._handle_index_inject(ctx, rc) + + mock_client.ls.assert_not_called() + assert result is rc + assert cap._index_injected is False + + @pytest.mark.asyncio + async def test_handle_index_inject_second_turn_no_reinjection( + self, mock_client: AsyncMock + ) -> None: + """_handle_index_inject skips on subsequent turns (no re-injection).""" + from pydantic_ai.messages import ModelRequest, UserPromptPart + + cap = VikingCapability(mode="all", index_enabled=True) + cap._client = mock_client + + ctx = _make_ctx() + # 3 messages = subsequent turn + messages = [ + ModelRequest(parts=[UserPromptPart(content="msg1")]), + ModelRequest(parts=[UserPromptPart(content="msg2")]), + ModelRequest(parts=[UserPromptPart(content="msg3")]), + ] + rc = _make_request_context(messages) + + result = await cap._handle_index_inject(ctx, rc) + + mock_client.ls.assert_not_called() + assert result is rc + # _index_injected set to True to prevent future attempts + assert cap._index_injected is True + + @pytest.mark.asyncio + async def test_handle_index_inject_graceful_failure(self, mock_client: AsyncMock) -> None: + """_handle_index_inject handles ls() errors gracefully — no raise.""" + from pydantic_ai.messages import ModelRequest, UserPromptPart + + mock_client.ls = AsyncMock(side_effect=RuntimeError("server unreachable")) + cap = VikingCapability(mode="all", index_enabled=True) + cap._client = mock_client + + ctx = _make_ctx() + msg = ModelRequest(parts=[UserPromptPart(content="hello")]) + rc = _make_request_context([msg]) + + result = await cap._handle_index_inject(ctx, rc) + + # Should return original context, not raise + assert result is rc + # _index_injected should be True (set before the try block, no retry) + assert cap._index_injected is True + + @pytest.mark.asyncio + async def test_handle_index_inject_limit_truncates_namespaces( + self, mock_client: AsyncMock + ) -> None: + """_handle_index_inject truncates the namespace list to index_limit.""" + from pydantic_ai.messages import ModelRequest, UserPromptPart + + mock_client.ls = AsyncMock( + return_value=[ + {"uri": f"viking://resources/ns{i}", "name": f"ns{i}", "isDir": True} + for i in range(30) + ] + ) + cap = VikingCapability(mode="all", index_enabled=True, index_limit=5) + cap._client = mock_client + + ctx = _make_ctx() + msg = ModelRequest(parts=[UserPromptPart(content="hello")]) + rc = _make_request_context([msg]) + + result = await cap._handle_index_inject(ctx, rc) + + first_msg = result.messages[0] + sys_parts = [p for p in first_msg.parts if isinstance(p, UserPromptPart)] + content = sys_parts[0].content + assert "ns0, ns1, ns2, ns3, ns4" in content + assert "ns5" not in content + + @pytest.mark.asyncio + async def test_handle_index_inject_custom_uri(self, mock_client: AsyncMock) -> None: + """_handle_index_inject uses index_uri override when set.""" + from pydantic_ai.messages import ModelRequest, UserPromptPart + + mock_client.ls = AsyncMock(return_value=[]) + cap = VikingCapability( + mode="all", + index_enabled=True, + index_uri="viking://resources/custom/", + ) + cap._client = mock_client + + ctx = _make_ctx() + msg = ModelRequest(parts=[UserPromptPart(content="hello")]) + rc = _make_request_context([msg]) + + await cap._handle_index_inject(ctx, rc) + + mock_client.ls.assert_called_once_with("viking://resources/custom/") + + # ---- Config fields ---- + + def test_config_index_enabled_default_false(self) -> None: + """VikingCapability has index_enabled=False by default.""" + cap = VikingCapability(mode="all") + assert cap.index_enabled is False + + def test_config_index_fields_defaults(self) -> None: + """VikingCapability has index defaults (max_tokens, limit, uri).""" + cap = VikingCapability(mode="all") + assert cap.index_max_tokens == 1000 + assert cap.index_limit == 20 + assert cap.index_uri is None + + # ---- for_run() reset ---- + + @pytest.mark.asyncio + async def test_for_run_resets_index_injected(self, mock_client: AsyncMock) -> None: + """for_run() resets _index_injected to False.""" + cap = VikingCapability(mode="all", index_enabled=True) + cap._client = mock_client + cap._index_injected = True # Simulate after first turn + + ctx = _make_ctx() + copy_cap = await cap.for_run(ctx) + + assert copy_cap._index_injected is False + # Original is unchanged + assert cap._index_injected is True + + @pytest.mark.asyncio + async def test_for_run_preserves_index_config(self, mock_client: AsyncMock) -> None: + """for_run() preserves index config fields.""" + cap = VikingCapability( + mode="all", + index_enabled=True, + index_max_tokens=500, + index_limit=5, + index_uri="viking://resources/custom/", + ) + cap._client = mock_client + + ctx = _make_ctx() + copy_cap = await cap.for_run(ctx) + + assert copy_cap.index_enabled is True + assert copy_cap.index_max_tokens == 500 + assert copy_cap.index_limit == 5 + assert copy_cap.index_uri == "viking://resources/custom/" + + +# --------------------------------------------------------------------------- +# 6.9 — Test Wiki Build Index Injection (B-scheme: config roots) +# --------------------------------------------------------------------------- + + +class TestWikiBuildIndex: + """Tests for WikiBuildCapability's config-driven index injection. + + Covers _format_index_block, _resolve_index_roots (through injection), + before_model_request first-turn injection, config defaults, and + for_run() reset. + """ + + def test_wrapped_host_tool_preserves_resolved_parameter_types(self) -> None: + """Host annotations must reach the model schema instead of degrading to ``Any``.""" + + class HostTools: + def record_source_packet( + self, + packet_id: str, + *, + evidence_count: int = 0, + packet_body: dict[str, object] | None = None, + ) -> tuple[str, int, dict[str, object] | None]: + return packet_id, evidence_count, packet_body + + wrapped = _build_tool_fns( + HostTools(), + tool_names=frozenset({"record_source_packet"}), + )[0] + hints = get_type_hints(wrapped) + + assert hints["packet_id"] is str + assert hints["evidence_count"] is int + assert hints["packet_body"] == dict[str, object] | None + + # ---- _format_index_block (pure function) ---- + + def test_format_index_block_exact_format(self) -> None: + """_format_index_block renders the exact <openviking-index> block.""" + block = _format_wiki_index_block( + [ + ("wiki", "viking://resources/810test"), + ("raw", "viking://resources/raw"), + ("bom", "viking://resources/bom"), + ], + max_tokens=1000, + limit=20, + ) + assert block == ( + "<openviking-index>\n" + "Wiki resources are addressed by viking:// URIs. Active build " + "roots for this session:\n" + "- wiki: viking://resources/810test\n" + "- raw: viking://resources/raw\n" + "- bom: viking://resources/bom\n" + "Use list_children / get_resource / read_resource to navigate " + "and never guess or splice namespaces.\n" + "</openviking-index>" + ) + + def test_format_index_block_limit(self) -> None: + """_format_index_block keeps at most `limit` root pairs.""" + roots = [(label, f"viking://resources/{label}") for label in ("wiki", "raw", "bom")] + block = _format_wiki_index_block(roots, max_tokens=1000, limit=2) + assert "- wiki: viking://resources/wiki" in block + assert "- raw: viking://resources/raw" in block + assert "- bom: viking://resources/bom" not in block + + def test_format_index_block_empty(self) -> None: + """_format_index_block returns empty string for empty roots.""" + assert _format_wiki_index_block([], max_tokens=1000, limit=20) == "" + + def test_format_index_block_token_budget(self) -> None: + """_format_index_block truncates content exceeding token budget.""" + roots = [ + (label, f"viking://resources/namespace-{label}") for label in ("wiki", "raw", "bom") + ] + block = _format_wiki_index_block(roots, max_tokens=10, limit=20) + # max_tokens=10 → max_chars=40 budget + assert "truncated" in block + assert len(block) > 40 + + # ---- before_model_request injection ---- + + @pytest.mark.asyncio + async def test_before_model_request_first_turn_injects_index(self) -> None: + """before_model_request injects a block with resolved config roots.""" + from pydantic_ai.messages import ModelRequest, UserPromptPart + + cap = WikiBuildCapability( + wiki_root="viking://resources/810test", + library_root="viking://resources/raw", + bom_root="viking://resources/bom", + index_enabled=True, + ) + ctx = _make_ctx() + msg = ModelRequest(parts=[UserPromptPart(content="hello")]) + rc = _make_request_context([msg]) + + result = await cap.before_model_request(ctx, rc) + + assert cap._index_injected is True + assert len(result.messages) == 2 + first_msg = result.messages[0] + assert isinstance(first_msg, ModelRequest) + sys_parts = [p for p in first_msg.parts if isinstance(p, UserPromptPart)] + assert len(sys_parts) == 1 + assert "<openviking-index>" in sys_parts[0].content + assert "- wiki: viking://resources/810test" in sys_parts[0].content + assert "- raw: viking://resources/raw" in sys_parts[0].content + assert "- bom: viking://resources/bom" in sys_parts[0].content + + @pytest.mark.asyncio + async def test_before_model_request_filters_non_viking_and_missing_roots( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Only viking:// roots are listed; missing and local roots are omitted. + + With no namespace env vars set, a local root cannot be mapped to a + remote wiki, so it is filtered out (local backend semantics). + """ + from pydantic_ai.messages import ModelRequest, UserPromptPart + + monkeypatch.delenv("VIKING_NAMESPACE", raising=False) + monkeypatch.delenv("VIKING_RAW_NAMESPACE", raising=False) + + cap = WikiBuildCapability( + wiki_root="viking://resources/810test", + library_root="output/library", # local path — must be filtered out + index_enabled=True, + # bom_root omitted entirely + ) + ctx = _make_ctx() + msg = ModelRequest(parts=[UserPromptPart(content="hello")]) + rc = _make_request_context([msg]) + + result = await cap.before_model_request(ctx, rc) + + first_msg = result.messages[0] + sys_parts = [p for p in first_msg.parts if isinstance(p, UserPromptPart)] + content = sys_parts[0].content + assert "- wiki: viking://resources/810test" in content + assert "output/library" not in content + assert "- raw:" not in content + assert "- bom:" not in content + + @pytest.mark.asyncio + async def test_before_model_request_maps_local_roots_when_namespace_set( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Local roots map to namespace URIs when viking backend + namespaces.""" + from pydantic_ai.messages import ModelRequest, UserPromptPart + + monkeypatch.setenv("WIKI_STORAGE_BACKEND", "viking") + monkeypatch.setenv("VIKING_NAMESPACE", "810test") + monkeypatch.setenv("VIKING_RAW_NAMESPACE", "raw/vikingtest") + + cap = WikiBuildCapability( + wiki_root="output/wiki_newbuild", # local path — mapped to wiki ns + library_root="output/raw/repairmenu", # local path — mapped to raw ns + index_enabled=True, + # bom_root omitted entirely + ) + ctx = _make_ctx() + msg = ModelRequest(parts=[UserPromptPart(content="hello")]) + rc = _make_request_context([msg]) + + result = await cap.before_model_request(ctx, rc) + + first_msg = result.messages[0] + sys_parts = [p for p in first_msg.parts if isinstance(p, UserPromptPart)] + content = sys_parts[0].content + assert "- wiki: viking://resources/810test" in content + assert "- raw: viking://resources/raw/vikingtest" in content + assert "output/wiki_newbuild" not in content + assert "output/raw/repairmenu" not in content + assert "- bom:" not in content + + @pytest.mark.asyncio + async def test_before_model_request_local_backend_keeps_local_filter( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Local roots stay filtered even with namespaces when backend=local.""" + from pydantic_ai.messages import ModelRequest, UserPromptPart + + monkeypatch.setenv("WIKI_STORAGE_BACKEND", "local") + monkeypatch.setenv("VIKING_NAMESPACE", "810test") + monkeypatch.setenv("VIKING_RAW_NAMESPACE", "raw/vikingtest") + + cap = WikiBuildCapability( + wiki_root="output/wiki_newbuild", + library_root="output/raw/repairmenu", + index_enabled=True, + ) + ctx = _make_ctx() + msg = ModelRequest(parts=[UserPromptPart(content="hello")]) + rc = _make_request_context([msg]) + + result = await cap.before_model_request(ctx, rc) + + # No viking:// roots resolve under the local backend, so the block is + # empty and injection is skipped entirely. + assert result is rc + + @pytest.mark.asyncio + async def test_before_model_request_disabled_by_default(self) -> None: + """before_model_request is a no-op when index_enabled=False (default).""" + from pydantic_ai.messages import ModelRequest, UserPromptPart + + cap = WikiBuildCapability(wiki_root="viking://resources/810test") + ctx = _make_ctx() + msg = ModelRequest(parts=[UserPromptPart(content="hello")]) + rc = _make_request_context([msg]) + + result = await cap.before_model_request(ctx, rc) + + assert result is rc + assert cap._index_injected is False + + @pytest.mark.asyncio + async def test_before_model_request_second_turn_no_reinjection(self) -> None: + """before_model_request skips on subsequent turns (no re-injection).""" + from pydantic_ai.messages import ModelRequest, UserPromptPart + + cap = WikiBuildCapability( + wiki_root="viking://resources/810test", + index_enabled=True, + ) + ctx = _make_ctx() + # 3 messages = subsequent turn + messages = [ + ModelRequest(parts=[UserPromptPart(content="msg1")]), + ModelRequest(parts=[UserPromptPart(content="msg2")]), + ModelRequest(parts=[UserPromptPart(content="msg3")]), + ] + rc = _make_request_context(messages) + + result = await cap.before_model_request(ctx, rc) + + assert result is rc + # _index_injected set to True to prevent future attempts + assert cap._index_injected is True + + @pytest.mark.asyncio + async def test_before_model_request_exception_returns_unchanged(self) -> None: + """before_model_request handles errors gracefully — no raise.""" + from unittest.mock import MagicMock, patch + + from pydantic_ai.messages import ModelRequest, UserPromptPart + + cap = WikiBuildCapability( + wiki_root="viking://resources/810test", + index_enabled=True, + ) + ctx = _make_ctx() + msg = ModelRequest(parts=[UserPromptPart(content="hello")]) + rc = _make_request_context([msg]) + + with patch.object( + cap, + "_resolve_index_roots", + MagicMock(side_effect=RuntimeError("boom")), + ): + result = await cap.before_model_request(ctx, rc) + + # Should return original context, not raise + assert result is rc + # _index_injected should be True (set before the try block, no retry) + assert cap._index_injected is True + + # ---- Config fields ---- + + def test_config_index_defaults(self) -> None: + """WikiBuildConfig has index defaults (disabled, 1000, 20).""" + cap = WikiBuildCapability() + assert cap._config.index_enabled is False + assert cap._config.index_max_tokens == 1000 + assert cap._config.index_limit == 20 + + def test_config_index_kwargs_roundtrip(self) -> None: + """__init__ kwargs flow through to WikiBuildConfig.""" + cap = WikiBuildCapability(index_enabled=True, index_max_tokens=500, index_limit=3) + assert cap._config.index_enabled is True + assert cap._config.index_max_tokens == 500 + assert cap._config.index_limit == 3 + + # ---- for_run() reset ---- + + @pytest.mark.asyncio + async def test_for_run_resets_index_injected(self) -> None: + """for_run() resets _index_injected to False.""" + cap = WikiBuildCapability( + wiki_root="viking://resources/810test", + index_enabled=True, + ) + cap._index_injected = True # Simulate after first turn + + ctx = _make_ctx() + run_cap = await cap.for_run(ctx) + + assert run_cap._index_injected is False + # Original is unchanged + assert cap._index_injected is True + + # --------------------------------------------------------------------------- # 7.5 — Handler Chain Integration Tests # --------------------------------------------------------------------------- diff --git a/tests/capabilities/viking/test_viking_integration.py b/tests/capabilities/viking/test_viking_integration.py index 643afb6e7..2688b7259 100644 --- a/tests/capabilities/viking/test_viking_integration.py +++ b/tests/capabilities/viking/test_viking_integration.py @@ -18,7 +18,9 @@ ModelResponse, SystemPromptPart, TextPart, + ToolCallPart, ToolReturn, + ToolReturnPart, UserPromptPart, ) from pydantic_ai.models import ModelRequestContext, ModelRequestParameters @@ -28,6 +30,7 @@ from wolfharness.capabilities.viking import VikingCapability from wolfharness.capabilities.viking.identity import VikingIdentity +from wolfharness.capabilities.viking.ingest import _extract_full_trace from wolfharness.capabilities.viking.tools import build_tools from wolfharness_config.capabilities import VikingCapabilityConfig, build_capability @@ -324,7 +327,8 @@ async def test_mode_tool_exposure_write() -> None: assert toolset is not None tool_names = list(toolset.tools.keys()) # type: ignore[attr-defined] # 4 write tools (remember gated by enable_memory, forget by enable_forget) - assert len(tool_names) == 4 + # + viking_upload_tree (tree upload from capability) + assert len(tool_names) == 5 @pytest.mark.asyncio @@ -356,7 +360,7 @@ async def test_mode_tool_exposure_all() -> None: toolset = cap.get_toolset() assert toolset is not None tool_names = list(toolset.tools.keys()) # type: ignore[attr-defined] - assert len(tool_names) == 12 # 7 retrieve + 4 write + 1 graph (expand included in retrieve) + assert len(tool_names) == 13 # 7 retrieve + 5 write + 1 graph (expand included in retrieve) @pytest.mark.asyncio @@ -380,7 +384,7 @@ async def test_mode_tool_exposure_with_config_fields() -> None: toolset = cap.get_toolset() assert toolset is not None tool_names = list(toolset.tools.keys()) # type: ignore[attr-defined] - assert len(tool_names) == 12 # default flags: enable_link=False, enable_memory=False + assert len(tool_names) == 13 # default flags: enable_link=False, enable_memory=False # --------------------------------------------------------------------------- @@ -667,7 +671,7 @@ async def test_l2_auto_recall_before_model_request_fires() -> None: sys_msg = result.messages[0] assert isinstance(sys_msg, ModelRequest) sys_part = sys_msg.parts[0] - assert isinstance(sys_part, SystemPromptPart) + assert isinstance(sys_part, UserPromptPart) assert "<openviking-recall>" in sys_part.content assert "viking://user/alice/memories/doc.md" in sys_part.content @@ -719,6 +723,102 @@ async def test_l2_auto_ingest_second_turn_triggers_ingestion() -> None: assert cap._last_ingested_idx == 3 +@pytest.mark.asyncio +async def test_l2_auto_ingest_rolls_back_cursor_when_commit_fails() -> None: + """L2: failed auto-ingest leaves the message range retryable.""" + client = _make_mock_client() + client.commit_session = AsyncMock(side_effect=RuntimeError("viking unavailable")) + identity = VikingIdentity(account_id="acct", user_id="alice", role="user") + + cfg = VikingCapabilityConfig( + mode="all", + auto_ingest_enabled=True, + auto_ingest_mode="sync", + ) + cap = _build_cap_from_config(cfg, client, identity) + + messages = [ + ModelRequest(parts=[UserPromptPart(content="What is X?")]), + ModelResponse(parts=[TextPart(content="X is a thing.")]), + ] + rc = _make_request_context(messages) + + await cap.before_model_request(_make_run_context(), rc) + + assert cap._last_ingested_idx == 0 + assert len(cap._failed_ingest_batches) == 1 + + +@pytest.mark.asyncio +async def test_l2_auto_ingest_retries_captured_batch_after_context_is_gone() -> None: + """L2: failed captured batches retry without relying on source messages.""" + client = _make_mock_client() + client.commit_session = AsyncMock( + side_effect=[RuntimeError("viking unavailable"), {"ok": True}] + ) + identity = VikingIdentity(account_id="acct", user_id="alice", role="user") + + cfg = VikingCapabilityConfig( + mode="all", + auto_ingest_enabled=True, + auto_ingest_mode="sync", + ) + cap = _build_cap_from_config(cfg, client, identity) + + messages = [ + ModelRequest(parts=[UserPromptPart(content="What is X?")]), + ModelResponse(parts=[TextPart(content="X is a thing.")]), + ] + await cap.before_model_request(_make_run_context(), _make_request_context(messages)) + + ctx = _make_run_context() + ctx.messages = [] + await cap._flush_tail(ctx) + + assert cap._failed_ingest_batches == [] + assert client.commit_session.await_count == 2 + + +def test_extract_full_trace_skips_system_prompt_and_preserves_tool_payloads() -> None: + """Full trace capture avoids injected system text and keeps tool output complete.""" + long_output = "result-" * 500 + messages = [ + ModelRequest( + parts=[ + SystemPromptPart(content="<openviking-recall>injected</openviking-recall>"), + UserPromptPart(content=["SY215C 发动机无法启动", {"text": "冷车更明显"}]), + ], + ), + ModelResponse( + parts=[ + TextPart(content="先查蓄电池。"), + ToolCallPart( + tool_name="viking_search", + args={"query": "SY215C 发动机无法启动"}, + tool_call_id="call-search", + ), + ], + ), + ModelRequest( + parts=[ + ToolReturnPart( + tool_name="viking_search", + content=long_output, + tool_call_id="call-search", + ), + ], + ), + ] + + trace = _extract_full_trace(messages, 0) + + assert trace[0]["role"] == "user" + assert trace[0]["parts"] == [{"type": "text", "text": "SY215C 发动机无法启动\n冷车更明显"}] + assert "injected" not in str(trace) + assert trace[-1]["role"] == "assistant" + assert trace[-1]["parts"][0]["tool_output"] == long_output + + # --------------------------------------------------------------------------- # 8.4 — L2: URI guard blocks viking:// URIs in protected tools # --------------------------------------------------------------------------- @@ -785,7 +885,7 @@ async def test_l2_allowed_uri_prefixes_block_read_outside_scope() -> None: ctx = _make_run_context() result = await read_tool(ctx, uris="viking://resources/raw/engine.md") - assert "outside the allowed prefixes" in result.return_value + assert "read_scope_denied" in result.return_value client.read.assert_not_called() @@ -917,6 +1017,58 @@ async def test_l2_allowed_uri_prefixes_config_roundtrip() -> None: "viking://resources/wiki/", "viking://resources/raw/", ] + assert cfg.write_allowed_uri_prefixes == [] + + +@pytest.mark.asyncio +async def test_l2_write_allowed_uri_prefixes_config_roundtrip() -> None: + """L2: write_allowed_uri_prefixes propagates independently from read prefixes.""" + from wolfharness import AgentsManifest + + yaml_str = """ +agents: + test_agent: + type: native + model: test + capabilities: + - type: viking + mode: all + allowed_uri_prefixes: + - viking://resources/wiki/ + write_allowed_uri_prefixes: + - viking://resources/tickets/ +""" + d = yamling.load_yaml(yaml_str, verify_type=dict) + manifest = AgentsManifest.model_validate(d) + cap_configs = manifest.agents["test_agent"].capabilities + assert len(cap_configs) == 1 + cfg = cap_configs[0] + assert isinstance(cfg, VikingCapabilityConfig) + assert cfg.allowed_uri_prefixes == ["viking://resources/wiki/"] + assert cfg.write_allowed_uri_prefixes == ["viking://resources/tickets/"] + + +@pytest.mark.asyncio +async def test_l2_allowed_uri_prefixes_do_not_block_write_by_default() -> None: + """L2: read prefixes do not constrain viking_write without an explicit write guard.""" + client = _make_mock_client() + cfg = VikingCapabilityConfig( + mode="all", + allowed_uri_prefixes=["viking://resources/wiki/"], + ) + cap = _build_cap_from_config(cfg, client) + tools = build_tools(cap) + write_tool = next(t for t in tools if t.__name__ == "viking_write") + + ctx = _make_run_context() + result = await write_tool( + ctx, + uri="viking://resources/810test/Procedure/tickets/OPA-001.md", + content="ticket", + ) + + assert "Wrote" in result.return_value + client.write.assert_called_once() @pytest.mark.asyncio @@ -967,7 +1119,7 @@ async def test_l2_memory_features_implicitly_allowed_outside_prefixes() -> None: for msg_ in result.messages if isinstance(msg_, ModelRequest) for part in msg_.parts - if isinstance(part, SystemPromptPart) + if isinstance(part, UserPromptPart) ) @@ -1137,11 +1289,11 @@ async def test_l2_profile_injection_first_turn_fires_second_skips() -> None: sys_msgs = [ m for m in result1.messages - if isinstance(m, ModelRequest) and any(isinstance(p, SystemPromptPart) for p in m.parts) + if isinstance(m, ModelRequest) and any(isinstance(p, UserPromptPart) for p in m.parts) ] assert len(sys_msgs) >= 1 profile_content = next( - p.content for m in sys_msgs for p in m.parts if isinstance(p, SystemPromptPart) + p.content for m in sys_msgs for p in m.parts if isinstance(p, UserPromptPart) ) assert "<openviking-profile>" in profile_content diff --git a/tests/capabilities/wiki/__init__.py b/tests/capabilities/wiki/__init__.py new file mode 100644 index 000000000..1526dae4f --- /dev/null +++ b/tests/capabilities/wiki/__init__.py @@ -0,0 +1 @@ +"""Wiki capability tests.""" diff --git a/tests/capabilities/wiki/test_ticket_reference_scopes.py b/tests/capabilities/wiki/test_ticket_reference_scopes.py new file mode 100644 index 000000000..b1b86c996 --- /dev/null +++ b/tests/capabilities/wiki/test_ticket_reference_scopes.py @@ -0,0 +1,138 @@ +"""Ticket reference scopes are independent from the ticket write scope.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from wolfharness.capabilities.wiki.tickets.opa import OPAMixin +from wolfharness.capabilities.wiki.tickets.ticket import EvalRevision, _ticket_evidence +from wolfharness.capabilities.wiki.tickets.ticket_engine import TicketEngine + + +pytestmark = pytest.mark.unit + + +class _FakeRawFs: + root_uri = "viking://resources/805/raw" + + +class _FakeStore: + root_uri = "viking://resources/810test" + + def is_wiki_uri(self, uri: str) -> bool: + return uri.startswith(self.root_uri + "/") + + def read_text(self, key: str) -> str | None: + del key + return None + + +class _FakeOpaTools(OPAMixin): + def __init__(self) -> None: + self.store = _FakeStore() + self._raw_fs = _FakeRawFs() + + def read_resource(self, uri: str, line_numbers: bool = False) -> str | None: + del uri, line_numbers + return None + + +def _ticket_engine() -> TicketEngine: + engine = TicketEngine.__new__(TicketEngine) + engine.store = _FakeStore() + engine._raw_fs = _FakeRawFs() + return engine + + +@pytest.mark.parametrize("factory", [_FakeOpaTools, _ticket_engine]) +def test_ticket_references_may_use_read_scope_outside_write_scope(factory: Any) -> None: + """A ticket written under one scope may cite a resource from another scope.""" + tools = factory() + + tools._validate_opa_uris( + "viking://resources/730/wikitest/Fault/tmp/sy215c/发动机不启动.draft.md", + ["viking://resources/730/wikitest/manual.md"], + ) + tools._validate_op_evidence( + ["viking://resources/730/wikitest/manual.md"], + ["viking://resources/730/wikitest/Fault/tmp/sy215c/发动机不启动.draft.md"], + ) + + +@pytest.mark.parametrize("factory", [_FakeOpaTools, _ticket_engine]) +def test_ticket_references_still_reject_non_provider_paths(factory: Any) -> None: + tools = factory() + + with pytest.raises(ValueError, match="OPA URI must be"): + tools._validate_opa_uris("not-a-provider-reference", []) + + +def _mixed_revision() -> EvalRevision: + """One revision with URI citations plus text and URI evidence entries.""" + return EvalRevision( + ticket_id="OPA-001", + kind="OPA", + cited_references=[ + { + "ref_id": "knowledge-1", + "title": "draft", + "uri": "viking://resources/730/wikitest/Fault/x.md", + }, + ], + evidence=[ + "QuotedText: 启动时蓄电池电压 >10V 正常范围", + "Matched knowledge snippet: 蓄电池电压 SY75C >12V", + "viking://resources/805/raw/cases/c1.md", + ], + ) + + +@pytest.mark.parametrize("factory", [_FakeOpaTools, _ticket_engine]) +def test_is_valid_op_uri_accepts_provider_uris_and_rejects_text(factory: Any) -> None: + """The exposed predicate mirrors ``_validate_opa_uris`` format rules.""" + tools = factory() + + assert tools.is_valid_op_uri("viking://resources/730/wikitest/manual.md") + assert tools.is_valid_op_uri("viking://resources/805/raw/cases/c.md") + assert tools.is_valid_op_uri("viking://resources/810test/Fault/f.md") + + assert not tools.is_valid_op_uri("QuotedText: 启动时蓄电池电压 >10V") + assert not tools.is_valid_op_uri("Matched knowledge snippet: 蓄电池电压 SY75C >12V") + assert not tools.is_valid_op_uri("not-a-provider-reference") + + +@pytest.mark.parametrize("factory", [_FakeOpaTools, _ticket_engine]) +def test_ticket_evidence_extracts_only_uri_entries_when_predicate_given( + factory: Any, +) -> None: + """Plain-text ``evidence`` entries must not leak into evidence URIs. + + Regression: the ticket closures re-derive evidence URIs from the eval + revision dict (bypassing the xeno-side adapter filter); without the + predicate the engine's ``_validate_opa_uris`` rejected text entries such + as ``QuotedText: ...`` with ``OPA URI must be a provider resource URI``. + """ + tools = factory() + revision = _mixed_revision() + + evidence = _ticket_evidence(revision, is_uri_valid=tools.is_valid_op_uri) + + assert evidence == [ + "viking://resources/730/wikitest/Fault/x.md", + "viking://resources/805/raw/cases/c1.md", + ] + + +@pytest.mark.parametrize("factory", [_FakeOpaTools, _ticket_engine]) +def test_ticket_evidence_without_predicate_keeps_historical_behavior( + factory: Any, +) -> None: + """No predicate (third-party engine) preserves the historical blind merge.""" + factory() + revision = _mixed_revision() + + evidence = _ticket_evidence(revision, is_uri_valid=None) + assert len(evidence) == 4 + assert any(item.startswith("QuotedText:") for item in evidence) diff --git a/tests/config/test_mcp_server_config.py b/tests/config/test_mcp_server_config.py index 026da4b6a..2934f2858 100644 --- a/tests/config/test_mcp_server_config.py +++ b/tests/config/test_mcp_server_config.py @@ -228,6 +228,21 @@ def test_to_transport_sse(): assert transport.headers == {"Authorization": "Bearer token"} +def test_to_transport_sse_expands_header_env_vars(monkeypatch: pytest.MonkeyPatch): + """SSEMCPServerConfig.to_transport() should expand environment variables in headers.""" + from fastmcp.client.transports import SSETransport + + monkeypatch.setenv("TEST_MCP_TOKEN", "expanded-token") + config = SSEMCPServerConfig( + url=HttpUrl("http://localhost:8080/sse"), + headers={"Authorization": "Bearer ${TEST_MCP_TOKEN}"}, + ) + transport = config.to_transport() + + assert isinstance(transport, SSETransport) + assert transport.headers == {"Authorization": "Bearer expanded-token"} + + def test_to_transport_http(): """StreamableHTTPMCPServerConfig.to_transport() should return StreamableHttpTransport.""" from fastmcp.client.transports import StreamableHttpTransport @@ -243,34 +258,19 @@ def test_to_transport_http(): assert transport.headers == {"X-Api-Key": "secret"} -def test_to_transport_sse_expands_env_in_headers(monkeypatch): - """to_transport() expands ${VAR} in SSE header values (repro for #365).""" - from fastmcp.client.transports import SSETransport - - monkeypatch.setenv("VIKING_MCP_API_KEY", "actual-token") - config = SSEMCPServerConfig( - url=HttpUrl("http://localhost:8080/sse"), - headers={"Authorization": "Bearer ${VIKING_MCP_API_KEY}"}, - ) - transport = config.to_transport() - - assert isinstance(transport, SSETransport) - assert transport.headers == {"Authorization": "Bearer actual-token"} - - -def test_to_transport_http_expands_env_in_headers(monkeypatch): - """StreamableHttp to_transport() expands ${VAR} in header values (#365).""" +def test_to_transport_http_expands_header_env_vars(monkeypatch: pytest.MonkeyPatch): + """StreamableHTTPMCPServerConfig.to_transport() should expand env vars in headers.""" from fastmcp.client.transports import StreamableHttpTransport - monkeypatch.setenv("VIKING_MCP_API_KEY", "actual-token") + monkeypatch.setenv("TEST_MCP_TOKEN", "expanded-token") config = StreamableHTTPMCPServerConfig( url=HttpUrl("https://api.example.com/mcp"), - headers={"Authorization": "Bearer ${VIKING_MCP_API_KEY}"}, + headers={"Authorization": "Bearer ${TEST_MCP_TOKEN}"}, ) transport = config.to_transport() assert isinstance(transport, StreamableHttpTransport) - assert transport.headers == {"Authorization": "Bearer actual-token"} + assert transport.headers == {"Authorization": "Bearer expanded-token"} def test_to_transport_acp_raises(): diff --git a/tests/config/test_storage_config.py b/tests/config/test_storage_config.py new file mode 100644 index 000000000..67307cb25 --- /dev/null +++ b/tests/config/test_storage_config.py @@ -0,0 +1,32 @@ +"""Tests for portable WolfHarness storage configuration.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from wolfharness_config.storage import get_database_path + + +if TYPE_CHECKING: + from pathlib import Path + + +pytestmark = pytest.mark.unit + + +def test_database_path_uses_configured_data_dir( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.delenv("WOLFHARNESS_DATABASE_URL", raising=False) + monkeypatch.setenv("WOLFHARNESS_DATA_DIR", str(tmp_path / "history")) + + assert get_database_path() == f"sqlite:///{tmp_path / 'history' / 'history.db'}" + + +def test_database_url_takes_precedence(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("WOLFHARNESS_DATA_DIR", str(tmp_path / "history")) + monkeypatch.setenv("WOLFHARNESS_DATABASE_URL", "sqlite:////private/tmp/wolfharness-smoke.db") + + assert get_database_path() == "sqlite:////private/tmp/wolfharness-smoke.db" diff --git a/tests/delegation/test_pool_skills.py b/tests/delegation/test_pool_skills.py index fec5fda53..86a4d3999 100644 --- a/tests/delegation/test_pool_skills.py +++ b/tests/delegation/test_pool_skills.py @@ -460,3 +460,47 @@ async def supports_resources(self) -> bool: assert len(prefixes) == 2 assert prefixes[0] == "kb" assert prefixes[1] == "kb_2" + + async def test_mcp_server_explicit_tool_prefix_preferred( + self, + manifest_with_mcp: AgentsManifest, + monkeypatch: Any, + ) -> None: + """Configured tool_prefix takes precedence over display_name-derived prefixes.""" + from wolfharness.mcp_server.manager import MCPManager + from wolfharness_config.mcp_server import StreamableHTTPMCPServerConfig + + class _FakeClient: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + @property + def connected(self) -> bool: + return True + + async def __aenter__(self) -> Self: + return self + + async def __aexit__(self, *args: object) -> None: + return None + + async def supports_resources(self) -> bool: + return False + + monkeypatch.setattr( + "wolfharness.mcp_server.client.MCPClient", + _FakeClient, + ) + + manager = MCPManager( + name="pool_mcp", + servers=[ + StreamableHTTPMCPServerConfig( + url="http://127.0.0.1:1/mcp", + name="Knowledge Base", + tool_prefix="fault_kb", + ), + ], + ) + async with manager: + assert [p.tool_prefix for p in manager.providers] == ["fault_kb"] diff --git a/tests/host/test_factory.py b/tests/host/test_factory.py index 6dc466d27..e55cafef8 100644 --- a/tests/host/test_factory.py +++ b/tests/host/test_factory.py @@ -173,6 +173,57 @@ def test_compile_registers_config_capabilities_at_agent_scope( assert isinstance(test_ra_caps[0], TestResourceAccessCap) +def test_register_config_capabilities_allows_same_owned_scheme_across_agent_scopes( + minimal_pool: AgentPool, +) -> None: + """Per-agent resource providers may own the same URI scheme.""" + from tests.fixtures.test_resource_cap import TestResourceAccessCap + from wolfharness.capabilities.extension_registry import Scope, ScopeLevel + + class VikingLikeResourceCap(TestResourceAccessCap): + @property + def owned_schemes(self) -> frozenset[str]: + return frozenset({"viking"}) + + cap_a = VikingLikeResourceCap(read_text="a", read_uri="viking://doc.md") + cap_b = VikingLikeResourceCap(read_text="b", read_uri="viking://doc.md") + + class NativeCfgA: + def __init__(self) -> None: + self.capabilities = [cap_a] + + class NativeCfgB: + def __init__(self) -> None: + self.capabilities = [cap_b] + + cfg_a = NativeCfgA() + cfg_b = NativeCfgB() + + manifest = MagicMock() + manifest.agents = {"agent_a": cfg_a, "agent_b": cfg_b} + mcp = MagicMock() + mcp.get_mcp_providers.return_value = [] + + factory = AgentFactory(pool=minimal_pool) + + with patch("wolfharness.models.agents.NativeAgentConfig", (NativeCfgA, NativeCfgB)): + factory.register_config_capabilities( + manifest=manifest, + host_context=_make_host_context(mcp=mcp), + ) + + visible_a = minimal_pool.extension_registry.get_visible_capabilities( + Scope(level=ScopeLevel.AGENT, agent_name="agent_a") + ) + visible_b = minimal_pool.extension_registry.get_visible_capabilities( + Scope(level=ScopeLevel.AGENT, agent_name="agent_b") + ) + assert cap_a in visible_a + assert cap_a not in visible_b + assert cap_b in visible_b + assert cap_b not in visible_a + + def test_get_visible_capabilities_no_duplicates_across_scopes( minimal_pool: AgentPool, ) -> None: diff --git a/tests/lifecycle/test_session_migration.py b/tests/lifecycle/test_session_migration.py index bf5babb86..f351a1575 100644 --- a/tests/lifecycle/test_session_migration.py +++ b/tests/lifecycle/test_session_migration.py @@ -22,6 +22,7 @@ from __future__ import annotations import asyncio +from types import SimpleNamespace from typing import Any from unittest.mock import ANY, AsyncMock, MagicMock @@ -52,6 +53,46 @@ pytestmark = pytest.mark.unit +def test_get_live_run_clears_stale_current_run_id() -> None: + """A missing run handle must not leave a session permanently busy.""" + controller = SessionController(pool=MagicMock()) + session = SessionState(session_id="stale-session", agent_name="test_agent") + session.current_run_id = "missing-run" + controller._sessions[session.session_id] = session + + assert controller.get_live_run(session.session_id) is None + assert session.current_run_id is None + + +def test_get_live_run_returns_only_an_incomplete_registered_run() -> None: + """A registered incomplete handle is the only authoritative busy state.""" + controller = SessionController(pool=MagicMock()) + session = SessionState(session_id="live-session", agent_name="test_agent") + session.current_run_id = "live-run" + controller._sessions[session.session_id] = session + run = SimpleNamespace(complete_event=asyncio.Event()) + controller._runs["live-run"] = run + + assert controller.get_live_run(session.session_id) is run + + run.complete_event.set() + assert controller.get_live_run(session.session_id) is None + assert session.current_run_id is None + + +def test_session_pool_active_run_lookup_repairs_stale_pointer() -> None: + """SessionPool routing must not wait on a completed or missing run.""" + controller = SessionController(pool=MagicMock()) + session = SessionState(session_id="stale-pool-session", agent_name="test_agent") + session.current_run_id = "missing-run" + controller._sessions[session.session_id] = session + session_pool = SessionPool.__new__(SessionPool) + session_pool.sessions = controller + + assert session_pool._get_active_run_handle(session.session_id) is None + assert session.current_run_id is None + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/mcp_server/test_manager_capability.py b/tests/mcp_server/test_manager_capability.py index df3068d66..e52747348 100644 --- a/tests/mcp_server/test_manager_capability.py +++ b/tests/mcp_server/test_manager_capability.py @@ -7,6 +7,7 @@ from pydantic import HttpUrl from pydantic_ai.mcp import MCPToolset +from pydantic_ai.toolsets import PrefixedToolset import pytest from wolfharness.mcp_server.manager import MCPManager, _make_elicitation_handler @@ -144,6 +145,23 @@ async def test_allowed_tools_passed_through() -> None: assert caps[0].allowed_tools == ["read_file", "list_directory"] +async def test_tool_prefix_namespaces_mcp_tools() -> None: + """tool_prefix should namespace MCP tools and allowed tool names.""" + config = StdioMCPServerConfig( + command="python", + args=["server.py"], + enabled_tools=["search", "read"], + tool_prefix="kb1", + ) + manager = MCPManager(servers=[config]) + + caps = await manager.get_capabilities() + + assert len(caps) == 1 + assert caps[0].allowed_tools == ["kb1_search", "kb1_read"] + assert isinstance(caps[0].local, PrefixedToolset) + + async def test_capability_is_abstract_capability() -> None: """Returned capabilities should be instances of AbstractCapability.""" from pydantic_ai.capabilities import AbstractCapability diff --git a/tests/messaging/test_compaction.py b/tests/messaging/test_compaction.py index 21c0122c1..ad2d4c700 100644 --- a/tests/messaging/test_compaction.py +++ b/tests/messaging/test_compaction.py @@ -24,6 +24,7 @@ KeepFirstMessages, KeepLastMessages, TruncateTextParts, + TruncateToolCallInputs, TruncateToolOutputs, WhenMessageCountExceeds, balanced_context, @@ -33,6 +34,7 @@ CompactionConfig, FilterThinkingConfig, KeepLastMessagesConfig, + TruncateToolCallInputsConfig, TruncateToolOutputsConfig, WhenMessageCountExceedsConfig, ) @@ -196,6 +198,66 @@ async def test_truncate_text_parts(): assert "[truncated]" in text_part.content +async def test_truncate_tool_call_inputs_str(): + """Test truncation of large string tool-call input arguments.""" + long_args = "x" * 5000 + part = ToolCallPart(tool_name="read", args=long_args, tool_call_id="1") + messages = [ModelResponse(parts=[part])] + step = TruncateToolCallInputs(max_length=100) + result = await step.apply(messages) + response = result[0] + assert isinstance(response, ModelResponse) + call = response.parts[0] + assert isinstance(call, ToolCallPart) + assert isinstance(call.args, str) + assert len(call.args) < 150 + assert "[input truncated]" in call.args + assert call.tool_name == "read" + assert call.tool_call_id == "1" + + +async def test_truncate_tool_call_inputs_dict(): + """Test truncation of large dict tool-call input arguments (recursive).""" + big = "y" * 5000 + small = "ok" + data = {"path": big, "nested": {"content": big}, "small": small} + part = ToolCallPart(tool_name="write", args=data, tool_call_id="2") + messages = [ModelResponse(parts=[part])] + step = TruncateToolCallInputs(max_length=100) + result = await step.apply(messages) + call = result[0].parts[0] + assert isinstance(call, ToolCallPart) + assert isinstance(call.args, dict) + assert len(call.args["path"]) < 150 + assert len(call.args["nested"]["content"]) < 150 + assert "[input truncated]" in call.args["path"] + assert call.args["small"] == "ok" + + +async def test_truncate_tool_call_inputs_short_and_none_untouched(): + """Test that short/None tool-call inputs are left untouched.""" + part = ToolCallPart(tool_name="get", args="hi", tool_call_id="3") + none_part = ToolCallPart(tool_name="get", args=None, tool_call_id="4") + messages = [ + ModelResponse(parts=[part]), + ModelResponse(parts=[none_part]), + ] + step = TruncateToolCallInputs(max_length=100) + result = await step.apply(messages) + assert result[0].parts[0].args == "hi" + assert result[1].parts[0].args is None + + +async def test_truncate_tool_call_inputs_config_roundtrip(): + """Test the TruncateToolCallInputs YAML config builds correctly.""" + from wolfharness.messaging.compaction import TruncateToolCallInputs + + cfg = TruncateToolCallInputsConfig(max_length=300) + step = cfg.build() + assert isinstance(step, TruncateToolCallInputs) + assert step.max_length == 300 + + async def test_keep_last_messages(sample_messages): """Test keeping only last N messages.""" step = KeepLastMessages(count=2, count_pairs=False) diff --git a/tests/orchestrator/test_event_mapper_tool_call_sanitization.py b/tests/orchestrator/test_event_mapper_tool_call_sanitization.py index f295809d3..494255782 100644 --- a/tests/orchestrator/test_event_mapper_tool_call_sanitization.py +++ b/tests/orchestrator/test_event_mapper_tool_call_sanitization.py @@ -236,3 +236,36 @@ def test_idempotent(self): sanitize_tool_call_args_in_messages(messages) assert messages[0].parts[0].args == '{"cmd": "ls"}' + + def test_repairs_invalid_json_args_to_empty_object(self): + """Args that fail json.loads (truncated/unescaped) become {}. + + Regression: poisoned args previously persisted verbatim into message + history, making the next model request fail with vLLM HTTP 400 + "Assistant tool call function.arguments must be valid JSON". + """ + call = ToolCallPart( + tool_name="distill", + args='{"topic": "body"影响范围"补链"}', + tool_call_id="call_broken", + ) + response = ModelResponse(parts=[call], model_name="svc/deepseek-v4-flash") + messages: list[Any] = [response] + + sanitize_tool_call_args_in_messages(messages) + + assert messages[0].parts[0].args == "{}" + + def test_repairs_truncated_json_args_to_empty_object(self): + """A truncated JSON blob (unknown length) also degrades to {}.""" + call = ToolCallPart( + tool_name="distill", + args='{"topic": "long truncated streamed content without closing brace', + tool_call_id="call_truncated", + ) + response = ModelResponse(parts=[call], model_name="svc/deepseek-v4-flash") + messages: list[Any] = [response] + + sanitize_tool_call_args_in_messages(messages) + + assert messages[0].parts[0].args == "{}" diff --git a/tests/orchestrator/test_execute_turn.py b/tests/orchestrator/test_execute_turn.py index 0fdc76857..970ab0f54 100644 --- a/tests/orchestrator/test_execute_turn.py +++ b/tests/orchestrator/test_execute_turn.py @@ -133,3 +133,62 @@ async def mock_execute(): complete_events = [e for e in events if isinstance(e, StreamCompleteEvent)] assert len(complete_events) == 1 assert handle._current_turn_failed is False + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_execute_turn_dispatches_to_agent_event_handler() -> None: + """Serve path (RunHandle) forwards events to the agent's event handler. + + The configured ``event_handlers`` (e.g. + ``wolfharness.capabilities.wiki.harness.event_handlers:per_session_file_handler``) only fire + when ``_execute_turn()`` dispatches each mapped event to + ``agent.event_handler``. Without this dispatch, per-session log files + are never written in the serve/OpenCode path. + """ + from wolfharness.orchestrator.run import RunHandle + + complete_event = StreamCompleteEvent( + message=ChatMessage(content="done", role="assistant"), + cancelled=False, + ) + + async def mock_execute(): + yield complete_event + + mock_turn = MagicMock() + mock_turn.execute = mock_execute + mock_turn._final_message = ChatMessage(content="done", role="assistant") + + mock_agent = MagicMock() + mock_agent.create_turn = MagicMock(return_value=mock_turn) + mock_agent.conversation = MagicMock() + mock_agent.conversation.add_chat_messages = MagicMock() + # The agent's event_handler is a MultiEventHandler; a real AsyncMock + # asserts each dispatched event reaches it. + handler = AsyncMock() + mock_agent.event_handler = handler + mock_agent.get_context = MagicMock(return_value="ctx-mock") + + handle = RunHandle( + run_id="test-run", + session_id="test-session", + agent_type="native", + agent=mock_agent, + event_bus=None, + session=None, + run_ctx=MagicMock(), + ) + + _ = [ + event + async for event in handle._execute_turn(mock_agent, None, _make_mock_session(), ["test"]) + ] + + # Both RunStartedEvent and the turned event must be dispatched. + assert handler.await_count == 2, ( + f"Expected RunStartedEvent + StreamCompleteEvent dispatched, got {handler.await_count}" + ) + dispatched_kinds = [call.args[1].__class__.__name__ for call in handler.await_args_list] + assert "RunStartedEvent" in dispatched_kinds + assert "StreamCompleteEvent" in dispatched_kinds diff --git a/tests/servers/acp_server/test_acp_prompt_metadata.py b/tests/servers/acp_server/test_acp_prompt_metadata.py new file mode 100644 index 000000000..d7d954538 --- /dev/null +++ b/tests/servers/acp_server/test_acp_prompt_metadata.py @@ -0,0 +1,266 @@ +"""Tests for ACP prompt metadata helpers.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from acp.schema import ( + AgentMessageChunk, + AgentThoughtChunk, + TextContentBlock, + ToolCallProgress, + ToolCallStart, + TurnCompleteUpdate, + UserMessageChunk, +) +from wolfharness_server.acp_server.handler import _content_items_to_text +from wolfharness_server.acp_server.viking_archive import ACPVikingEventArchive + + +async def _drain_archive_tasks(archive: ACPVikingEventArchive) -> None: + for _ in range(5): + if not archive._tasks: + return + await asyncio.sleep(0) + + +def test_content_items_to_text_keeps_text_prompt_for_capability_metadata() -> None: + text = _content_items_to_text([ + "/systematic-troubleshooting cy215c 动臂吊臂", + SimpleNamespace(text="补充: 冷车更明显"), + SimpleNamespace(uri="file:///tmp/image.png"), + ]) + + assert text == "/systematic-troubleshooting cy215c 动臂吊臂\n补充: 冷车更明显" + + +@pytest.mark.asyncio +async def test_viking_archive_serializes_acp_user_thought_and_tool_updates() -> None: + archive = ACPVikingEventArchive(enabled=True, batch_size=100) + + await archive.record_update( + consumer_session_id="acp-session-1", + source_session_id="acp-session-1", + event_id="evt-user", + update=UserMessageChunk.text("/fta-eval report.md", message_id="msg-user"), + ) + await archive.record_update( + consumer_session_id="acp-session-1", + source_session_id="acp-session-1", + event_id="evt-think", + update=AgentThoughtChunk.text("需要先读取报告", message_id="msg-think"), + ) + await archive.record_update( + consumer_session_id="acp-session-1", + source_session_id="acp-session-1", + event_id="evt-tool-start", + update=ToolCallStart( + tool_call_id="tool-1", + title="Read report", + status="in_progress", + kind="read", + ), + ) + await archive.record_update( + consumer_session_id="acp-session-1", + source_session_id="acp-session-1", + event_id="evt-tool-update", + update=ToolCallProgress(tool_call_id="tool-1", status="completed"), + ) + + assert archive._pending_update_count["acp-session-1"] == 4 + assert len(archive._transcript_pending["acp-session-1"]) == 4 + assert archive._transcript_pending["acp-session-1"][2]["parts"][0]["tool_id"] == "tool-1" + assert archive._transcript_pending["acp-session-1"][3]["parts"][0]["tool_status"] == "completed" + + +@pytest.mark.asyncio +async def test_viking_archive_writes_structured_updates_as_readable_transcript() -> None: + client = AsyncMock() + client.commit_session = AsyncMock(return_value={"archive_uri": "viking://archive/001"}) + archive = ACPVikingEventArchive( + enabled=True, + session_prefix="iroot-acp-session", + user="bin.chen", + ) + archive._client = client + + await archive.record_update( + consumer_session_id="session/with spaces", + source_session_id="session/with spaces", + event_id=123, + update=UserMessageChunk.text("/systematic-troubleshooting cy215c 动臂吊臂"), + ) + await archive.flush_session("session/with spaces") + + client.write.assert_not_awaited() + client.create_session.assert_awaited_once_with( + session_id="iroot-acp-session-session-with-spaces", + source_type="iroot-acp-session", + ) + client.add_message.assert_awaited_once_with( + "iroot-acp-session-session-with-spaces", + "user", + parts=[{"type": "text", "text": "/systematic-troubleshooting cy215c 动臂吊臂"}], + ) + client.commit_session.assert_awaited_once_with("iroot-acp-session-session-with-spaces") + + +@pytest.mark.asyncio +async def test_viking_archive_writes_readable_transcript_with_merged_chunks() -> None: + client = AsyncMock() + client.commit_session = AsyncMock(return_value={"archive_uri": "viking://archive/001"}) + archive = ACPVikingEventArchive(enabled=True, user="bin.chen") + archive._client = client + + await archive.record_update( + consumer_session_id="acp-session-1", + source_session_id="acp-session-1", + event_id="evt-user-1", + update=UserMessageChunk.text("发动机", message_id="msg-user"), + ) + await archive.record_update( + consumer_session_id="acp-session-1", + source_session_id="acp-session-1", + event_id="evt-user-2", + update=UserMessageChunk.text("无法启动", message_id="msg-user"), + ) + await archive.record_update( + consumer_session_id="acp-session-1", + source_session_id="acp-session-1", + event_id="evt-assistant-1", + update=AgentMessageChunk( + content=TextContentBlock(text="先检查"), + message_id="msg-agent", + ), + ) + await archive.record_update( + consumer_session_id="acp-session-1", + source_session_id="acp-session-1", + event_id="evt-assistant-2", + update=AgentMessageChunk( + content=TextContentBlock(text="蓄电池电压"), + message_id="msg-agent", + ), + ) + + await archive.flush_session("acp-session-1") + + assert client.add_message.await_count == 2 + assert client.add_message.await_args_list[0].args == ( + "iroot-acp-session-acp-session-1", + "user", + ) + assert client.add_message.await_args_list[0].kwargs["parts"] == [ + {"type": "text", "text": "发动机无法启动"} + ] + assert client.add_message.await_args_list[1].args == ( + "iroot-acp-session-acp-session-1", + "assistant", + ) + assert client.add_message.await_args_list[1].kwargs["parts"] == [ + {"type": "text", "text": "先检查蓄电池电压"} + ] + + +@pytest.mark.asyncio +async def test_viking_archive_filters_low_signal_ack_messages() -> None: + client = AsyncMock() + archive = ACPVikingEventArchive(enabled=True, user="bin.chen") + archive._client = client + + await archive.record_update( + consumer_session_id="acp-session-1", + source_session_id="acp-session-1", + event_id="evt-user", + update=UserMessageChunk.text("好的", message_id="msg-user"), + ) + await archive.flush_session("acp-session-1") + + client.add_message.assert_not_awaited() + client.commit_session.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_viking_archive_flush_failure_is_non_blocking() -> None: + client = AsyncMock() + client.commit_session = AsyncMock(side_effect=RuntimeError("viking unavailable")) + archive = ACPVikingEventArchive(enabled=True) + archive._client = client + + await archive.record_update( + consumer_session_id="acp-session-1", + source_session_id="acp-session-1", + event_id="evt-user", + update=UserMessageChunk.text("普通 prompt"), + ) + + await archive.flush_session("acp-session-1") + + client.commit_session.assert_awaited_once() + assert archive._pending_update_count["acp-session-1"] == 1 + assert len(archive._transcript_pending["acp-session-1"]) == 1 + + +@pytest.mark.asyncio +async def test_viking_archive_resolves_user_from_health_for_user_scoped_uri() -> None: + client = AsyncMock() + response = SimpleNamespace(json=lambda: {"user_id": "bin.chen"}) + client._request = AsyncMock(return_value=response) + + archive = ACPVikingEventArchive(enabled=True) + await archive._resolve_user_from_health(client) + + assert archive._archive_base_uri("session/with spaces") == ( + "viking://user/bin.chen/sessions/iroot-acp-session-session-with-spaces" + ) + + +def test_viking_archive_from_config_requires_url(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("VIKING_MCP_URL", raising=False) + + archive = ACPVikingEventArchive.from_config( + SimpleNamespace(enabled=True, api_key="test-key", url=None) + ) + + assert archive.enabled is False + + +def test_viking_archive_from_config_uses_env_url(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VIKING_MCP_URL", "https://viking.example.com") + + archive = ACPVikingEventArchive.from_config( + SimpleNamespace(enabled=True, api_key="test-key", url=None) + ) + + assert archive.enabled is True + assert archive.url == "https://viking.example.com" + + +@pytest.mark.asyncio +async def test_viking_archive_turn_complete_flushes_pending_events() -> None: + client = AsyncMock() + archive = ACPVikingEventArchive(enabled=True, flush_on_turn_complete=True) + archive._client = client + + await archive.record_update( + consumer_session_id="acp-session-1", + source_session_id="acp-session-1", + event_id="evt-user", + update=UserMessageChunk.text("普通 prompt"), + ) + await archive.record_update( + consumer_session_id="acp-session-1", + source_session_id="acp-session-1", + event_id="evt-done", + update=TurnCompleteUpdate(stop_reason="end_turn"), + ) + await _drain_archive_tasks(archive) + + assert "acp-session-1" not in archive._pending_update_count + client.add_message.assert_awaited_once() + client.commit_session.assert_awaited_once() diff --git a/tests/servers/opencode_server/test_resource_resolution.py b/tests/servers/opencode_server/test_resource_resolution.py index 199d3123e..6fb90e697 100644 --- a/tests/servers/opencode_server/test_resource_resolution.py +++ b/tests/servers/opencode_server/test_resource_resolution.py @@ -26,6 +26,7 @@ TextResourceContent, ) from wolfharness.capabilities.resource_resolver import resolve_resource_content +from wolfharness.capabilities.uri_scheme_registry import UriSchemeRegistry if TYPE_CHECKING: @@ -73,6 +74,24 @@ async def resource_exists(self, uri: str) -> bool: return self._read_result is not None +class FakeOwnedResourceAccess(FakeResourceAccess): + """Resource provider that explicitly owns one URI scheme.""" + + def __init__( + self, + *, + scheme: str, + read_result: list[TextResourceContent | BlobResourceContent] | None = None, + raise_exc: Exception | None = None, + ) -> None: + super().__init__(read_result=read_result, raise_exc=raise_exc) + self._scheme = scheme + + @property + def owned_schemes(self) -> frozenset[str]: + return frozenset({self._scheme}) + + class FakeSkillResource: """Minimal ``SkillResource`` implementation for testing. @@ -216,6 +235,33 @@ async def test_resolve_resource_mixed_text_and_binary() -> None: assert result[3] == "\n</resource>" +async def test_resolve_resource_uses_visible_owner_when_global_owner_not_visible() -> None: + """Agent-scoped duplicate scheme owners route to the visible provider.""" + first_agent_cap = FakeOwnedResourceAccess( + scheme="viking", + read_result=[TextResourceContent(text="first", uri="viking://doc.md")], + ) + visible_agent_cap = FakeOwnedResourceAccess( + scheme="viking", + read_result=[TextResourceContent(text="visible", uri="viking://doc.md")], + ) + scheme_registry = UriSchemeRegistry() + scheme_registry.register( + provider_name="FirstAgentViking", + schemes=frozenset({"viking"}), + provider=first_agent_cap, + ) + + result = await resolve_resource_content( + "viking://doc.md", + resource_caps=[visible_agent_cap], + skill_caps=[], + scheme_registry=scheme_registry, + ) + + assert result == ['<resource uri="viking://doc.md">\nvisible\n</resource>'] + + async def test_resolve_resource_multiple_providers() -> None: """First provider returns None, second returns content → returns content from second.""" cap1 = FakeResourceAccess(read_result=None) diff --git a/tests/team_mode/conftest.py b/tests/team_mode/conftest.py index 28165cefa..84d389919 100644 --- a/tests/team_mode/conftest.py +++ b/tests/team_mode/conftest.py @@ -312,6 +312,7 @@ def make_mock_pool() -> MagicMock: pool.create_child_session = AsyncMock(return_value=mock_child_state) pool.sessions = MagicMock() pool.sessions.get_or_create_session_agent = AsyncMock() + pool.sessions.get_live_run = MagicMock(return_value=None) pool.event_bus = None return pool diff --git a/tests/team_mode/test_unit_batch_progress.py b/tests/team_mode/test_unit_batch_progress.py index e83206af6..5da9d3eb3 100644 --- a/tests/team_mode/test_unit_batch_progress.py +++ b/tests/team_mode/test_unit_batch_progress.py @@ -192,6 +192,26 @@ def test_batch_exceeding_max_tasks_atomic_failure( ) +def test_terminal_tasks_do_not_consume_dispatch_capacity( + initialized_team: FileTeamState, +) -> None: + """Completed task history must not block a later dispatch batch.""" + from wolfharness.capabilities.file_team_state import _MAX_TASKS + + task_ids = [ + initialized_team.create_task("team-1", {"subject": f"Work {i}"}) for i in range(_MAX_TASKS) + ] + for task_id in task_ids[:10]: + initialized_team.update_task("team-1", task_id, {"status": "completed"}) + + new_ids = initialized_team.create_tasks_batch( + "team-1", + [{"subject": "Follow-up A"}, {"subject": "Follow-up B"}], + ) + + assert len(new_ids) == 2 + + def test_batch_with_progress_total(initialized_team: FileTeamState) -> None: """Given: initialized team. @@ -209,6 +229,30 @@ def test_batch_with_progress_total(initialized_team: FileTeamState) -> None: assert task["progress_total"] == 50 +def test_batch_persists_write_scope_and_write_set(initialized_team: FileTeamState) -> None: + """Given: a formal-write task with an explicit isolation contract. + + When: the task is created through the batch API. + Then: both fields survive persistence for retries and conflict checks. + """ + write_set = ["viking://resources/816/Component/engine"] + task_ids = initialized_team.create_tasks_batch( + "team-1", + [ + { + "subject": "Write engine", + "write_scope": "component_write", + "write_set": write_set, + } + ], + ) + + task = initialized_team.get_task("team-1", task_ids[0]) + assert task is not None + assert task["write_scope"] == "component_write" + assert task["write_set"] == write_set + + def test_batch_empty_list_returns_empty(initialized_team: FileTeamState) -> None: """Given: initialized team. diff --git a/tests/team_mode/test_unit_capability.py b/tests/team_mode/test_unit_capability.py index c625594c9..1f2fb66e6 100644 --- a/tests/team_mode/test_unit_capability.py +++ b/tests/team_mode/test_unit_capability.py @@ -3,12 +3,14 @@ from __future__ import annotations import asyncio +import re from typing import Any from unittest.mock import AsyncMock, MagicMock from pydantic_ai.tools import ToolDefinition import pytest +from wolfharness.capabilities.file_team_state import FileTeamState from wolfharness.capabilities.team_comm_capability import TeamCommCapability from wolfharness_config.team_mode import TeamBounds, TeamModeConfig @@ -24,6 +26,7 @@ def _make_enabled_config( base_dir: str | None = None, notice_delivery_mode: str = "steer", max_watch_timeout: int = 120, + member_can_create_subtasks: bool = True, ) -> TeamModeConfig: """Create an enabled TeamModeConfig for testing. @@ -34,6 +37,7 @@ def _make_enabled_config( base_dir: Base directory for team state files. notice_delivery_mode: Delivery mode for notifications. max_watch_timeout: Max watch timeout in seconds. + member_can_create_subtasks: Whether members may create child tasks. Returns: A frozen TeamModeConfig with enabled=True. @@ -47,6 +51,7 @@ def _make_enabled_config( base_dir=base_dir, notice_delivery_mode=notice_delivery_mode, max_watch_timeout=max_watch_timeout, + member_can_create_subtasks=member_can_create_subtasks, ) @@ -543,6 +548,7 @@ def _make_add_member_setup( mock_pool.create_child_session = AsyncMock(return_value=mock_child_state) mock_pool.sessions = MagicMock() mock_pool.sessions.get_or_create_session_agent = AsyncMock() + mock_pool.sessions.get_live_run = MagicMock(return_value=None) mock_pool.event_bus = None # Skip SpawnSessionStart emission in unit tests mock_delegation = MagicMock() mock_delegation.create_child_session = AsyncMock(return_value="child_session_new") @@ -576,6 +582,11 @@ async def test_send_message_happy_path(tmp_path: Any) -> None: assert result.return_value == "Message sent to reviewer_agent" mock_pool.send_message.assert_awaited_once() + delivered = mock_pool.send_message.await_args.args[1] + assert re.search( + r'<team-message from="worker" type="private" sent_at="[^"]+Z">', + delivered, + ) @pytest.mark.unit @@ -1011,6 +1022,28 @@ async def test_task_list_returns_tasks(tmp_path: Any) -> None: assert "Task B" in result.return_value +@pytest.mark.unit +async def test_task_list_active_only_omits_terminal_history(tmp_path: Any) -> None: + """The compact board view must not rehydrate completed task history.""" + _init_team(str(tmp_path)) + ctx = _make_run_context( + metadata=_make_lead_metadata(), + base_dir=str(tmp_path), + ) + config = _make_enabled_config(base_dir=str(tmp_path)) + cap = TeamCommCapability(config, "coordinator", _make_lead_metadata()) + + completed = await cap.task_create(ctx, "Completed history", owner="translator_agent") + completed_id = completed.return_value.replace("Task created: ", "") + await cap.task_update(ctx, completed_id, status="completed") + await cap.task_create(ctx, "Active work", owner="translator_agent") + + result = await cap.task_list(ctx, active_only=True) + + assert "Active work" in result.return_value + assert "Completed history" not in result.return_value + + @pytest.mark.unit async def test_task_update_changes_status(tmp_path: Any) -> None: """Given: team session with an existing task. @@ -1420,6 +1453,35 @@ async def test_team_create_success(tmp_path: Any) -> None: assert mock_pool.send_message.await_count == 2 +@pytest.mark.unit +async def test_team_create_propagates_team_state_directory_to_members(tmp_path: Any) -> None: + """Spawned member sessions receive the directory containing their task board.""" + config = _make_enabled_config( + member_eligible=["worker"], + base_dir=str(tmp_path), + ) + mock_registry = MagicMock() + mock_registry.exists = MagicMock(return_value=True) + mock_pool = MagicMock() + mock_pool.send_message = AsyncMock(return_value="msg_id") + ctx = _make_run_context( + metadata=_make_lead_metadata(), + session_pool=mock_pool, + config=config, + base_dir=str(tmp_path), + agent_registry=mock_registry, + ) + cap = TeamCommCapability(config, "coordinator", _make_lead_metadata()) + + await cap.team_create( + ctx, + "my_team", + [{"agent": "worker", "name": "extraction_1"}], + ) + + assert mock_pool.create_child_session.await_args.kwargs["team_base_dir"] == str(tmp_path) + + @pytest.mark.unit async def test_team_create_not_lead() -> None: """Given: non-lead agent (team_role='translator'). @@ -1552,6 +1614,7 @@ async def test_shutdown_request_success(tmp_path: Any) -> None: session_pool=mock_pool, base_dir=str(tmp_path), ) + mock_pool.sessions.get_live_run = MagicMock(return_value=None) config = _make_enabled_config(base_dir=str(tmp_path)) cap = TeamCommCapability(config, "coordinator", _make_lead_metadata()) @@ -2259,6 +2322,27 @@ async def test_team_add_member_with_notify(tmp_path: Any) -> None: assert "child_session_new" not in broadcast_targets # new member excluded +@pytest.mark.unit +async def test_team_add_member_prompt_preserves_member_identity_protocol(tmp_path: Any) -> None: + """A task prompt supplements, but never replaces, the member identity protocol.""" + cap, ctx, mock_pool, _mock_delegation, _mock_registry = _make_add_member_setup(tmp_path) + + await cap.team_add_member( + ctx, + "extraction_2", + "worker", + prompt="Process the next source-packet shard.", + ) + + initial_prompt = mock_pool.send_message.await_args_list[0].args[1] + assert "extraction_2" in initial_prompt + assert "alpha_team" in initial_prompt + assert "Process the next source-packet shard." in initial_prompt + assert initial_prompt.index("extraction_2") < initial_prompt.index( + "Process the next source-packet shard." + ) + + @pytest.mark.unit async def test_team_add_member_ephemeral(tmp_path: Any) -> None: """Given: lead agent adding an ephemeral member. @@ -2284,6 +2368,89 @@ async def test_team_add_member_ephemeral(tmp_path: Any) -> None: assert sid == "child_session_new" +@pytest.mark.unit +async def test_team_add_member_persists_initial_task_before_member_wakeup( + tmp_path: Any, +) -> None: + """A new worker must never run before its authoritative task is visible.""" + cap, ctx, mock_pool, _mock_delegation, _mock_registry = _make_add_member_setup( + tmp_path, + ) + from wolfharness.capabilities.file_team_state import FileTeamState + + state = FileTeamState(str(tmp_path)) + visible_tasks_at_wakeup: list[list[dict[str, Any]]] = [] + + async def capture_wakeup(*args: Any, **kwargs: Any) -> str: + visible_tasks_at_wakeup.append(state.list_tasks("team_123")) + return "msg_id" + + mock_pool.send_message.side_effect = capture_wakeup + + result = await cap.team_add_member( + ctx, + "atomic_worker", + "editor", + lifecycle="persistent", + initial_task={ + "subject": "Extract one chapter", + "description": "packet_id=chapter_01 phase=1A", + "write_scope": "chapter_01", + }, + ) + + owned = [task for task in state.list_tasks("team_123") if task.get("owner") == "atomic_worker"] + assert len(owned) == 1 + assert owned[0]["status"] == "pending" + assert visible_tasks_at_wakeup + assert any(task.get("task_id") == owned[0]["task_id"] for task in visible_tasks_at_wakeup[0]) + assert str(owned[0]["task_id"]) in result.return_value + + +@pytest.mark.unit +async def test_team_add_member_binds_released_task_before_replacement_wakeup( + tmp_path: Any, +) -> None: + """Recovery reuses the released task ID without an unowned wakeup gap.""" + cap, ctx, mock_pool, _mock_delegation, _mock_registry = _make_add_member_setup( + tmp_path, + ) + from wolfharness.capabilities.file_team_state import FileTeamState + + state = FileTeamState(str(tmp_path)) + task_id = state.create_task( + "team_123", + { + "subject": "Resume relation shard", + "description": "packet_id=relation_01 phase=2.1", + "status": "pending", + "owner": "", + }, + ) + visible_owner_at_wakeup: list[str] = [] + + async def capture_wakeup(*args: Any, **kwargs: Any) -> str: + task = state.get_task("team_123", task_id) + visible_owner_at_wakeup.append(str(task.get("owner", "")) if task else "") + return "msg_id" + + mock_pool.send_message.side_effect = capture_wakeup + + result = await cap.team_add_member( + ctx, + "relation_replacement", + "editor", + lifecycle="persistent", + initial_task_id=task_id, + ) + + task = state.get_task("team_123", task_id) + assert task is not None + assert task["owner"] == "relation_replacement" + assert visible_owner_at_wakeup[0] == "relation_replacement" + assert task_id in result.return_value + + @pytest.mark.unit async def test_shutdown_request_removes_member(tmp_path: Any) -> None: """Given: lead agent with initialized team. @@ -2301,6 +2468,7 @@ async def test_shutdown_request_removes_member(tmp_path: Any) -> None: session_pool=mock_pool, base_dir=str(tmp_path), ) + mock_pool.sessions.get_live_run = MagicMock(return_value=None) config = _make_enabled_config(base_dir=str(tmp_path)) cap = TeamCommCapability(config, "coordinator", _make_lead_metadata()) @@ -3094,6 +3262,41 @@ async def test_member_can_create_subtask(tmp_path: Any) -> None: assert result.return_value.startswith("Task created: ") +@pytest.mark.unit +async def test_member_subtask_creation_can_be_disabled_per_workflow(tmp_path: Any) -> None: + """A workflow may require the lead to own all task decomposition.""" + _init_team(str(tmp_path)) + lead_ctx = _make_run_context( + metadata=_make_lead_metadata(), + base_dir=str(tmp_path), + ) + config = _make_enabled_config( + base_dir=str(tmp_path), + member_can_create_subtasks=False, + ) + lead_cap = TeamCommCapability(config, "coordinator", _make_lead_metadata()) + parent_result = await lead_cap.task_create(lead_ctx, "Parent task", owner="translator_agent") + parent_id = parent_result.return_value.replace("Task created: ", "") + + member_ctx = _make_run_context( + metadata=_make_member_metadata(), + base_dir=str(tmp_path), + ) + member_cap = TeamCommCapability(config, "worker", _make_member_metadata()) + + result = await member_cap.task_create( + member_ctx, + "Subtask", + parent_id=parent_id, + owner="translator_agent", + ) + + assert result.return_value == ( + "Member subtask creation is disabled for this workflow; " + "report the missing work to the lead instead of creating a task." + ) + + @pytest.mark.unit async def test_member_cannot_create_top_level_task(tmp_path: Any) -> None: """Given: non-lead member. @@ -3275,6 +3478,37 @@ async def test_member_can_update_own_task(tmp_path: Any) -> None: result = await member_cap.task_update(member_ctx, task_id, status="in_progress") assert 'status="in_progress"' in result.return_value + stored = FileTeamState(str(tmp_path)).get_task("team_123", task_id) + assert stored is not None + assert stored["lease_token"] + assert stored["lease_expires_at"] + + +@pytest.mark.unit +async def test_member_cannot_start_second_task_before_completing_first(tmp_path: Any) -> None: + """A worker has one authoritative current task at a time.""" + _init_team(str(tmp_path)) + lead_ctx = _make_run_context( + metadata=_make_lead_metadata(), + base_dir=str(tmp_path), + ) + config = _make_enabled_config(base_dir=str(tmp_path)) + lead_cap = TeamCommCapability(config, "coordinator", _make_lead_metadata()) + first = await lead_cap.task_create(lead_ctx, "First", owner="translator_agent") + second = await lead_cap.task_create(lead_ctx, "Second", owner="translator_agent") + first_id = first.return_value.replace("Task created: ", "") + second_id = second.return_value.replace("Task created: ", "") + member_ctx = _make_run_context( + metadata=_make_member_metadata(), + base_dir=str(tmp_path), + ) + member_cap = TeamCommCapability(config, "worker", _make_member_metadata()) + + first_update = await member_cap.task_update(member_ctx, first_id, status="in_progress") + second_update = await member_cap.task_update(member_ctx, second_id, status="in_progress") + + assert 'status="in_progress"' in first_update.return_value + assert "already has in_progress task" in second_update.return_value @pytest.mark.unit @@ -3311,6 +3545,36 @@ async def test_member_cannot_update_other_member_task(tmp_path: Any) -> None: assert "send_message(to='reviewer_agent'" in result.return_value +@pytest.mark.unit +async def test_member_cannot_reassign_own_task_to_another_member(tmp_path: Any) -> None: + """Only the lead can transfer an already-owned task between members.""" + _init_team(str(tmp_path)) + lead_ctx = _make_run_context( + metadata=_make_lead_metadata(), + base_dir=str(tmp_path), + ) + config = _make_enabled_config(base_dir=str(tmp_path)) + lead_cap = TeamCommCapability(config, "coordinator", _make_lead_metadata()) + create_result = await lead_cap.task_create( + lead_ctx, + "Owned task", + owner="translator_agent", + ) + task_id = create_result.return_value.replace("Task created: ", "") + member_ctx = _make_run_context( + metadata=_make_member_metadata(), + base_dir=str(tmp_path), + ) + member_cap = TeamCommCapability(config, "worker", _make_member_metadata()) + + result = await member_cap.task_update(member_ctx, task_id, owner="reviewer_agent") + + assert "Only the team lead can reassign" in result.return_value + task_result = await lead_cap.task_get(lead_ctx, task_id) + assert 'owner="translator_agent"' in task_result.return_value + assert 'owner="reviewer_agent"' not in task_result.return_value + + @pytest.mark.unit async def test_member_can_claim_unclaimed_task(tmp_path: Any) -> None: """Given: task with no owner. @@ -3335,9 +3599,9 @@ async def test_member_can_claim_unclaimed_task(tmp_path: Any) -> None: ) member_cap = TeamCommCapability(config, "worker", _make_member_metadata()) - result = await member_cap.task_update(member_ctx, task_id, owner="worker") + result = await member_cap.task_update(member_ctx, task_id, owner="translator_agent") - assert 'owner="worker"' in result.return_value + assert 'owner="translator_agent"' in result.return_value @pytest.mark.unit @@ -3672,17 +3936,18 @@ async def test_after_run_no_reminder_when_no_unfinished_tasks(tmp_path: Any) -> @pytest.mark.unit -async def test_shutdown_request_warns_about_unfinished_tasks(tmp_path: Any) -> None: - """Given: lead shuts down a member who has an in_progress task. +async def test_shutdown_request_auto_releases_in_progress_task(tmp_path: Any) -> None: + """Given: lead shuts down a member who has an in_progress task but no live run. When: shutdown_request is called. - Then: return value includes a warning about the unfinished task. + Then: the in_progress task is auto-released (owner cleared, status→pending) + and shutdown proceeds successfully. """ from wolfharness.capabilities.file_team_state import FileTeamState _init_team(str(tmp_path)) team_state = FileTeamState(str(tmp_path)) - team_state.create_task( + task_id = team_state.create_task( "team_123", { "subject": "Translate chapter 3", @@ -3699,17 +3964,60 @@ async def test_shutdown_request_warns_about_unfinished_tasks(tmp_path: Any) -> N session_pool=mock_pool, base_dir=str(tmp_path), ) + mock_pool.sessions.get_live_run = MagicMock(return_value=None) config = _make_enabled_config(base_dir=str(tmp_path)) cap = TeamCommCapability(config, "coordinator", _make_lead_metadata()) result = await cap.shutdown_request(ctx, "translator_agent") assert "Shutdown completed for translator_agent" in result.return_value - assert "Warning" in result.return_value - assert "Translate chapter 3" in result.return_value - assert "in_progress" not in result.return_value # status word not in output - assert "unfinished" in result.return_value mock_pool.close_session.assert_awaited_once_with("sess_translator") + # Task should be released: owner cleared, status flipped to pending. + task = team_state.get_task("team_123", task_id) + assert task is not None + assert task["owner"] == "" + assert task["status"] == "pending" + + +@pytest.mark.unit +async def test_shutdown_request_rejects_member_with_active_run_and_pending_tasks( + tmp_path: Any, +) -> None: + """A busy member cannot be killed before its task heartbeat is persisted.""" + from wolfharness.capabilities.file_team_state import FileTeamState + + _init_team(str(tmp_path)) + team_state = FileTeamState(str(tmp_path)) + team_state.create_task( + "team_123", + { + "subject": "Decode and persist source packet", + "owner": "translator_agent", + "status": "pending", + "content": "", + }, + ) + + member_session = MagicMock() + member_session.current_run_id = "run_active_123" + mock_pool = MagicMock() + mock_pool.close_session = AsyncMock() + ctx = _make_run_context( + metadata=_make_lead_metadata(), + session_pool=mock_pool, + base_dir=str(tmp_path), + ) + mock_pool.sessions.get_live_run = MagicMock(return_value=MagicMock()) + mock_pool.sessions.get_session.return_value = member_session + config = _make_enabled_config(base_dir=str(tmp_path)) + cap = TeamCommCapability(config, "coordinator", _make_lead_metadata()) + + result = await cap.shutdown_request(ctx, "translator_agent") + + assert "Shutdown rejected for translator_agent" in result.return_value + assert "an active run" in result.return_value + assert "Wait for it to become idle" in result.return_value + mock_pool.close_session.assert_not_awaited() @pytest.mark.unit @@ -3740,6 +4048,7 @@ async def test_shutdown_request_no_warning_when_tasks_completed(tmp_path: Any) - session_pool=mock_pool, base_dir=str(tmp_path), ) + mock_pool.sessions.get_live_run = MagicMock(return_value=None) config = _make_enabled_config(base_dir=str(tmp_path)) cap = TeamCommCapability(config, "coordinator", _make_lead_metadata()) @@ -3747,3 +4056,265 @@ async def test_shutdown_request_no_warning_when_tasks_completed(tmp_path: Any) - assert result.return_value == "Shutdown completed for translator_agent" mock_pool.close_session.assert_awaited_once_with("sess_translator") + + +@pytest.mark.unit +async def test_shutdown_request_warns_and_closes_member_with_pending_task(tmp_path: Any) -> None: + """Pending work can be transferred safely after the member is closed.""" + from wolfharness.capabilities.file_team_state import FileTeamState + + _init_team(str(tmp_path)) + team_state = FileTeamState(str(tmp_path)) + team_state.create_task( + "team_123", + { + "subject": "Translate chapter 4", + "owner": "translator_agent", + "status": "pending", + "content": "", + }, + ) + mock_pool = MagicMock() + mock_pool.close_session = AsyncMock() + ctx = _make_run_context( + metadata=_make_lead_metadata(), + session_pool=mock_pool, + base_dir=str(tmp_path), + ) + mock_pool.sessions.get_live_run = MagicMock(return_value=None) + config = _make_enabled_config(base_dir=str(tmp_path)) + cap = TeamCommCapability(config, "coordinator", _make_lead_metadata()) + + result = await cap.shutdown_request(ctx, "translator_agent") + + assert "Shutdown completed for translator_agent" in result.return_value + assert "Warning" in result.return_value + assert "Translate chapter 4" in result.return_value + mock_pool.close_session.assert_awaited_once_with("sess_translator") + + +@pytest.mark.unit +def test_task_lease_revocation_rejects_stale_worker(tmp_path: Any) -> None: + """A released worker cannot complete or mutate a task with an old token.""" + team_state = FileTeamState(str(tmp_path)) + team_state.init( + "team-lease", + "wiki", + [{"name": "worker-a", "agent": "worker"}], + ) + task_id = team_state.create_task( + "team-lease", + {"subject": "one bounded task", "owner": "worker-a"}, + ) + + claimed = team_state.update_task( + "team-lease", + task_id, + {"status": "in_progress"}, + claim=True, + lease_owner="worker-a", + ) + old_token = str(claimed["lease_token"]) + team_state.release_task_lease("team-lease", task_id) + + with pytest.raises(ValueError, match="TASK_LEASE_INVALID"): + team_state.update_task( + "team-lease", + task_id, + {"status": "completed"}, + lease_owner="worker-a", + expected_lease_token=old_token, + ) + + reclaimed = team_state.update_task( + "team-lease", + task_id, + {"status": "in_progress"}, + claim=True, + lease_owner="worker-a", + ) + assert reclaimed["lease_token"] != old_token + + +@pytest.mark.unit +def test_lead_reassignment_revokes_old_lease_before_new_worker_claims(tmp_path: Any) -> None: + """A lead handoff must not leave the new owner behind a stale token.""" + team_state = FileTeamState(str(tmp_path)) + team_state.init( + "team-handoff", + "wiki", + [ + {"name": "worker-a", "agent": "worker"}, + {"name": "worker-b", "agent": "worker"}, + ], + ) + task_id = team_state.create_task( + "team-handoff", + {"subject": "handoff", "owner": "worker-a"}, + ) + claimed = team_state.update_task( + "team-handoff", + task_id, + {"status": "in_progress"}, + claim=True, + lease_owner="worker-a", + ) + + reassigned = team_state.update_task( + "team-handoff", + task_id, + {"owner": "worker-b"}, + ) + + assert reassigned["status"] == "pending" + assert reassigned["owner"] == "worker-b" + assert reassigned["lease_token"] == "" + assert reassigned["lease_token"] != claimed["lease_token"] + + reclaimed = team_state.update_task( + "team-handoff", + task_id, + {"status": "in_progress"}, + claim=True, + lease_owner="worker-b", + ) + assert reclaimed["status"] == "in_progress" + assert reclaimed["lease_token"] + assert reclaimed["lease_token"] != claimed["lease_token"] + + +@pytest.mark.unit +def test_control_plane_metrics_track_retry_reassign_and_duplicate_intent(tmp_path: Any) -> None: + """Runtime counters expose the task-board events used by build metrics.""" + team_state = FileTeamState(str(tmp_path)) + team_state.init("team-metrics", "wiki", [{"name": "worker-a", "agent": "worker"}]) + task = { + "subject": "one bounded task", + "owner": "worker-a", + "intent_key": "build|extract|component|pump", + } + task_id = team_state.create_task("team-metrics", task) + assert team_state.create_task("team-metrics", task) == task_id + team_state.update_task( + "team-metrics", + task_id, + {"status": "in_progress"}, + claim=True, + lease_owner="worker-a", + ) + team_state.release_task_lease("team-metrics", task_id) + team_state.update_task( + "team-metrics", + task_id, + {"status": "in_progress"}, + claim=True, + lease_owner="worker-a", + ) + + metrics = team_state.control_plane_metrics("team-metrics") + + assert metrics["task_count"] == 1 + assert metrics["duplicate_intent_count"] == 1 + assert metrics["task_retry_count"] == 1 + assert metrics["task_reassign_count"] == 1 + + +@pytest.mark.unit +def test_task_intent_is_idempotent_at_file_store_boundary(tmp_path: Any) -> None: + """Concurrent retry paths cannot create two tasks for one intent.""" + team_state = FileTeamState(str(tmp_path)) + team_state.init("team-intent", "wiki", []) + first = team_state.create_task( + "team-intent", + {"subject": "packet", "intent_key": "build-1:packet-1"}, + ) + second = team_state.create_task( + "team-intent", + {"subject": "packet retry", "intent_key": "build-1:packet-1"}, + ) + assert second == first + assert len(team_state.list_tasks("team-intent")) == 1 + + batch_ids = team_state.create_tasks_batch( + "team-intent", + [{"subject": "batch packet", "intent_key": "build-1:packet-2"}], + ) + batch_task = team_state.get_task("team-intent", batch_ids[0]) + assert batch_task is not None + assert batch_task["intent_key"] == "build-1:packet-2" + + +@pytest.mark.unit +def test_workflow_task_intent_ignores_retry_title_changes() -> None: + """A reworded workflow retry keeps the same packet intent.""" + key_one = TeamCommCapability._task_idempotency_key( + "Extract source packet", + "phase=1A packet_id=packet-7 entity_type=Fault", + ) + key_two = TeamCommCapability._task_idempotency_key( + "Extract source packet (retry)", + "phase=1A packet_id=packet-7 entity_type=Fault", + ) + assert key_one + assert key_one == key_two + + +@pytest.mark.unit +def test_workflow_task_intent_separates_write_shards() -> None: + """One packet may fan out into independent, idempotent write shards.""" + description_template = ( + "phase=phase0 phase0_operation=component_write packet_id=sy75_bom_identity " + "write_set: [{target}]" + ) + engine_uri = "viking://resources/816/Component/发动机/电控发动机/五十铃CC-4JG1-PAC-02" + pump_uri = "viking://resources/816/Component/主泵/开式柱塞泵/川崎HP3V80" + + engine_key = TeamCommCapability._task_idempotency_key( + "component shard", + description_template.format(target=engine_uri), + ) + pump_key = TeamCommCapability._task_idempotency_key( + "component shard retry with another title", + description_template.format(target=pump_uri), + ) + engine_retry_key = TeamCommCapability._task_idempotency_key( + "component shard renamed", + description_template.format(target=engine_uri), + ) + + assert engine_key + assert pump_key + assert engine_key != pump_key + assert engine_key == engine_retry_key + + +@pytest.mark.unit +def test_workflow_task_intent_idempotency_key_zero_falls_through_to_packet() -> None: + """idempotency_key=0 is a conductor placeholder — must fall through to packet_id.""" + desc_a = ( + "phase=1A_source_analysis packet_id=chapter_aaa idempotency_key=0 " + "chapter_uri=viking://resources/805/raw/chapter_a" + ) + desc_b = ( + "phase=1A_source_analysis packet_id=chapter_bbb idempotency_key=0 " + "chapter_uri=viking://resources/805/raw/chapter_b" + ) + key_a = TeamCommCapability._task_idempotency_key("1A: chapter A", desc_a) + key_b = TeamCommCapability._task_idempotency_key("1A: chapter B", desc_b) + assert key_a + assert key_b + assert key_a != key_b, ( + "Different packet_ids must produce different keys even with idempotency_key=0" + ) + + +@pytest.mark.unit +def test_workflow_task_intent_real_idempotency_key_takes_priority() -> None: + """A real planner-generated idempotency_key should take priority over packet_id.""" + desc = ( + "phase=1A_source_analysis packet_id=chapter_aaa idempotency_key=e5210b979ae7 " + "chapter_uri=viking://resources/805/raw/chapter_a" + ) + key = TeamCommCapability._task_idempotency_key("1A: chapter A", desc) + assert key + assert "e5210b979ae7" in key.split("|")[0] diff --git a/tests/team_mode/test_unit_collab_flow.py b/tests/team_mode/test_unit_collab_flow.py index 73f531a46..71a30c6b2 100644 --- a/tests/team_mode/test_unit_collab_flow.py +++ b/tests/team_mode/test_unit_collab_flow.py @@ -1010,6 +1010,38 @@ async def test_mine_only_filters_correctly(tmp_path: Path) -> None: assert "translator_agent" in result.return_value +@pytest.mark.unit +async def test_mine_only_includes_owned_subtasks_under_other_parent(tmp_path: Path) -> None: + """A worker can see an owned child task even when its parent is owned by the lead.""" + init_team(str(tmp_path)) + ctx_lead = make_run_context( + metadata=make_lead_metadata(), + base_dir=str(tmp_path), + ) + config = make_enabled_config(base_dir=str(tmp_path)) + lead_cap = TeamCommCapability(config, "coordinator", make_lead_metadata()) + + parent_result = await lead_cap.task_create(ctx_lead, "Build phase", owner="coordinator") + parent_id = parent_result.return_value.splitlines()[0].replace("Task created: ", "") + await lead_cap.task_create( + ctx_lead, + "Extract sy75 electric chapters", + parent_id=parent_id, + owner="translator_agent", + ) + + member_ctx = make_run_context( + metadata=make_member_metadata(), + base_dir=str(tmp_path), + ) + member_cap = TeamCommCapability(config, "worker", make_member_metadata()) + + result = await member_cap.task_list(member_ctx, mine_only=True) + + assert "Extract sy75 electric chapters" in result.return_value + assert "Build phase" not in result.return_value + + @pytest.mark.unit async def test_mine_only_zero_owned_tasks(tmp_path: Path) -> None: """Given: member owns no tasks. @@ -1167,6 +1199,39 @@ async def test_task_create_batch_with_hash_dependencies(tmp_path: Path) -> None: assert "#1" in result.return_value +@pytest.mark.unit +async def test_task_create_batch_notifies_each_external_owner(tmp_path: Path) -> None: + """Given: a batch with tasks assigned to another member. + + When: the lead creates the batch. + Then: each external owner receives one authoritative batch notification. + """ + init_team(str(tmp_path)) + ctx = make_run_context( + metadata=make_lead_metadata(), + base_dir=str(tmp_path), + ) + config = make_enabled_config(base_dir=str(tmp_path)) + cap = TeamCommCapability(config, "coordinator", make_lead_metadata()) + notify_member = AsyncMock(return_value=True) + cap._notify_member = notify_member + + result = await cap.task_create_batch( + ctx, + [ + {"subject": "Worker A", "owner": "translator_agent"}, + {"subject": "Worker B", "owner": "translator_agent"}, + ], + ) + + assert "Dispatch: translator_agent=notified (2 task(s))" in result.return_value + notify_member.assert_awaited_once() + notification = notify_member.await_args.args[3] + assert "authoritative assignments" in notification + assert "Worker A" in notification + assert "Worker B" in notification + + @pytest.mark.unit async def test_task_create_batch_with_symbolic_id_dependencies( tmp_path: Path, diff --git a/tests/team_mode/test_unit_file_state.py b/tests/team_mode/test_unit_file_state.py index d61fa369e..4231eb8bb 100644 --- a/tests/team_mode/test_unit_file_state.py +++ b/tests/team_mode/test_unit_file_state.py @@ -104,6 +104,12 @@ def test_get_member_session_id_returns_none_for_unregistered( assert result is None +def test_get_member_agent_returns_registered_role(initialized_team: FileTeamState) -> None: + """Member display names resolve to their configured agent role.""" + assert initialized_team.get_member_agent("team-1", "alice") == "alice" + assert initialized_team.get_member_agent("team-1", "charlie") is None + + # ------------------------------------------------------------------ # 3. write_message + read_messages round-trip # ------------------------------------------------------------------ diff --git a/tests/test_model_capabilities.py b/tests/test_model_capabilities.py index 8120a0e60..ce2569787 100644 --- a/tests/test_model_capabilities.py +++ b/tests/test_model_capabilities.py @@ -2,6 +2,8 @@ from __future__ import annotations +from unittest.mock import patch + import pytest from wolfharness.models.model_configs import ( @@ -35,6 +37,7 @@ def test_all_fields_default_to_none() -> None: assert caps.video_input is None assert caps.document_input is None assert caps.image_output is None + assert caps.image_output is None def test_image_input_false_round_trips() -> None: @@ -60,7 +63,85 @@ def test_partial_specification() -> None: assert caps.audio_input is None assert caps.video_input is None assert caps.document_input is None - assert caps.image_output is None + + +def test_fallback_model_parses_nested_string_config_dicts() -> None: + """YAML fallback chains preserve custom endpoint settings per model.""" + config = FallbackModelConfig.model_validate( + { + "type": "fallback", + "models": [ + { + "type": "string", + "identifier": "openai:svc/primary", + "base_url": "https://primary.example/v1", + "api_key": "primary-key", + }, + { + "type": "string", + "identifier": "openai:svc/fallback", + "base_url": "https://fallback.example/v1", + "api_key": "fallback-key", + }, + ], + }, + ) + + assert isinstance(config.models[0], StringModelConfig) + assert config.models[0].base_url == "https://primary.example/v1" + assert isinstance(config.models[1], StringModelConfig) + assert config.models[1].base_url == "https://fallback.example/v1" + + +def test_string_model_reads_credential_from_named_environment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Custom endpoints can select a credential without manifest secrets.""" + monkeypatch.setenv("PRIVATE_MODEL_KEY", "runtime-secret") + config = StringModelConfig( + identifier="openai:svc/private", + base_url="https://private.example/v1", + api_key_env="PRIVATE_MODEL_KEY", + ) + + with patch( + "wolfharness.utils.model_helpers._get_openai_based_model", + return_value=object(), + ) as build_model: + config.get_model() + + build_model.assert_called_once_with( + "openai:svc/private", + base_url="https://private.example/v1", + api_key="runtime-secret", + ) + + +def test_string_model_forwards_reasoning_effort_to_openai_settings() -> None: + """OpenAI-compatible string models preserve the configured effort level.""" + config = StringModelConfig( + identifier="openai:svc/deepseek-v4-flash", + reasoning_effort="low", + ) + + settings = config.get_model_settings() + + assert settings["openai_reasoning_effort"] == "low" + + +def test_string_model_rejects_missing_named_credential( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing named credential fails before the first provider request.""" + monkeypatch.delenv("PRIVATE_MODEL_KEY", raising=False) + config = StringModelConfig( + identifier="openai:svc/private", + base_url="https://private.example/v1", + api_key_env="PRIVATE_MODEL_KEY", + ) + + with pytest.raises(ValueError, match="PRIVATE_MODEL_KEY"): + config.get_model() def test_all_fields_set() -> None: diff --git a/uv.lock b/uv.lock index b2ab92f44..54cbec106 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 1 +revision = 3 requires-python = "==3.13.*" resolution-markers = [ "sys_platform == 'win32'", @@ -21,9 +21,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/10/4ad299267a7d04b89935aa99eef62979758fcf95aee9f8bb5d70c35b1be1/ag_ui_protocol-0.1.19.tar.gz", hash = "sha256:43c27f60d41712dcad0e9e0a203cbdf1c8e248b22417374c5c68321c448af4ea", size = 10720 } +sdist = { url = "https://files.pythonhosted.org/packages/a7/10/4ad299267a7d04b89935aa99eef62979758fcf95aee9f8bb5d70c35b1be1/ag_ui_protocol-0.1.19.tar.gz", hash = "sha256:43c27f60d41712dcad0e9e0a203cbdf1c8e248b22417374c5c68321c448af4ea", size = 10720, upload-time = "2026-06-02T17:26:15.627Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4c/0a/bcad8116eb058e4b4a305e3fc37ebd7efc879deeb86b854f1c5b8b6e97dd/ag_ui_protocol-0.1.19-py3-none-any.whl", hash = "sha256:898843b1410d378824da0c6a776486288b9c5828689d0bf563118868e37f390f", size = 13490 }, + { url = "https://files.pythonhosted.org/packages/4c/0a/bcad8116eb058e4b4a305e3fc37ebd7efc879deeb86b854f1c5b8b6e97dd/ag_ui_protocol-0.1.19-py3-none-any.whl", hash = "sha256:898843b1410d378824da0c6a776486288b9c5828689d0bf563118868e37f390f", size = 13490, upload-time = "2026-06-02T17:26:16.313Z" }, ] [[package]] @@ -33,18 +33,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "caio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342 } +sdist = { url = "https://files.pythonhosted.org/packages/48/41/2fea7e193e061ce54eacc3b7bc0e6a99e4fcff43c78cf0a76dd781ed8334/aiofile-3.11.1.tar.gz", hash = "sha256:1f91912c6643d2a4e49ca4ae3514f0bf3867ce948a36d99a6411b8f4755f4cf9", size = 19342, upload-time = "2026-05-16T08:18:33.538Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446 }, + { url = "https://files.pythonhosted.org/packages/67/cd/0d76dfc5de72bde52f55f53e925c7d152d9c7906634ec1e0cbc7e8d4ad93/aiofile-3.11.1-py3-none-any.whl", hash = "sha256:ce77d14ac07f77bc2b757834a5c129321f3f705c474593deed5ab209079a52c9", size = 20446, upload-time = "2026-05-16T08:18:32.051Z" }, ] [[package]] name = "aiohappyeyeballs" version = "2.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757 } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038 }, + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, ] [[package]] @@ -60,49 +60,49 @@ dependencies = [ { name = "propcache" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269 }, - { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166 }, - { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263 }, - { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299 }, - { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235 }, - { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883 }, - { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473 }, - { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190 }, - { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478 }, - { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092 }, - { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546 }, - { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250 }, - { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289 }, - { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706 }, - { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652 }, - { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239 }, - { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161 }, - { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759 }, - { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025 }, - { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477 }, - { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069 }, - { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518 }, - { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676 }, +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, ] [[package]] name = "aioimaplib" version = "2.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/da/a454c47fb8522e607425e15bf1f49ccfdb3d75f4071f40b63ebd49573495/aioimaplib-2.0.1.tar.gz", hash = "sha256:5a494c3b75f220977048f5eb2c7ba9c0570a3148aaf38bee844e37e4d7af8648", size = 35555 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/da/a454c47fb8522e607425e15bf1f49ccfdb3d75f4071f40b63ebd49573495/aioimaplib-2.0.1.tar.gz", hash = "sha256:5a494c3b75f220977048f5eb2c7ba9c0570a3148aaf38bee844e37e4d7af8648", size = 35555, upload-time = "2025-01-16T10:38:23.14Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/52/48aaa287fb3c4c995edcb602370b10d182dc5c48371df7cb3a404356733f/aioimaplib-2.0.1-py3-none-any.whl", hash = "sha256:727e00c35cf25106bd34611dddd6e2ddf91a5f1a7e72d9269f3ce62486b31e14", size = 34729 }, + { url = "https://files.pythonhosted.org/packages/13/52/48aaa287fb3c4c995edcb602370b10d182dc5c48371df7cb3a404356733f/aioimaplib-2.0.1-py3-none-any.whl", hash = "sha256:727e00c35cf25106bd34611dddd6e2ddf91a5f1a7e72d9269f3ce62486b31e14", size = 34729, upload-time = "2025-01-16T10:38:20.427Z" }, ] [[package]] name = "aioitertools" version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322 } +sdist = { url = "https://files.pythonhosted.org/packages/fd/3c/53c4a17a05fb9ea2313ee1777ff53f5e001aefd5cc85aa2f4c2d982e1e38/aioitertools-0.13.0.tar.gz", hash = "sha256:620bd241acc0bbb9ec819f1ab215866871b4bbd1f73836a55f799200ee86950c", size = 19322, upload-time = "2025-11-06T22:17:07.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182 }, + { url = "https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl", hash = "sha256:0be0292b856f08dfac90e31f4739432f4cb6d7520ab9eb73e143f4f2fa5259be", size = 24182, upload-time = "2025-11-06T22:17:06.502Z" }, ] [[package]] @@ -112,18 +112,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] [[package]] name = "aiosqlite" version = "0.22.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821 } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405 }, + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, ] [[package]] @@ -135,36 +135,36 @@ dependencies = [ { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480 } +sdist = { url = "https://files.pythonhosted.org/packages/1a/cc/ac0bed8e562e7407fe55c3ba85a4dce86e6dbd8730887bd1e406a6c5c18a/alembic-1.18.5.tar.gz", hash = "sha256:1554982221dd17e9a749b53902407578eb305e453f71999e8c7f0a48389fff8e", size = 2060480, upload-time = "2026-06-25T15:20:54.888Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664 }, + { url = "https://files.pythonhosted.org/packages/96/78/5fe6dc3a3a5b2f5a2a4faef8bfe336d5fa049a38884ab3172e0098160c01/alembic-1.18.5-py3-none-any.whl", hash = "sha256:06d8ba9d04558022f5395e9317de03d270f3dced49cee01f89fe7a13c26f14bc", size = 264664, upload-time = "2026-06-25T15:20:56.673Z" }, ] [[package]] name = "altgraph" version = "0.17.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428 } +sdist = { url = "https://files.pythonhosted.org/packages/7e/f8/97fdf103f38fed6792a1601dbc16cc8aac56e7459a9fff08c812d8ae177a/altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7", size = 48428, upload-time = "2025-11-21T20:35:50.583Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228 }, + { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, ] [[package]] name = "annotated-doc" version = "0.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] [[package]] name = "annotated-types" version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893 } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427 }, + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] [[package]] @@ -181,9 +181,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/c5/f1d53b7d27b25a7f491d39c5cd9d39805be2d115d7f7431a8bf22f286afe/anthropic-0.119.0.tar.gz", hash = "sha256:40a81b094ae2a0056a77771257943b19bd46edce1326e22f85c2794492097f0a", size = 999050 } +sdist = { url = "https://files.pythonhosted.org/packages/2a/c5/f1d53b7d27b25a7f491d39c5cd9d39805be2d115d7f7431a8bf22f286afe/anthropic-0.119.0.tar.gz", hash = "sha256:40a81b094ae2a0056a77771257943b19bd46edce1326e22f85c2794492097f0a", size = 999050, upload-time = "2026-07-23T17:34:40.233Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/2e/5e90df53b474e856a3ae065c0df77e62bf58cb2df8819ec850d08b82518a/anthropic-0.119.0-py3-none-any.whl", hash = "sha256:2b39107077489d19d7c66000e9d9263293d431550fcf6b543ead785c6ac85962", size = 1012756 }, + { url = "https://files.pythonhosted.org/packages/dc/2e/5e90df53b474e856a3ae065c0df77e62bf58cb2df8819ec850d08b82518a/anthropic-0.119.0-py3-none-any.whl", hash = "sha256:2b39107077489d19d7c66000e9d9263293d431550fcf6b543ead785c6ac85962", size = 1012756, upload-time = "2026-07-23T17:34:41.896Z" }, ] [[package]] @@ -193,9 +193,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/08/ddad0d5398d0961d506b0489e737a06e29a963eff0f2f0a2bb2cfb36dd1f/anybadge-1.16.0.tar.gz", hash = "sha256:f4e95eca834482f9932f9020ac2fe04a5ca863728b446324a8d35b1e67faab71", size = 34616 } +sdist = { url = "https://files.pythonhosted.org/packages/64/08/ddad0d5398d0961d506b0489e737a06e29a963eff0f2f0a2bb2cfb36dd1f/anybadge-1.16.0.tar.gz", hash = "sha256:f4e95eca834482f9932f9020ac2fe04a5ca863728b446324a8d35b1e67faab71", size = 34616, upload-time = "2025-01-11T23:03:27.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/7d/01b2ac2fec808dea667b8678938156c3910219f2c45ee2e0b01e72786d72/anybadge-1.16.0-py3-none-any.whl", hash = "sha256:bc9ef2e20d875ee09237a15250a17b6fd7e67276f083d32a297963cdec179918", size = 28412 }, + { url = "https://files.pythonhosted.org/packages/83/7d/01b2ac2fec808dea667b8678938156c3910219f2c45ee2e0b01e72786d72/anybadge-1.16.0-py3-none-any.whl", hash = "sha256:bc9ef2e20d875ee09237a15250a17b6fd7e67276f083d32a297963cdec179918", size = 28412, upload-time = "2025-01-11T23:03:24.857Z" }, ] [[package]] @@ -208,9 +208,9 @@ dependencies = [ { name = "appdirs" }, { name = "universal-pathlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/79/470d6fc8b16172292fd27d9c5ed112fc5efd0fa6c6636c9828071c21743f/anyenv-2.0.15.tar.gz", hash = "sha256:e55d27982a42099034d1ea424f943255d61b9dcc4e073dd1b574993c4d09acae", size = 97848 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/79/470d6fc8b16172292fd27d9c5ed112fc5efd0fa6c6636c9828071c21743f/anyenv-2.0.15.tar.gz", hash = "sha256:e55d27982a42099034d1ea424f943255d61b9dcc4e073dd1b574993c4d09acae", size = 97848, upload-time = "2026-01-12T14:27:58.99Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/32/d7fbc87a89fe8f1e2ab871a8cc02839e900ac440c118d2101e12e322d5eb/anyenv-2.0.15-py3-none-any.whl", hash = "sha256:eba488219d9865b0ea2bdd120f6e41c0ab33619185d8ba20a5fa28148d3c030f", size = 161016 }, + { url = "https://files.pythonhosted.org/packages/d4/32/d7fbc87a89fe8f1e2ab871a8cc02839e900ac440c118d2101e12e322d5eb/anyenv-2.0.15-py3-none-any.whl", hash = "sha256:eba488219d9865b0ea2bdd120f6e41c0ab33619185d8ba20a5fa28148d3c030f", size = 161016, upload-time = "2026-01-12T14:28:01.283Z" }, ] [package.optional-dependencies] @@ -226,9 +226,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176 } +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813 }, + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] [[package]] @@ -238,9 +238,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/4b/cd5d66b9f87e773bc71344a368b9472987e33514e6627e28342b9c3e7c43/anysqlite-0.0.5.tar.gz", hash = "sha256:9dfcf87baf6b93426ad1d9118088c41dbf24ef01b445eea4a5d486bac2755cce", size = 3432 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4b/cd5d66b9f87e773bc71344a368b9472987e33514e6627e28342b9c3e7c43/anysqlite-0.0.5.tar.gz", hash = "sha256:9dfcf87baf6b93426ad1d9118088c41dbf24ef01b445eea4a5d486bac2755cce", size = 3432, upload-time = "2023-10-02T13:49:25.135Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/31/349eae2bc9d9331dd8951684cf94528d91efaa71129dc30822ac111dfc66/anysqlite-0.0.5-py3-none-any.whl", hash = "sha256:cb345dc4f76f6b37f768d7a0b3e9cf5c700dfcb7a6356af8ab46a11f666edbe7", size = 3907 }, + { url = "https://files.pythonhosted.org/packages/0b/31/349eae2bc9d9331dd8951684cf94528d91efaa71129dc30822ac111dfc66/anysqlite-0.0.5-py3-none-any.whl", hash = "sha256:cb345dc4f76f6b37f768d7a0b3e9cf5c700dfcb7a6356af8ab46a11f666edbe7", size = 3907, upload-time = "2023-10-02T13:49:26.943Z" }, ] [[package]] @@ -251,9 +251,9 @@ dependencies = [ { name = "pydantic" }, { name = "schemez" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/b875a56c88b83b2707f02b4afc38c77e2fc3acba3519c64a114681e9edca/anyvoice-0.0.2.tar.gz", hash = "sha256:0a45e8d20b2e26e115c86ce3b8b64f37702d44bfd7ef3f02b518452d25797e35", size = 11072 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/b875a56c88b83b2707f02b4afc38c77e2fc3acba3519c64a114681e9edca/anyvoice-0.0.2.tar.gz", hash = "sha256:0a45e8d20b2e26e115c86ce3b8b64f37702d44bfd7ef3f02b518452d25797e35", size = 11072, upload-time = "2025-12-24T15:48:03.869Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/7e/9d782e979af721ea066123a83ae6aa391f050c98bb59a2f3696b90560dd1/anyvoice-0.0.2-py3-none-any.whl", hash = "sha256:d2fb884c3d755f115a74a850ce0f7e1098cb71d6d663049cc31322792f5dc3e7", size = 12813 }, + { url = "https://files.pythonhosted.org/packages/8e/7e/9d782e979af721ea066123a83ae6aa391f050c98bb59a2f3696b90560dd1/anyvoice-0.0.2-py3-none-any.whl", hash = "sha256:d2fb884c3d755f115a74a850ce0f7e1098cb71d6d663049cc31322792f5dc3e7", size = 12813, upload-time = "2025-12-24T15:48:02.203Z" }, ] [package.optional-dependencies] @@ -272,9 +272,9 @@ tts-edge = [ name = "appdirs" version = "1.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566 }, + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, ] [[package]] @@ -290,66 +290,66 @@ dependencies = [ { name = "requests-oauthlib" }, { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/f7/95a52cda9355f32c5db9ef77bac18e85977472d29c555fafd825cf60c309/apprise-1.12.0.tar.gz", hash = "sha256:ffe9860d99cbde05fd156c610d4bcb49d953200c4ed3cb2db001ed50722fc142", size = 2477874 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/f7/95a52cda9355f32c5db9ef77bac18e85977472d29c555fafd825cf60c309/apprise-1.12.0.tar.gz", hash = "sha256:ffe9860d99cbde05fd156c610d4bcb49d953200c4ed3cb2db001ed50722fc142", size = 2477874, upload-time = "2026-07-04T16:15:36.67Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/e9/acfea54abdd66a059ff82995d3703eca3a27df3dc1bdc6afaf8a30a854fe/apprise-1.12.0-py3-none-any.whl", hash = "sha256:28edabfec5a9d5dbcd9aa28bdfd8928ecb68764e40214b6e648eab6d974b5a93", size = 1797824 }, + { url = "https://files.pythonhosted.org/packages/d3/e9/acfea54abdd66a059ff82995d3703eca3a27df3dc1bdc6afaf8a30a854fe/apprise-1.12.0-py3-none-any.whl", hash = "sha256:28edabfec5a9d5dbcd9aa28bdfd8928ecb68764e40214b6e648eab6d974b5a93", size = 1797824, upload-time = "2026-07-04T16:15:34.228Z" }, ] [[package]] name = "argcomplete" version = "3.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/c0/c8e94135e66fabf89a120d9b4b123fe6993506beca6c1938a74c24cfa5fd/argcomplete-3.7.0.tar.gz", hash = "sha256:afde224f753f874807b1dc1414e883ab8fe0cda9c04807b6047dcb8e1ac23913", size = 73284 } +sdist = { url = "https://files.pythonhosted.org/packages/95/c0/c8e94135e66fabf89a120d9b4b123fe6993506beca6c1938a74c24cfa5fd/argcomplete-3.7.0.tar.gz", hash = "sha256:afde224f753f874807b1dc1414e883ab8fe0cda9c04807b6047dcb8e1ac23913", size = 73284, upload-time = "2026-06-30T22:28:22.249Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/f6/5b8ec087cd9cfa9449491ec83f76fb6b7006b4dff57d2ba8aaab330fe8e4/argcomplete-3.7.0-py3-none-any.whl", hash = "sha256:d8f0f22d2a8a7caa383be1e22b6caf1ecaf0ebd10d8f83cc125e36540c95830c", size = 42575 }, + { url = "https://files.pythonhosted.org/packages/12/f6/5b8ec087cd9cfa9449491ec83f76fb6b7006b4dff57d2ba8aaab330fe8e4/argcomplete-3.7.0-py3-none-any.whl", hash = "sha256:d8f0f22d2a8a7caa383be1e22b6caf1ecaf0ebd10d8f83cc125e36540c95830c", size = 42575, upload-time = "2026-06-30T22:28:20.547Z" }, ] [[package]] name = "asgiref" version = "3.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378 } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478 }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, ] [[package]] name = "ast-grep-py" version = "0.45.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/01/675c994c5309f59f0a3a1a1f8e00beb5a40bc51c45aeffafb27b00d51743/ast_grep_py-0.45.0.tar.gz", hash = "sha256:f96ec0175c296a04b2119ef4ff526aa14573db2a2dd35d24b22175390b2cbab9", size = 155313 } +sdist = { url = "https://files.pythonhosted.org/packages/bf/01/675c994c5309f59f0a3a1a1f8e00beb5a40bc51c45aeffafb27b00d51743/ast_grep_py-0.45.0.tar.gz", hash = "sha256:f96ec0175c296a04b2119ef4ff526aa14573db2a2dd35d24b22175390b2cbab9", size = 155313, upload-time = "2026-07-23T16:20:59.22Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/30/15ac9b3eb165b7b4223beaf35814cb5791482360c08708bafcdf572c8a05/ast_grep_py-0.45.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4fdba29578f1b34d9186d6a446a2c687e683cb409df6bbf5d7d60b8dcdc7abee", size = 5358555 }, - { url = "https://files.pythonhosted.org/packages/34/be/da3954eaf57e57451747b5f5581acdd96bc16d49d57d09748fdf895e3259/ast_grep_py-0.45.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b04d1bd7f0144e9ab23a1eeab6b271288a59938d2864e3afeb4ed4dd74fddfe0", size = 5530668 }, - { url = "https://files.pythonhosted.org/packages/8d/d1/d06a16325268bb66565497b4dc2f6b69670ba5ea5825826331e6cc724cc5/ast_grep_py-0.45.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:8237ee9049afc02cb40f090928db283a1f0843dee93c41f747ec978571e514c5", size = 5346125 }, - { url = "https://files.pythonhosted.org/packages/7b/b7/64d9b9ff8b950890a86ea94b8a669e73fd14318f958ec50a19a72ed85ed0/ast_grep_py-0.45.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:dfda851a6be563a86518c93bb8be0bdc5a400ba262ea56fd2a2d9619c70b3f81", size = 5458633 }, - { url = "https://files.pythonhosted.org/packages/8c/c0/02157953adc01798d73210bccb0f1102a9817137f23b74df3f0c725ff210/ast_grep_py-0.45.0-cp313-cp313-win32.whl", hash = "sha256:d6f1cdadd015a9b0806403fcb44c921ae40bb615fe96ce8e28b0957b6139efb2", size = 5070101 }, - { url = "https://files.pythonhosted.org/packages/2b/4f/9673e3a7b7dcc12e1118e813a01a6b380ab6c7d05e81175fbcfc91465cde/ast_grep_py-0.45.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df945e6c112b6d21860c0d074f780dad6a25c72cb1988c40eb66b7ed701272b", size = 5206071 }, + { url = "https://files.pythonhosted.org/packages/f1/30/15ac9b3eb165b7b4223beaf35814cb5791482360c08708bafcdf572c8a05/ast_grep_py-0.45.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4fdba29578f1b34d9186d6a446a2c687e683cb409df6bbf5d7d60b8dcdc7abee", size = 5358555, upload-time = "2026-07-23T16:20:39.165Z" }, + { url = "https://files.pythonhosted.org/packages/34/be/da3954eaf57e57451747b5f5581acdd96bc16d49d57d09748fdf895e3259/ast_grep_py-0.45.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b04d1bd7f0144e9ab23a1eeab6b271288a59938d2864e3afeb4ed4dd74fddfe0", size = 5530668, upload-time = "2026-07-23T16:20:40.677Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d1/d06a16325268bb66565497b4dc2f6b69670ba5ea5825826331e6cc724cc5/ast_grep_py-0.45.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:8237ee9049afc02cb40f090928db283a1f0843dee93c41f747ec978571e514c5", size = 5346125, upload-time = "2026-07-23T16:20:42.45Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b7/64d9b9ff8b950890a86ea94b8a669e73fd14318f958ec50a19a72ed85ed0/ast_grep_py-0.45.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:dfda851a6be563a86518c93bb8be0bdc5a400ba262ea56fd2a2d9619c70b3f81", size = 5458633, upload-time = "2026-07-23T16:20:43.977Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c0/02157953adc01798d73210bccb0f1102a9817137f23b74df3f0c725ff210/ast_grep_py-0.45.0-cp313-cp313-win32.whl", hash = "sha256:d6f1cdadd015a9b0806403fcb44c921ae40bb615fe96ce8e28b0957b6139efb2", size = 5070101, upload-time = "2026-07-23T16:20:45.654Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4f/9673e3a7b7dcc12e1118e813a01a6b380ab6c7d05e81175fbcfc91465cde/ast_grep_py-0.45.0-cp313-cp313-win_amd64.whl", hash = "sha256:0df945e6c112b6d21860c0d074f780dad6a25c72cb1988c40eb66b7ed701272b", size = 5206071, upload-time = "2026-07-23T16:20:47.181Z" }, ] [[package]] name = "ast-serialize" version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489 } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367 }, - { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657 }, - { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620 }, - { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075 }, - { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348 }, - { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580 }, - { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693 }, - { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517 }, - { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843 }, - { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698 }, - { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209 }, - { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464 }, - { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164 }, - { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014 }, - { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165 }, - { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444 }, - { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965 }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] [[package]] @@ -359,18 +359,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/1d/f03bcb60c4a3212e15f99a56085d93093a497718adf828d050b9d675da81/asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0", size = 62284 } +sdist = { url = "https://files.pythonhosted.org/packages/45/1d/f03bcb60c4a3212e15f99a56085d93093a497718adf828d050b9d675da81/asttokens-2.4.1.tar.gz", hash = "sha256:b03869718ba9a6eb027e134bfdf69f38a236d681c83c160d510768af11254ba0", size = 62284, upload-time = "2023-10-26T10:03:05.06Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/86/4736ac618d82a20d87d2f92ae19441ebc7ac9e7a581d7e58bbe79233b24a/asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24", size = 27764 }, + { url = "https://files.pythonhosted.org/packages/45/86/4736ac618d82a20d87d2f92ae19441ebc7ac9e7a581d7e58bbe79233b24a/asttokens-2.4.1-py2.py3-none-any.whl", hash = "sha256:051ed49c3dcae8913ea7cd08e46a606dba30b79993209636c4875bc1d637bc24", size = 27764, upload-time = "2023-10-26T10:03:01.789Z" }, ] [[package]] name = "attrs" version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055 } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] @@ -381,9 +381,9 @@ dependencies = [ { name = "cryptography" }, { name = "joserfc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511 } +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548 }, + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] [[package]] @@ -396,57 +396,57 @@ dependencies = [ { name = "polyleven" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/bd/cbb2d2d53e991341fa63355248ed32870e2e60686fe11c066613f0df3a3c/autoevals-0.3.0.tar.gz", hash = "sha256:8def35d33146f80f09d24fa11d446c44d0be9471cce2836de437d82e88c6538a", size = 66952 } +sdist = { url = "https://files.pythonhosted.org/packages/53/bd/cbb2d2d53e991341fa63355248ed32870e2e60686fe11c066613f0df3a3c/autoevals-0.3.0.tar.gz", hash = "sha256:8def35d33146f80f09d24fa11d446c44d0be9471cce2836de437d82e88c6538a", size = 66952, upload-time = "2026-06-09T17:29:51.944Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/7d/142140e894c67bd69c9dbde15b2e929b4495647b5907e73e87f28d0b82ed/autoevals-0.3.0-py3-none-any.whl", hash = "sha256:c2292ab059d4617ae706658c5df51969eb8c6435a65fc930c61abb918ac2eb2d", size = 73778 }, + { url = "https://files.pythonhosted.org/packages/99/7d/142140e894c67bd69c9dbde15b2e929b4495647b5907e73e87f28d0b82ed/autoevals-0.3.0-py3-none-any.whl", hash = "sha256:c2292ab059d4617ae706658c5df51969eb8c6435a65fc930c61abb918ac2eb2d", size = 73778, upload-time = "2026-06-09T17:29:50.278Z" }, ] [[package]] name = "babel" version = "2.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845 }, + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] [[package]] name = "backoff" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001 } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148 }, + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] [[package]] name = "backrefs" version = "7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453 } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824 }, - { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626 }, - { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537 }, - { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491 }, + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, ] [[package]] name = "bashlex" version = "0.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/60/aae0bb54f9af5e0128ba90eb83d8d0d506ee8f0475c4fdda3deeda20b1d2/bashlex-0.18.tar.gz", hash = "sha256:5bb03a01c6d5676338c36fd1028009c8ad07e7d61d8a1ce3f513b7fff52796ee", size = 68742 } +sdist = { url = "https://files.pythonhosted.org/packages/76/60/aae0bb54f9af5e0128ba90eb83d8d0d506ee8f0475c4fdda3deeda20b1d2/bashlex-0.18.tar.gz", hash = "sha256:5bb03a01c6d5676338c36fd1028009c8ad07e7d61d8a1ce3f513b7fff52796ee", size = 68742, upload-time = "2023-01-18T15:21:26.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/be/6985abb1011fda8a523cfe21ed9629e397d6e06fb5bae99750402b25c95b/bashlex-0.18-py2.py3-none-any.whl", hash = "sha256:91d73a23a3e51711919c1c899083890cdecffc91d8c088942725ac13e9dcfffa", size = 69539 }, + { url = "https://files.pythonhosted.org/packages/f4/be/6985abb1011fda8a523cfe21ed9629e397d6e06fb5bae99750402b25c95b/bashlex-0.18-py2.py3-none-any.whl", hash = "sha256:91d73a23a3e51711919c1c899083890cdecffc91d8c088942725ac13e9dcfffa", size = 69539, upload-time = "2023-01-18T15:21:24.167Z" }, ] [[package]] name = "beartype" version = "0.22.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658 }, + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, ] [[package]] @@ -457,9 +457,9 @@ dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571 } +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924 }, + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] @@ -474,14 +474,14 @@ dependencies = [ { name = "platformdirs" }, { name = "pytokens" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020 }, - { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335 }, - { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614 }, - { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925 }, - { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883 }, - { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693 }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, ] [[package]] @@ -501,9 +501,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/26/775bb8a476d7921dfb86fa3e2740e2cd48c1b22602a09369fef413868e9a/braintrust-0.30.1.tar.gz", hash = "sha256:c8f43c2db18ad21a816307011964b0fd0b6f8e59c8a16092fd65bb50480d97b6", size = 694843 } +sdist = { url = "https://files.pythonhosted.org/packages/57/26/775bb8a476d7921dfb86fa3e2740e2cd48c1b22602a09369fef413868e9a/braintrust-0.30.1.tar.gz", hash = "sha256:c8f43c2db18ad21a816307011964b0fd0b6f8e59c8a16092fd65bb50480d97b6", size = 694843, upload-time = "2026-07-21T18:46:49.045Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/ba/850abbf73a38eacda46ced4267736f9ac5e826927b9908c67693f6b514ce/braintrust-0.30.1-py3-none-any.whl", hash = "sha256:7f5e6491f95bc047ecd121ad9dfb75d9f4ee56a5c20d851cf6d9be297fa0acd2", size = 808827 }, + { url = "https://files.pythonhosted.org/packages/69/ba/850abbf73a38eacda46ced4267736f9ac5e826927b9908c67693f6b514ce/braintrust-0.30.1-py3-none-any.whl", hash = "sha256:7f5e6491f95bc047ecd121ad9dfb75d9f4ee56a5c20d851cf6d9be297fa0acd2", size = 808827, upload-time = "2026-07-21T18:46:47.21Z" }, ] [[package]] @@ -515,27 +515,27 @@ dependencies = [ { name = "tenacity" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1d/56/bbd47494ebf93742a4ccb40d758a93d0cc1bc0a9513f9106ac9b6f0fa7a2/brave_search_python_client-0.4.27.tar.gz", hash = "sha256:3b8803dd8d4bac8110de2c5ba3014610e6bb36da7ea19ad9ce62b5f1deb38d5a", size = 124413 } +sdist = { url = "https://files.pythonhosted.org/packages/1d/56/bbd47494ebf93742a4ccb40d758a93d0cc1bc0a9513f9106ac9b6f0fa7a2/brave_search_python_client-0.4.27.tar.gz", hash = "sha256:3b8803dd8d4bac8110de2c5ba3014610e6bb36da7ea19ad9ce62b5f1deb38d5a", size = 124413, upload-time = "2025-04-27T17:04:17.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/d3/ae16180d52456d8fedcb288e4e4927dd806ce0a43d8ec9b9388ea6c6b072/brave_search_python_client-0.4.27-py3-none-any.whl", hash = "sha256:5b7b7a93f46a825517f81b42dc6c0c6ddcabee4a0fe44ce92b9ceae6a37699ec", size = 128788 }, + { url = "https://files.pythonhosted.org/packages/98/d3/ae16180d52456d8fedcb288e4e4927dd806ce0a43d8ec9b9388ea6c6b072/brave_search_python_client-0.4.27-py3-none-any.whl", hash = "sha256:5b7b7a93f46a825517f81b42dc6c0c6ddcabee4a0fe44ce92b9ceae6a37699ec", size = 128788, upload-time = "2025-04-27T17:04:15.772Z" }, ] [[package]] name = "brotli" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632 } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523 }, - { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289 }, - { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076 }, - { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880 }, - { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737 }, - { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440 }, - { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313 }, - { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945 }, - { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368 }, - { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116 }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, ] [[package]] @@ -547,47 +547,47 @@ dependencies = [ { name = "packaging" }, { name = "pyproject-hooks" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796 } +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018 }, + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, ] [[package]] name = "burner-redis" version = "0.1.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/89/54706febafc135095b2a9d797cfbd4eed2ab1ad7819808b99b587020471b/burner_redis-0.1.7.tar.gz", hash = "sha256:7474ff092669fd11ef765411572cdafcc3d89b8054aef4ca0617be6d6be4c680", size = 638644 } +sdist = { url = "https://files.pythonhosted.org/packages/52/89/54706febafc135095b2a9d797cfbd4eed2ab1ad7819808b99b587020471b/burner_redis-0.1.7.tar.gz", hash = "sha256:7474ff092669fd11ef765411572cdafcc3d89b8054aef4ca0617be6d6be4c680", size = 638644, upload-time = "2026-05-08T15:01:42.961Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/5d/198bd1d22e504b3034353430703afbdb3efe6e25cb90bf52d896e1d266a7/burner_redis-0.1.7-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f80c866996e0455d584eb3c0f3b067e411c632fb0519eab454e0968edf01e62c", size = 1288888 }, - { url = "https://files.pythonhosted.org/packages/2f/4e/ce5c91b884ac37fcd380756402536f8810964014097950900517ce8bd30c/burner_redis-0.1.7-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a3d9569a376b690fb5876d454e4904443332dc3ad5c0057e149fc2ad220bf599", size = 1234282 }, - { url = "https://files.pythonhosted.org/packages/6f/c0/31c25cc88143eac2dddcc394151a0db627923d44c94376a83768552c9f13/burner_redis-0.1.7-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20eba1917e3bca9eea5957d5700ff8defcb5a209e57a7841d005549aa0151f44", size = 1337341 }, - { url = "https://files.pythonhosted.org/packages/e1/32/95cfa1833316ca2b6b2e58150a4900bc1ad256043cdd36198f1887618ccc/burner_redis-0.1.7-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39111467059b8a28f15ea061d2414ec25c3e57c65759983f90f4d358e7d6a72d", size = 1366800 }, - { url = "https://files.pythonhosted.org/packages/34/ad/93c3916f053f89b7b5760da5bf855cd78b7885d480f9cfcc64f3732c1dc2/burner_redis-0.1.7-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9b5adfe99aeb8407f468078f3769b2a63e9168fea12f7709df5d2a3b152706e4", size = 1538160 }, - { url = "https://files.pythonhosted.org/packages/5c/b9/19bae42cb124932d71168bc8e5bcb1da33aa62b908e5e632b3d298d7cb15/burner_redis-0.1.7-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:591a9d20685f9d6d22bf0c863b50b12dfcf328b06111b3f62c33cd3185d48ce0", size = 1591491 }, - { url = "https://files.pythonhosted.org/packages/f5/30/207f47f406619a5b564355d2946c3171f84231a28b800709b5645b06a5ae/burner_redis-0.1.7-cp310-abi3-win_amd64.whl", hash = "sha256:f6cf4ac666766b32fd63940aad0c120847905fd3102c17e5b6b305f91a21d079", size = 1117564 }, - { url = "https://files.pythonhosted.org/packages/76/6f/e9beaf46c5e9fd10dfcdb889ebf7d3aa85142c650c0ab17ab284194f58e1/burner_redis-0.1.7-cp310-abi3-win_arm64.whl", hash = "sha256:458f88feeddfb40a586cc3fcbd8e9384bbdfd2a4512a695af4900e06052570d4", size = 1040407 }, + { url = "https://files.pythonhosted.org/packages/6c/5d/198bd1d22e504b3034353430703afbdb3efe6e25cb90bf52d896e1d266a7/burner_redis-0.1.7-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f80c866996e0455d584eb3c0f3b067e411c632fb0519eab454e0968edf01e62c", size = 1288888, upload-time = "2026-05-08T15:01:26.103Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4e/ce5c91b884ac37fcd380756402536f8810964014097950900517ce8bd30c/burner_redis-0.1.7-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:a3d9569a376b690fb5876d454e4904443332dc3ad5c0057e149fc2ad220bf599", size = 1234282, upload-time = "2026-05-08T15:01:28.286Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/31c25cc88143eac2dddcc394151a0db627923d44c94376a83768552c9f13/burner_redis-0.1.7-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20eba1917e3bca9eea5957d5700ff8defcb5a209e57a7841d005549aa0151f44", size = 1337341, upload-time = "2026-05-08T15:01:30.397Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/95cfa1833316ca2b6b2e58150a4900bc1ad256043cdd36198f1887618ccc/burner_redis-0.1.7-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39111467059b8a28f15ea061d2414ec25c3e57c65759983f90f4d358e7d6a72d", size = 1366800, upload-time = "2026-05-08T15:01:32.891Z" }, + { url = "https://files.pythonhosted.org/packages/34/ad/93c3916f053f89b7b5760da5bf855cd78b7885d480f9cfcc64f3732c1dc2/burner_redis-0.1.7-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9b5adfe99aeb8407f468078f3769b2a63e9168fea12f7709df5d2a3b152706e4", size = 1538160, upload-time = "2026-05-08T15:01:34.667Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b9/19bae42cb124932d71168bc8e5bcb1da33aa62b908e5e632b3d298d7cb15/burner_redis-0.1.7-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:591a9d20685f9d6d22bf0c863b50b12dfcf328b06111b3f62c33cd3185d48ce0", size = 1591491, upload-time = "2026-05-08T15:01:36.708Z" }, + { url = "https://files.pythonhosted.org/packages/f5/30/207f47f406619a5b564355d2946c3171f84231a28b800709b5645b06a5ae/burner_redis-0.1.7-cp310-abi3-win_amd64.whl", hash = "sha256:f6cf4ac666766b32fd63940aad0c120847905fd3102c17e5b6b305f91a21d079", size = 1117564, upload-time = "2026-05-08T15:01:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/76/6f/e9beaf46c5e9fd10dfcdb889ebf7d3aa85142c650c0ab17ab284194f58e1/burner_redis-0.1.7-cp310-abi3-win_arm64.whl", hash = "sha256:458f88feeddfb40a586cc3fcbd8e9384bbdfd2a4512a695af4900e06052570d4", size = 1040407, upload-time = "2026-05-08T15:01:41.235Z" }, ] [[package]] name = "cachetools" version = "5.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 }, + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, ] [[package]] name = "caio" version = "0.9.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781 } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979 }, - { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900 }, - { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523 }, - { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243 }, - { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087 }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" }, ] [[package]] @@ -598,18 +598,18 @@ dependencies = [ { name = "protobuf" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/5e/d04f0af3d9a74e93c4916d2656156ecc9930f4f5a09e1d2bb061ee817853/centrifuge_python-0.4.4.tar.gz", hash = "sha256:e18e33af9e984825b01ec85c96436e5c701703cc36c8c8f6d8bf00052a720e83", size = 44121 } +sdist = { url = "https://files.pythonhosted.org/packages/71/5e/d04f0af3d9a74e93c4916d2656156ecc9930f4f5a09e1d2bb061ee817853/centrifuge_python-0.4.4.tar.gz", hash = "sha256:e18e33af9e984825b01ec85c96436e5c701703cc36c8c8f6d8bf00052a720e83", size = 44121, upload-time = "2026-04-18T15:19:39.657Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/f5/a5bfa538d31aa006dd501b16523df37e5faa56765cf0bb755a87cc741398/centrifuge_python-0.4.4-py3-none-any.whl", hash = "sha256:6d30ce617354401e6e7496ddb47d86e5538ee83dbc4a3b314dfec2e6fb68660e", size = 26442 }, + { url = "https://files.pythonhosted.org/packages/c4/f5/a5bfa538d31aa006dd501b16523df37e5faa56765cf0bb755a87cc741398/centrifuge_python-0.4.4-py3-none-any.whl", hash = "sha256:6d30ce617354401e6e7496ddb47d86e5538ee83dbc4a3b314dfec2e6fb68660e", size = 26442, upload-time = "2026-04-18T15:19:38.644Z" }, ] [[package]] name = "certifi" version = "2026.7.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 }, + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] [[package]] @@ -619,44 +619,44 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036 } +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331 }, - { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966 }, - { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795 }, - { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746 }, - { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747 }, - { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392 }, - { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285 }, - { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801 }, - { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808 }, - { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241 }, - { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588 }, - { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248 }, - { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717 }, - { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114 }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, ] [[package]] name = "charset-normalizer" version = "3.4.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439 } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688 }, - { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982 }, - { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460 }, - { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003 }, - { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149 }, - { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901 }, - { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176 }, - { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356 }, - { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614 }, - { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991 }, - { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622 }, - { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947 }, - { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594 }, - { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538 }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] @@ -670,18 +670,18 @@ dependencies = [ { name = "requests" }, { name = "ruamel-yaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/dd/27b7f575586505c3d71b66b1fd60076feeba1ba7ffd1303fbf5a70cedad6/check_jsonschema-0.37.4.tar.gz", hash = "sha256:f933521f14cbeaaf113270751185b01379f453ac9ce9fb01d6190ce1352ccaa6", size = 428594 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/dd/27b7f575586505c3d71b66b1fd60076feeba1ba7ffd1303fbf5a70cedad6/check_jsonschema-0.37.4.tar.gz", hash = "sha256:f933521f14cbeaaf113270751185b01379f453ac9ce9fb01d6190ce1352ccaa6", size = 428594, upload-time = "2026-06-29T21:10:23.726Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/82/6a929238ec352eadd4f4ce6e1be98c575fa8cbebe45b7c519f8016ad2860/check_jsonschema-0.37.4-py3-none-any.whl", hash = "sha256:e4d1647992674d6b1ed2ca46eee6697760d17a5cc0c167ba09c5b503b883d041", size = 412508 }, + { url = "https://files.pythonhosted.org/packages/92/82/6a929238ec352eadd4f4ce6e1be98c575fa8cbebe45b7c519f8016ad2860/check_jsonschema-0.37.4-py3-none-any.whl", hash = "sha256:e4d1647992674d6b1ed2ca46eee6697760d17a5cc0c167ba09c5b503b883d041", size = 412508, upload-time = "2026-06-29T21:10:22.041Z" }, ] [[package]] name = "chevron" version = "0.14.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/1f/ca74b65b19798895d63a6e92874162f44233467c9e7c1ed8afd19016ebe9/chevron-0.14.0.tar.gz", hash = "sha256:87613aafdf6d77b6a90ff073165a61ae5086e21ad49057aa0e53681601800ebf", size = 11440 } +sdist = { url = "https://files.pythonhosted.org/packages/15/1f/ca74b65b19798895d63a6e92874162f44233467c9e7c1ed8afd19016ebe9/chevron-0.14.0.tar.gz", hash = "sha256:87613aafdf6d77b6a90ff073165a61ae5086e21ad49057aa0e53681601800ebf", size = 11440, upload-time = "2021-01-02T22:47:59.233Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/93/342cc62a70ab727e093ed98e02a725d85b746345f05d2b5e5034649f4ec8/chevron-0.14.0-py3-none-any.whl", hash = "sha256:fbf996a709f8da2e745ef763f482ce2d311aa817d287593a5b990d6d6e4f0443", size = 11595 }, + { url = "https://files.pythonhosted.org/packages/52/93/342cc62a70ab727e093ed98e02a725d85b746345f05d2b5e5034649f4ec8/chevron-0.14.0-py3-none-any.whl", hash = "sha256:fbf996a709f8da2e745ef763f482ce2d311aa817d287593a5b990d6d6e4f0443", size = 11595, upload-time = "2021-01-02T22:47:57.847Z" }, ] [[package]] @@ -691,9 +691,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000 } +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243 }, + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, ] [[package]] @@ -704,36 +704,36 @@ dependencies = [ { name = "anyenv", extra = ["httpx"] }, { name = "schemez" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/da/5523bbfb94a8f961b7ee998fcbdf3efdeea14225c848f35e520383d35369/clinspector-1.0.1.tar.gz", hash = "sha256:2854ea75c8b70c4a1c5273dcb9d44497e8a29c0d4d9dcb7299e551ac20e0c99a", size = 23099 } +sdist = { url = "https://files.pythonhosted.org/packages/2e/da/5523bbfb94a8f961b7ee998fcbdf3efdeea14225c848f35e520383d35369/clinspector-1.0.1.tar.gz", hash = "sha256:2854ea75c8b70c4a1c5273dcb9d44497e8a29c0d4d9dcb7299e551ac20e0c99a", size = 23099, upload-time = "2025-11-09T11:22:31.128Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/06/c8508a6a7b8dfa1600c57e2227194ffc0877958b1bf97bc0e2985b360c7c/clinspector-1.0.1-py3-none-any.whl", hash = "sha256:67616bbbb6e5c7a50a5e7760c0a6022ca31f612910aab21770296513e4a98492", size = 33078 }, + { url = "https://files.pythonhosted.org/packages/0e/06/c8508a6a7b8dfa1600c57e2227194ffc0877958b1bf97bc0e2985b360c7c/clinspector-1.0.1-py3-none-any.whl", hash = "sha256:67616bbbb6e5c7a50a5e7760c0a6022ca31f612910aab21770296513e4a98492", size = 33078, upload-time = "2025-11-09T11:22:29.707Z" }, ] [[package]] name = "cloudpickle" version = "3.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330 } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228 }, + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, ] [[package]] name = "coloraide" version = "8.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/36/b2/ff53a8c7921197177a4011cc1079b2229678280afd7a8f652c6b46944af8/coloraide-8.10.tar.gz", hash = "sha256:0120503a297181d7236c378297b6ad98b0da4e11bd99fd2500778b8485557a9e", size = 22407815 } +sdist = { url = "https://files.pythonhosted.org/packages/36/b2/ff53a8c7921197177a4011cc1079b2229678280afd7a8f652c6b46944af8/coloraide-8.10.tar.gz", hash = "sha256:0120503a297181d7236c378297b6ad98b0da4e11bd99fd2500778b8485557a9e", size = 22407815, upload-time = "2026-06-26T01:39:07.708Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/a7/94b95381ebcf3954cd726db7bc4e53cd414a830849ad47db870b2469627f/coloraide-8.10-py3-none-any.whl", hash = "sha256:8a55379769807ce50a1a5b3e973620708fd40b2552a016b89b7fa70e60dd58cc", size = 361350 }, + { url = "https://files.pythonhosted.org/packages/1e/a7/94b95381ebcf3954cd726db7bc4e53cd414a830849ad47db870b2469627f/coloraide-8.10-py3-none-any.whl", hash = "sha256:8a55379769807ce50a1a5b3e973620708fd40b2552a016b89b7fa70e60dd58cc", size = 361350, upload-time = "2026-06-26T01:39:05.522Z" }, ] [[package]] name = "colorama" version = "0.4.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] [[package]] @@ -748,9 +748,9 @@ dependencies = [ { name = "pysher" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/80/0071e855e9b8e06d2a5f4d87b9f5a65e2a7b3573b72e707256c4b6ee4fc3/composio-0.18.0.tar.gz", hash = "sha256:d8277412eabbb29e357025f1611d59dbb8f410ded5a1bed9a3a8fad09c630366", size = 239594 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/80/0071e855e9b8e06d2a5f4d87b9f5a65e2a7b3573b72e707256c4b6ee4fc3/composio-0.18.0.tar.gz", hash = "sha256:d8277412eabbb29e357025f1611d59dbb8f410ded5a1bed9a3a8fad09c630366", size = 239594, upload-time = "2026-07-15T20:40:09.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/8f/0f08f2698cfcdc03d53d8e4810256fbe16e4731c41b70b1fdbb9897f13f8/composio-0.18.0-py3-none-any.whl", hash = "sha256:8cf09b5f9494ae8b3f14103bab2a820a744e9007b0ed2cf9c36f575f9b63a522", size = 155940 }, + { url = "https://files.pythonhosted.org/packages/42/8f/0f08f2698cfcdc03d53d8e4810256fbe16e4731c41b70b1fdbb9897f13f8/composio-0.18.0-py3-none-any.whl", hash = "sha256:8cf09b5f9494ae8b3f14103bab2a820a744e9007b0ed2cf9c36f575f9b63a522", size = 155940, upload-time = "2026-07-15T20:39:56.498Z" }, ] [[package]] @@ -765,46 +765,46 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/d3/c47df669397e672172ff31090d186ff36e2158d33cc6c07cf5ec7dd05c39/composio_client-1.42.0.tar.gz", hash = "sha256:74f4635befaa4761e0ca83ed68ff63b0a62312ebea08cf0a2088a47cfe9d5154", size = 246045 } +sdist = { url = "https://files.pythonhosted.org/packages/12/d3/c47df669397e672172ff31090d186ff36e2158d33cc6c07cf5ec7dd05c39/composio_client-1.42.0.tar.gz", hash = "sha256:74f4635befaa4761e0ca83ed68ff63b0a62312ebea08cf0a2088a47cfe9d5154", size = 246045, upload-time = "2026-06-30T18:14:22.206Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/b5/f43e74632a91ae6516b713f7d2918391c98276967aa332bd8360457ec940/composio_client-1.42.0-py3-none-any.whl", hash = "sha256:4edfe2b3753fbf63fe2739a4f6705ffc8d4313f7fc98192efb12bac7990600a5", size = 277044 }, + { url = "https://files.pythonhosted.org/packages/fe/b5/f43e74632a91ae6516b713f7d2918391c98276967aa332bd8360457ec940/composio_client-1.42.0-py3-none-any.whl", hash = "sha256:4edfe2b3753fbf63fe2739a4f6705ffc8d4313f7fc98192efb12bac7990600a5", size = 277044, upload-time = "2026-06-30T18:14:20.857Z" }, ] [[package]] name = "copykitten" version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c1/72/57a5dee794b29c940f6bc429ff731c3d0b9f7af0aad94bd88bafbc3db07f/copykitten-2.0.0.tar.gz", hash = "sha256:bda13d614ffb38147d4fce5217d4f2397967514f5ea09fb8d5c5cf5d16028e58", size = 19712 } +sdist = { url = "https://files.pythonhosted.org/packages/c1/72/57a5dee794b29c940f6bc429ff731c3d0b9f7af0aad94bd88bafbc3db07f/copykitten-2.0.0.tar.gz", hash = "sha256:bda13d614ffb38147d4fce5217d4f2397967514f5ea09fb8d5c5cf5d16028e58", size = 19712, upload-time = "2025-09-21T02:48:24.848Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/24/2ff5084b1fe4825d47246de8f81fa738245ca6d6645b31dfc686bcbed51e/copykitten-2.0.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:191c202d19962fa5dbed5296d83bfa844b36609e30f0fb19cf1706b136f170e8", size = 381118 }, - { url = "https://files.pythonhosted.org/packages/b5/aa/51d0d24435846eb8c41a5f68874a2a39d367e22c58eb3a955455b1e0c7bf/copykitten-2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:1fb3118f5b3d9bc70442ae62f5d0b5fdd22243f2b4f2811e0680cbb4dfca112a", size = 359839 }, - { url = "https://files.pythonhosted.org/packages/3e/3c/ba4ce92ffe21c695d1492fe495032eb2356a5ade4a497d238f7efd277cea/copykitten-2.0.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6be53c8c45bb85bda1679ea2d890e24303096d2a9d9a1022e159b7cfd350b2c0", size = 453897 }, - { url = "https://files.pythonhosted.org/packages/98/59/5800f845f87f5841f467082698ccbe40e7eb000b1e069bdf24134c91c14c/copykitten-2.0.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b799e46da487e3380248065e5684d39e9f8f0f53b1124edba5e4fd2dc5a273", size = 502975 }, - { url = "https://files.pythonhosted.org/packages/60/45/22e1d8ffecadcfb0f57ba5189b6c2a7850621ad2045cbdf23a4d7f1f1849/copykitten-2.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:00d09e79fe8031dbdfaefd843d60e48f2b63e6ea9a823d25e07a17469f25017e", size = 267324 }, + { url = "https://files.pythonhosted.org/packages/3f/24/2ff5084b1fe4825d47246de8f81fa738245ca6d6645b31dfc686bcbed51e/copykitten-2.0.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:191c202d19962fa5dbed5296d83bfa844b36609e30f0fb19cf1706b136f170e8", size = 381118, upload-time = "2025-09-21T02:48:17.65Z" }, + { url = "https://files.pythonhosted.org/packages/b5/aa/51d0d24435846eb8c41a5f68874a2a39d367e22c58eb3a955455b1e0c7bf/copykitten-2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:1fb3118f5b3d9bc70442ae62f5d0b5fdd22243f2b4f2811e0680cbb4dfca112a", size = 359839, upload-time = "2025-09-21T02:48:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/3e/3c/ba4ce92ffe21c695d1492fe495032eb2356a5ade4a497d238f7efd277cea/copykitten-2.0.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6be53c8c45bb85bda1679ea2d890e24303096d2a9d9a1022e159b7cfd350b2c0", size = 453897, upload-time = "2025-09-21T02:48:21.055Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/5800f845f87f5841f467082698ccbe40e7eb000b1e069bdf24134c91c14c/copykitten-2.0.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b799e46da487e3380248065e5684d39e9f8f0f53b1124edba5e4fd2dc5a273", size = 502975, upload-time = "2025-09-21T02:48:22.428Z" }, + { url = "https://files.pythonhosted.org/packages/60/45/22e1d8ffecadcfb0f57ba5189b6c2a7850621ad2045cbdf23a4d7f1f1849/copykitten-2.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:00d09e79fe8031dbdfaefd843d60e48f2b63e6ea9a823d25e07a17469f25017e", size = 267324, upload-time = "2025-09-21T02:48:23.512Z" }, ] [[package]] name = "coverage" version = "7.15.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741 } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606 }, - { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982 }, - { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972 }, - { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569 }, - { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806 }, - { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936 }, - { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178 }, - { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934 }, - { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898 }, - { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056 }, - { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718 }, - { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490 }, - { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647 }, - { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190 }, - { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583 }, - { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375 }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, ] [[package]] @@ -814,9 +814,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267 } +sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/ba/d678e5bd329646ca51d3c92addbc77804e86d21f4b6b6a027218e6abb010/croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d", size = 46677 }, + { url = "https://files.pythonhosted.org/packages/cd/ba/d678e5bd329646ca51d3c92addbc77804e86d21f4b6b6a027218e6abb010/croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d", size = 46677, upload-time = "2026-07-10T09:52:58.425Z" }, ] [[package]] @@ -824,7 +824,7 @@ name = "cronsim" version = "2.7" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/1a/02f105147f7f2e06ed4f734ff5a6439590bb275a53dd91fc73df6312298a/cronsim-2.7-py3-none-any.whl", hash = "sha256:1e1431fa08c51dc7f72e67e571c7c7a09af26420169b607badd4ca9677ffad1e", size = 14213 }, + { url = "https://files.pythonhosted.org/packages/fb/1a/02f105147f7f2e06ed4f734ff5a6439590bb275a53dd91fc73df6312298a/cronsim-2.7-py3-none-any.whl", hash = "sha256:1e1431fa08c51dc7f72e67e571c7c7a09af26420169b607badd4ca9677ffad1e", size = 14213, upload-time = "2025-10-21T16:38:20.431Z" }, ] [[package]] @@ -834,34 +834,34 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100 }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978 }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422 }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503 }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779 }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683 }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874 }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283 }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844 }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290 }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612 }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804 }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026 }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947 }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429 }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968 }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758 }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863 }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983 }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173 }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298 }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338 }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650 }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820 }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968 }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547 }, +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, ] [[package]] @@ -874,9 +874,9 @@ dependencies = [ { name = "rich" }, { name = "rich-rst" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/47/32d992e829f63aedea5b93360db23c8882c9bbbde094bcf0fff899ea8a3b/cyclopts-4.22.1.tar.gz", hash = "sha256:49cd3779da7113a96ac5c23b151aa61ac9ae1b4b1fe813594d207ca843c97892", size = 193551 } +sdist = { url = "https://files.pythonhosted.org/packages/3d/47/32d992e829f63aedea5b93360db23c8882c9bbbde094bcf0fff899ea8a3b/cyclopts-4.22.1.tar.gz", hash = "sha256:49cd3779da7113a96ac5c23b151aa61ac9ae1b4b1fe813594d207ca843c97892", size = 193551, upload-time = "2026-07-20T17:38:59.38Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/89/f2710638cd2f824cb976777e63ef6ed1e83fc56888cf5c9f5a053490b7be/cyclopts-4.22.1-py3-none-any.whl", hash = "sha256:9b614e231075aee9849c0bfd78f7611ab7adf417f16af5b9e42b9ed6e18c17d1", size = 232953 }, + { url = "https://files.pythonhosted.org/packages/9a/89/f2710638cd2f824cb976777e63ef6ed1e83fc56888cf5c9f5a053490b7be/cyclopts-4.22.1-py3-none-any.whl", hash = "sha256:9b614e231075aee9849c0bfd78f7611ab7adf417f16af5b9e42b9ed6e18c17d1", size = 232953, upload-time = "2026-07-20T17:38:58.078Z" }, ] [[package]] @@ -887,9 +887,9 @@ dependencies = [ { name = "marshmallow" }, { name = "typing-inspect" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227 } +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686 }, + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, ] [[package]] @@ -906,9 +906,9 @@ dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/da/3f34411e9ef4d99fb774a506fabdf2c99f229612545fa213a9fab3d89661/datamodel_code_generator-0.70.0.tar.gz", hash = "sha256:eac611f052ede1dca4b37325d070d631a988a5936c69626a3de54e28472954c6", size = 1635415 } +sdist = { url = "https://files.pythonhosted.org/packages/66/da/3f34411e9ef4d99fb774a506fabdf2c99f229612545fa213a9fab3d89661/datamodel_code_generator-0.70.0.tar.gz", hash = "sha256:eac611f052ede1dca4b37325d070d631a988a5936c69626a3de54e28472954c6", size = 1635415, upload-time = "2026-07-23T03:45:26.397Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/ae/6a7da5c5b8e0e7667233e3ba0dd400131aa1cb688a1714a15fa989e36f59/datamodel_code_generator-0.70.0-py3-none-any.whl", hash = "sha256:4c3357328cf9998e9b1a6d4ad7ae4296f008563f9298aaa4e325b07418fafa40", size = 440877 }, + { url = "https://files.pythonhosted.org/packages/01/ae/6a7da5c5b8e0e7667233e3ba0dd400131aa1cb688a1714a15fa989e36f59/datamodel_code_generator-0.70.0-py3-none-any.whl", hash = "sha256:4c3357328cf9998e9b1a6d4ad7ae4296f008563f9298aaa4e325b07418fafa40", size = 440877, upload-time = "2026-07-23T03:45:24.39Z" }, ] [package.optional-dependencies] @@ -920,18 +920,18 @@ ruff = [ name = "deepmerge" version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2a/78/6e9e20106224083cfb817d2d3c26e80e72258d617b616721a169b87081e0/deepmerge-2.1.0.tar.gz", hash = "sha256:07ca7a7b8935df596c512fa8161877c0487ac61f691c07766e7d71d2b23bdd2f", size = 21449 } +sdist = { url = "https://files.pythonhosted.org/packages/2a/78/6e9e20106224083cfb817d2d3c26e80e72258d617b616721a169b87081e0/deepmerge-2.1.0.tar.gz", hash = "sha256:07ca7a7b8935df596c512fa8161877c0487ac61f691c07766e7d71d2b23bdd2f", size = 21449, upload-time = "2026-06-22T05:46:07.669Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/25/2a75b47cb057b1e164c604fb81ab690a6cdb5e2260ce651194eae90f64a3/deepmerge-2.1.0-py3-none-any.whl", hash = "sha256:8f148339a91d680a75ecb74ade235d9e759a93df373a0b04e9d31c8666cfeb75", size = 14345 }, + { url = "https://files.pythonhosted.org/packages/51/25/2a75b47cb057b1e164c604fb81ab690a6cdb5e2260ce651194eae90f64a3/deepmerge-2.1.0-py3-none-any.whl", hash = "sha256:8f148339a91d680a75ecb74ade235d9e759a93df373a0b04e9d31c8666cfeb75", size = 14345, upload-time = "2026-06-22T05:46:06.742Z" }, ] [[package]] name = "defusedxml" version = "0.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] [[package]] @@ -941,9 +941,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178 }, + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, ] [[package]] @@ -955,45 +955,45 @@ dependencies = [ { name = "executing" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/84/75/b78198620640d394bc435c17bb49db18419afdd6cfa3ed8bcfe14034ec80/devtools-0.12.2.tar.gz", hash = "sha256:efceab184cb35e3a11fa8e602cc4fadacaa2e859e920fc6f87bf130b69885507", size = 75005 } +sdist = { url = "https://files.pythonhosted.org/packages/84/75/b78198620640d394bc435c17bb49db18419afdd6cfa3ed8bcfe14034ec80/devtools-0.12.2.tar.gz", hash = "sha256:efceab184cb35e3a11fa8e602cc4fadacaa2e859e920fc6f87bf130b69885507", size = 75005, upload-time = "2023-09-03T16:57:00.679Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/ae/afb1487556e2dc827a17097aac8158a25b433a345386f0e249f6d2694ccb/devtools-0.12.2-py3-none-any.whl", hash = "sha256:c366e3de1df4cdd635f1ad8cbcd3af01a384d7abda71900e68d43b04eb6aaca7", size = 19411 }, + { url = "https://files.pythonhosted.org/packages/d1/ae/afb1487556e2dc827a17097aac8158a25b433a345386f0e249f6d2694ccb/devtools-0.12.2-py3-none-any.whl", hash = "sha256:c366e3de1df4cdd635f1ad8cbcd3af01a384d7abda71900e68d43b04eb6aaca7", size = 19411, upload-time = "2023-09-03T16:56:59.049Z" }, ] [[package]] name = "dirty-equals" version = "0.11" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/1d/c5913ac9d6615515a00f4bdc71356d302437cb74ff2e9aaccd3c14493b78/dirty_equals-0.11.tar.gz", hash = "sha256:f4ac74ee88f2d11e2fa0f65eb30ee4f07105c5f86f4dc92b09eb1138775027c3", size = 128067 } +sdist = { url = "https://files.pythonhosted.org/packages/30/1d/c5913ac9d6615515a00f4bdc71356d302437cb74ff2e9aaccd3c14493b78/dirty_equals-0.11.tar.gz", hash = "sha256:f4ac74ee88f2d11e2fa0f65eb30ee4f07105c5f86f4dc92b09eb1138775027c3", size = 128067, upload-time = "2025-11-17T01:51:24.451Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/8d/dbff05239043271dbeace563a7686212a3dd517864a35623fe4d4a64ca19/dirty_equals-0.11-py3-none-any.whl", hash = "sha256:b1d7093273fc2f9be12f443a8ead954ef6daaf6746fd42ef3a5616433ee85286", size = 28051 }, + { url = "https://files.pythonhosted.org/packages/bb/8d/dbff05239043271dbeace563a7686212a3dd517864a35623fe4d4a64ca19/dirty_equals-0.11-py3-none-any.whl", hash = "sha256:b1d7093273fc2f9be12f443a8ead954ef6daaf6746fd42ef3a5616433ee85286", size = 28051, upload-time = "2025-11-17T01:51:22.849Z" }, ] [[package]] name = "diskcache" version = "5.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916 } +sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550 }, + { url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" }, ] [[package]] name = "distro" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] [[package]] name = "dnspython" version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251 } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094 }, + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] [[package]] @@ -1010,18 +1010,18 @@ dependencies = [ { name = "schemez" }, { name = "upathtools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/fa/9d0da5814fae67fc890613f1dcf00fca0b1aea4fbf430502a1f76a20e2fa/docler-2.1.1.tar.gz", hash = "sha256:e231309e648924e8218475e37838ea1b917799d3cb5dfa7c10e7d31851fb7354", size = 1406248 } +sdist = { url = "https://files.pythonhosted.org/packages/d5/fa/9d0da5814fae67fc890613f1dcf00fca0b1aea4fbf430502a1f76a20e2fa/docler-2.1.1.tar.gz", hash = "sha256:e231309e648924e8218475e37838ea1b917799d3cb5dfa7c10e7d31851fb7354", size = 1406248, upload-time = "2026-01-04T03:40:00.131Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/64/f5901aa7e2029944de35b243164c0f01a45c06355d004a3b17cb0484de4b/docler-2.1.1-py3-none-any.whl", hash = "sha256:446d2476530017465519cb4d2da7d836a7b2cf531032bd16a3867549aef737ae", size = 1444038 }, + { url = "https://files.pythonhosted.org/packages/44/64/f5901aa7e2029944de35b243164c0f01a45c06355d004a3b17cb0484de4b/docler-2.1.1-py3-none-any.whl", hash = "sha256:446d2476530017465519cb4d2da7d836a7b2cf531032bd16a3867549aef737ae", size = 1444038, upload-time = "2026-01-04T03:40:04.19Z" }, ] [[package]] name = "docstring-parser" version = "0.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484 }, + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, ] [[package]] @@ -1034,9 +1034,9 @@ dependencies = [ { name = "tabulate" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3f/60/afbf548b43c78355e03926c6b1fff7500303a2da4d84db9e1324119e21ae/edge_tts-7.2.8.tar.gz", hash = "sha256:fcf185a0d527a0d2d003f9d5841facc1d5e0e7b3b88d5df9c32990402c6b8cd0", size = 27875 } +sdist = { url = "https://files.pythonhosted.org/packages/3f/60/afbf548b43c78355e03926c6b1fff7500303a2da4d84db9e1324119e21ae/edge_tts-7.2.8.tar.gz", hash = "sha256:fcf185a0d527a0d2d003f9d5841facc1d5e0e7b3b88d5df9c32990402c6b8cd0", size = 27875, upload-time = "2026-03-22T19:57:50.962Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/2b/a8cb687b92a2690d2ad171f0c2fd1c8f18690363cca7618bab2bbe4cdf2b/edge_tts-7.2.8-py3-none-any.whl", hash = "sha256:361fe48ce7ef613adbe30f664e3765dd71029c6cb57427279eff8ad6df2eb211", size = 31026 }, + { url = "https://files.pythonhosted.org/packages/8c/2b/a8cb687b92a2690d2ad171f0c2fd1c8f18690363cca7618bab2bbe4cdf2b/edge_tts-7.2.8-py3-none-any.whl", hash = "sha256:361fe48ce7ef613adbe30f664e3765dd71029c6cb57427279eff8ad6df2eb211", size = 31026, upload-time = "2026-03-22T19:57:49.672Z" }, ] [[package]] @@ -1047,27 +1047,27 @@ dependencies = [ { name = "dnspython" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238 } +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604 }, + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] [[package]] name = "epregistry" version = "2.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/c2/fa050adee0743de7bc55d923d938398dcc22c76a13161b5c4fd350f71eda/epregistry-2.0.3.tar.gz", hash = "sha256:c0e54fd2dcb9eadb61837b5ad6729a7567dbc98b98bbd4e00628ad7caf49c216", size = 11829 } +sdist = { url = "https://files.pythonhosted.org/packages/ee/c2/fa050adee0743de7bc55d923d938398dcc22c76a13161b5c4fd350f71eda/epregistry-2.0.3.tar.gz", hash = "sha256:c0e54fd2dcb9eadb61837b5ad6729a7567dbc98b98bbd4e00628ad7caf49c216", size = 11829, upload-time = "2025-11-29T01:04:41.244Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/29/2d/f8dcdfa47df719e0c5efba6f6212b09d0fe5fb247a6f26649e17311f9838/epregistry-2.0.3-py3-none-any.whl", hash = "sha256:9dfbc47787558c72d3f54290a7586b8b2202309eec2bedddad95c50170ef876b", size = 11773 }, + { url = "https://files.pythonhosted.org/packages/29/2d/f8dcdfa47df719e0c5efba6f6212b09d0fe5fb247a6f26649e17311f9838/epregistry-2.0.3-py3-none-any.whl", hash = "sha256:9dfbc47787558c72d3f54290a7586b8b2202309eec2bedddad95c50170ef876b", size = 11773, upload-time = "2025-11-29T01:04:40.129Z" }, ] [[package]] name = "eval-type-backport" version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/15/273a4baf8248d6d76220723c3caf039d283774b31a7c46ba686120145b76/eval_type_backport-0.4.0.tar.gz", hash = "sha256:8397d25e6524c2e67b9576bb0636be27dea2192017711220c534ec2de921e9b0", size = 10260 } +sdist = { url = "https://files.pythonhosted.org/packages/1c/15/273a4baf8248d6d76220723c3caf039d283774b31a7c46ba686120145b76/eval_type_backport-0.4.0.tar.gz", hash = "sha256:8397d25e6524c2e67b9576bb0636be27dea2192017711220c534ec2de921e9b0", size = 10260, upload-time = "2026-06-02T13:22:06.059Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/a7/bb99bf5e6f78736ddb53480f2c3ff3702ffe2196a7c5e1661c03081d398e/eval_type_backport-0.4.0-py3-none-any.whl", hash = "sha256:ad5e2a8db71b6696a56eafb938b0f5a337d3217f256b8e158b469422b4772b20", size = 6432 }, + { url = "https://files.pythonhosted.org/packages/50/a7/bb99bf5e6f78736ddb53480f2c3ff3702ffe2196a7c5e1661c03081d398e/eval_type_backport-0.4.0-py3-none-any.whl", hash = "sha256:ad5e2a8db71b6696a56eafb938b0f5a337d3217f256b8e158b469422b4772b20", size = 6432, upload-time = "2026-06-02T13:22:04.827Z" }, ] [[package]] @@ -1079,9 +1079,9 @@ dependencies = [ { name = "pydantic" }, { name = "schemez" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/bb/8eb332b5cff32f01e6d719186dec43f94cc3cf44bb9043ffe567febc731f/evented-1.1.0.tar.gz", hash = "sha256:7cab246065417fcac8a5531c4a96ac7467ebe9f6eecddc2047e986cfb265a1ca", size = 16248 } +sdist = { url = "https://files.pythonhosted.org/packages/83/bb/8eb332b5cff32f01e6d719186dec43f94cc3cf44bb9043ffe567febc731f/evented-1.1.0.tar.gz", hash = "sha256:7cab246065417fcac8a5531c4a96ac7467ebe9f6eecddc2047e986cfb265a1ca", size = 16248, upload-time = "2026-01-04T07:12:23.393Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/86/4246272170071672ddcb30b415edf32a71391b6eae93ba0365761eab0678/evented-1.1.0-py3-none-any.whl", hash = "sha256:ed8e0a30c54b24029851b2b8508cb7e4353b04bb919bf2db19dbaa57c5a06bed", size = 19801 }, + { url = "https://files.pythonhosted.org/packages/a3/86/4246272170071672ddcb30b415edf32a71391b6eae93ba0365761eab0678/evented-1.1.0-py3-none-any.whl", hash = "sha256:ed8e0a30c54b24029851b2b8508cb7e4353b04bb919bf2db19dbaa57c5a06bed", size = 19801, upload-time = "2026-01-04T07:12:24.585Z" }, ] [package.optional-dependencies] @@ -1105,36 +1105,36 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/a3/dabad8d3dbdb50d2ad49f426cc3e1fb47a0d0fd6ba10fbb27219c9bef684/exa_py-2.16.0.tar.gz", hash = "sha256:dce0db698720b1b7b39b58a1a17ad65fcc5969ee47f810cff5bcc836aeb0f777", size = 63530 } +sdist = { url = "https://files.pythonhosted.org/packages/68/a3/dabad8d3dbdb50d2ad49f426cc3e1fb47a0d0fd6ba10fbb27219c9bef684/exa_py-2.16.0.tar.gz", hash = "sha256:dce0db698720b1b7b39b58a1a17ad65fcc5969ee47f810cff5bcc836aeb0f777", size = 63530, upload-time = "2026-07-02T01:04:38.797Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/c8/69d100ed12f8493ab8b9d03bf20cd0080a3a60dec4abadb1782cc4f91969/exa_py-2.16.0-py3-none-any.whl", hash = "sha256:cf726f801d6e4be25f38275a6318b536445a92077450a11cb3d7e493ea77ecc2", size = 86866 }, + { url = "https://files.pythonhosted.org/packages/3c/c8/69d100ed12f8493ab8b9d03bf20cd0080a3a60dec4abadb1782cc4f91969/exa_py-2.16.0-py3-none-any.whl", hash = "sha256:cf726f801d6e4be25f38275a6318b536445a92077450a11cb3d7e493ea77ecc2", size = 86866, upload-time = "2026-07-02T01:04:37.451Z" }, ] [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] name = "execnet" version = "2.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622 } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708 }, + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] name = "executing" version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488 } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317 }, + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] [[package]] @@ -1144,9 +1144,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "extism-sys" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/c6/573488066680ef80ba6a46ab5e0df42aa4beafac51d2c3cc140a062531af/extism-1.1.1.tar.gz", hash = "sha256:067bf4ebd89ba85681508a77eb7f213ab13d2dbd6e71dabdac5d9622c61ee1d1", size = 11589 } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c6/573488066680ef80ba6a46ab5e0df42aa4beafac51d2c3cc140a062531af/extism-1.1.1.tar.gz", hash = "sha256:067bf4ebd89ba85681508a77eb7f213ab13d2dbd6e71dabdac5d9622c61ee1d1", size = 11589, upload-time = "2026-03-26T22:03:42.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/ac/f730be96df64fd3c85b0c24f16993181f72b72dfba3099ad86d49a55e7c0/extism-1.1.1-py3-none-any.whl", hash = "sha256:157692d7dc79cde6b1c9759eaf765f04b34b28e1f3a4a84507294c06081c8e5b", size = 11210 }, + { url = "https://files.pythonhosted.org/packages/e3/ac/f730be96df64fd3c85b0c24f16993181f72b72dfba3099ad86d49a55e7c0/extism-1.1.1-py3-none-any.whl", hash = "sha256:157692d7dc79cde6b1c9759eaf765f04b34b28e1f3a4a84507294c06081c8e5b", size = 11210, upload-time = "2026-03-26T22:03:42.092Z" }, ] [[package]] @@ -1157,13 +1157,13 @@ dependencies = [ { name = "cffi" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/01/30/06f1cbe28c3eb486dffc87b9d4e3d5522a8656fac86c9c05cc102f74732b/extism_sys-1.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f0225b2b2969bda66b0acab5ad13489e80b1b03b2fa7ad7be06dab57a381968d", size = 7597910 }, - { url = "https://files.pythonhosted.org/packages/04/1e/37a0c9422105909dd3932eac955c15122b9797ae305d6da3046b7036a9e3/extism_sys-1.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:242f021d12feee10bf67cc9a08c078d9b5e1da5c7a74f1f2eef61c10da36fccd", size = 7120898 }, - { url = "https://files.pythonhosted.org/packages/42/1f/50d6a4d22e8be5e50d379aee27cde1564c726ca42f3194d1b970a079f239/extism_sys-1.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db850dc58081b48d0d50a8c7395806cbe764b82c50a971c0be00519bf336200b", size = 8355066 }, - { url = "https://files.pythonhosted.org/packages/d9/7a/88fad64e8ae9bce4d2d26495e14600100145ecc3e3218fe9766b5e70d31f/extism_sys-1.12.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:687a1b1f1c009383aa466fe12b35ddda54d84c1775fc583e256a946e2bd17561", size = 7912809 }, - { url = "https://files.pythonhosted.org/packages/79/f6/50ff821a6ff825f89cc4ccccfcff46ac3755b474edfb1651fcd767196586/extism_sys-1.12.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:24f15d958db96bd17cd8a98dee9c172d0bd71094ff71cf9de193645a80d30269", size = 8087657 }, - { url = "https://files.pythonhosted.org/packages/bd/81/d32e279cd464292f5a51ee68d47f643012d937664259f03d5639630867ca/extism_sys-1.12.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9be0c414e08414d4ce5356a185b4f05cff780352feb6755b75a75455e84c3d0f", size = 8523720 }, - { url = "https://files.pythonhosted.org/packages/54/4c/aeaeb544f667693690bf48101f6a11d1c87784c7622af6f7aa5427602fe5/extism_sys-1.12.0-py3-none-win_amd64.whl", hash = "sha256:be2ddf0120117ac495e8686f5bc80ab64a5ffe0b55b8fa2b77ee23b5ea0b3836", size = 6588019 }, + { url = "https://files.pythonhosted.org/packages/01/30/06f1cbe28c3eb486dffc87b9d4e3d5522a8656fac86c9c05cc102f74732b/extism_sys-1.12.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f0225b2b2969bda66b0acab5ad13489e80b1b03b2fa7ad7be06dab57a381968d", size = 7597910, upload-time = "2025-07-14T18:53:50.102Z" }, + { url = "https://files.pythonhosted.org/packages/04/1e/37a0c9422105909dd3932eac955c15122b9797ae305d6da3046b7036a9e3/extism_sys-1.12.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:242f021d12feee10bf67cc9a08c078d9b5e1da5c7a74f1f2eef61c10da36fccd", size = 7120898, upload-time = "2025-07-14T18:53:51.489Z" }, + { url = "https://files.pythonhosted.org/packages/42/1f/50d6a4d22e8be5e50d379aee27cde1564c726ca42f3194d1b970a079f239/extism_sys-1.12.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db850dc58081b48d0d50a8c7395806cbe764b82c50a971c0be00519bf336200b", size = 8355066, upload-time = "2025-07-14T18:53:52.825Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7a/88fad64e8ae9bce4d2d26495e14600100145ecc3e3218fe9766b5e70d31f/extism_sys-1.12.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:687a1b1f1c009383aa466fe12b35ddda54d84c1775fc583e256a946e2bd17561", size = 7912809, upload-time = "2025-07-14T18:53:54.733Z" }, + { url = "https://files.pythonhosted.org/packages/79/f6/50ff821a6ff825f89cc4ccccfcff46ac3755b474edfb1651fcd767196586/extism_sys-1.12.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:24f15d958db96bd17cd8a98dee9c172d0bd71094ff71cf9de193645a80d30269", size = 8087657, upload-time = "2025-07-14T18:53:56.065Z" }, + { url = "https://files.pythonhosted.org/packages/bd/81/d32e279cd464292f5a51ee68d47f643012d937664259f03d5639630867ca/extism_sys-1.12.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9be0c414e08414d4ce5356a185b4f05cff780352feb6755b75a75455e84c3d0f", size = 8523720, upload-time = "2025-07-14T18:53:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/aeaeb544f667693690bf48101f6a11d1c87784c7622af6f7aa5427602fe5/extism_sys-1.12.0-py3-none-win_amd64.whl", hash = "sha256:be2ddf0120117ac495e8686f5bc80ab64a5ffe0b55b8fa2b77ee23b5ea0b3836", size = 6588019, upload-time = "2025-07-14T18:53:59.149Z" }, ] [[package]] @@ -1177,9 +1177,9 @@ dependencies = [ { name = "schemez" }, { name = "upathtools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/e3/4704f37bcfc30c07baf401254340ac48be6be80c439eff039813d6dc227e/exxec-0.4.1.tar.gz", hash = "sha256:12abc6324d421bdecf337193b324230b241eaab8a3d0e477e0e077315bb4264f", size = 94422 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/e3/4704f37bcfc30c07baf401254340ac48be6be80c439eff039813d6dc227e/exxec-0.4.1.tar.gz", hash = "sha256:12abc6324d421bdecf337193b324230b241eaab8a3d0e477e0e077315bb4264f", size = 94422, upload-time = "2026-04-25T02:28:55.746Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/34/c66b5a28616e17ded711fee9d596bda9f5cece41001973a0d1319a61c333/exxec-0.4.1-py3-none-any.whl", hash = "sha256:623215b41a63da4ef77e4b03c5a07626abae602ebd0657740002e79748598984", size = 133988 }, + { url = "https://files.pythonhosted.org/packages/d3/34/c66b5a28616e17ded711fee9d596bda9f5cece41001973a0d1319a61c333/exxec-0.4.1-py3-none-any.whl", hash = "sha256:623215b41a63da4ef77e4b03c5a07626abae602ebd0657740002e79748598984", size = 133988, upload-time = "2026-04-25T02:29:01.106Z" }, ] [[package]] @@ -1191,9 +1191,9 @@ dependencies = [ { name = "pydantic" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/28/35949997804129537becf17d0635b8b2b67036589a4da851e44a053857fc/fasta2a-0.6.1.tar.gz", hash = "sha256:e97b92771ad338166b1516dcbb687c9f3f8392a5cd5820542ec4b75ff5219a48", size = 1453473 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/28/35949997804129537becf17d0635b8b2b67036589a4da851e44a053857fc/fasta2a-0.6.1.tar.gz", hash = "sha256:e97b92771ad338166b1516dcbb687c9f3f8392a5cd5820542ec4b75ff5219a48", size = 1453473, upload-time = "2026-05-15T20:51:22.059Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/21/4d7e00c10bb4ec33a196efc55190ae086e97f5f487945bdfceb94df3e8d8/fasta2a-0.6.1-py3-none-any.whl", hash = "sha256:1b5369ee1b62405eebafa6d71c57c9e40a1a3f0dcbae5ec3aa6ee450361028dd", size = 28888 }, + { url = "https://files.pythonhosted.org/packages/21/21/4d7e00c10bb4ec33a196efc55190ae086e97f5f487945bdfceb94df3e8d8/fasta2a-0.6.1-py3-none-any.whl", hash = "sha256:1b5369ee1b62405eebafa6d71c57c9e40a1a3f0dcbae5ec3aa6ee450361028dd", size = 28888, upload-time = "2026-05-15T20:51:20.222Z" }, ] [[package]] @@ -1207,9 +1207,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234 }, + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, ] [[package]] @@ -1228,9 +1228,9 @@ dependencies = [ { name = "tokenizers" }, { name = "tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/26/25/58865e36b6e8a9a0d0ff905b5601aa30db97956327c0df42ec4ed6accc21/fastembed-0.8.0.tar.gz", hash = "sha256:75966edfa8b006ee78514c726bd7f6a50721dadc89305279052be9db72fd53e8", size = 75115 } +sdist = { url = "https://files.pythonhosted.org/packages/26/25/58865e36b6e8a9a0d0ff905b5601aa30db97956327c0df42ec4ed6accc21/fastembed-0.8.0.tar.gz", hash = "sha256:75966edfa8b006ee78514c726bd7f6a50721dadc89305279052be9db72fd53e8", size = 75115, upload-time = "2026-03-23T16:34:41.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e8/26b7d78bb8972498c467ca34cb12ee2e60d26ba5eae6d8443189a1af37a5/fastembed-0.8.0-py3-none-any.whl", hash = "sha256:40bee672657574a1009e35ec50030a55f2b426842cb011845379817641bbbbd0", size = 116572 }, + { url = "https://files.pythonhosted.org/packages/2a/e8/26b7d78bb8972498c467ca34cb12ee2e60d26ba5eae6d8443189a1af37a5/fastembed-0.8.0-py3-none-any.whl", hash = "sha256:40bee672657574a1009e35ec50030a55f2b426842cb011845379817641bbbbd0", size = 116572, upload-time = "2026-03-23T16:34:40.69Z" }, ] [[package]] @@ -1240,9 +1240,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastmcp-slim", extra = ["client", "server"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/f7/5188565d1b93ad611cbd80bf473e7ad669d1f3b689c4bedcd304e1ec3472/fastmcp-3.4.4.tar.gz", hash = "sha256:378202e26ec15b23819d9a1c0d1b0ebda096bc712720532010a0b82a45c2b1df", size = 28796458 } +sdist = { url = "https://files.pythonhosted.org/packages/9c/f7/5188565d1b93ad611cbd80bf473e7ad669d1f3b689c4bedcd304e1ec3472/fastmcp-3.4.4.tar.gz", hash = "sha256:378202e26ec15b23819d9a1c0d1b0ebda096bc712720532010a0b82a45c2b1df", size = 28796458, upload-time = "2026-07-09T00:32:41.352Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/67/3cef84ba38a23dca1e1e776bfda8a35ab3c7a6c94a8ca81d0715de6dd3c5/fastmcp-3.4.4-py3-none-any.whl", hash = "sha256:f86f208713212260068cf55c32936839eee856fefc7808e18a032f31eb0f718e", size = 8019 }, + { url = "https://files.pythonhosted.org/packages/5f/67/3cef84ba38a23dca1e1e776bfda8a35ab3c7a6c94a8ca81d0715de6dd3c5/fastmcp-3.4.4-py3-none-any.whl", hash = "sha256:f86f208713212260068cf55c32936839eee856fefc7808e18a032f31eb0f718e", size = 8019, upload-time = "2026-07-09T00:32:39.411Z" }, ] [[package]] @@ -1257,9 +1257,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/45/79/f35661c6a1d76dfbe17a079f912d96fffcfdd40fad5a9144bb9e7dfb1fdf/fastmcp_slim-3.4.4.tar.gz", hash = "sha256:dcaa3e0be2127d7eacdce592c2ef0039204923dc0ec396454615cb4a3275b078", size = 590203 } +sdist = { url = "https://files.pythonhosted.org/packages/45/79/f35661c6a1d76dfbe17a079f912d96fffcfdd40fad5a9144bb9e7dfb1fdf/fastmcp_slim-3.4.4.tar.gz", hash = "sha256:dcaa3e0be2127d7eacdce592c2ef0039204923dc0ec396454615cb4a3275b078", size = 590203, upload-time = "2026-07-09T00:32:20.531Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/91/321e0b2e9ed70d0628b17ddaec76fc7b09f3e1d5d290f70bf101a2890142/fastmcp_slim-3.4.4-py3-none-any.whl", hash = "sha256:9d3a6327b9ee835188eb7323fc3b5d4cd061631b48da8ece56794bb538972505", size = 765158 }, + { url = "https://files.pythonhosted.org/packages/16/91/321e0b2e9ed70d0628b17ddaec76fc7b09f3e1d5d290f70bf101a2890142/fastmcp_slim-3.4.4-py3-none-any.whl", hash = "sha256:9d3a6327b9ee835188eb7323fc3b5d4cd061631b48da8ece56794bb538972505", size = 765158, upload-time = "2026-07-09T00:32:19.11Z" }, ] [package.optional-dependencies] @@ -1303,18 +1303,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/46/7a8d1db959eabd5d3e52bd3c000a8b132184b7651cbd5481c4cbf31ff65d/fieldz-0.2.0.tar.gz", hash = "sha256:e11215188cad5c5371113d2b7707155960efd0d48500b6bf675648bc3f6fc8d6", size = 18222 } +sdist = { url = "https://files.pythonhosted.org/packages/f8/46/7a8d1db959eabd5d3e52bd3c000a8b132184b7651cbd5481c4cbf31ff65d/fieldz-0.2.0.tar.gz", hash = "sha256:e11215188cad5c5371113d2b7707155960efd0d48500b6bf675648bc3f6fc8d6", size = 18222, upload-time = "2026-02-24T19:26:02.251Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/9d/9034acaf3d80e85deb3d49876cb734479327b9cf89a918815ef67cb6b36c/fieldz-0.2.0-py3-none-any.whl", hash = "sha256:43b5be702816df39f55d08ae392eaabe58d3c69d2e4b61a853252cb2da41931f", size = 17946 }, + { url = "https://files.pythonhosted.org/packages/fe/9d/9034acaf3d80e85deb3d49876cb734479327b9cf89a918815ef67cb6b36c/fieldz-0.2.0-py3-none-any.whl", hash = "sha256:43b5be702816df39f55d08ae392eaabe58d3c69d2e4b61a853252cb2da41931f", size = 17946, upload-time = "2026-02-24T19:26:00.931Z" }, ] [[package]] name = "filelock" version = "3.32.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732 }, + { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, ] [[package]] @@ -1322,57 +1322,57 @@ name = "flatbuffers" version = "25.12.19" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661 }, + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] [[package]] name = "frozenlist" version = "1.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717 }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651 }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417 }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391 }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048 }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549 }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833 }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363 }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314 }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365 }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763 }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110 }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717 }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628 }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882 }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676 }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235 }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742 }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725 }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506 }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161 }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676 }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638 }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067 }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101 }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901 }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395 }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659 }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492 }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034 }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749 }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 }, +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "fsspec" version = "2026.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646 } +sdist = { url = "https://files.pythonhosted.org/packages/10/a1/ae4e3e5003468d6391d2c77b6fa1cd73bd5d13511d81c642d7b28ac90ed4/fsspec-2026.6.0.tar.gz", hash = "sha256:f5bac145310fe30e16e1471bd6840b2d990d609e872251d7e674241822abf01a", size = 313646, upload-time = "2026-06-16T01:57:28.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949 }, + { url = "https://files.pythonhosted.org/packages/e5/22/4222d7ddf3da30f363edaa98e329c2bce6c65497c9cb2810931c8b2c0fbc/fsspec-2026.6.0-py3-none-any.whl", hash = "sha256:02e0b71817df9b2169dc30a16832045764def1191b43dcff5bb85bdee212d2a1", size = 203949, upload-time = "2026-06-16T01:57:26.358Z" }, ] [[package]] @@ -1383,18 +1383,18 @@ dependencies = [ { name = "httpx2" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/c8/2549fa8ceaaf0bd61114cf392250a107be657233723bc4e65cb91dba934f/genai_prices-0.0.72.tar.gz", hash = "sha256:a7e481d0ea85922fcf48df6864f2491fc81a4f03dcf0ecbd9aa8c2c6d9fcdbe8", size = 82753 } +sdist = { url = "https://files.pythonhosted.org/packages/6f/c8/2549fa8ceaaf0bd61114cf392250a107be657233723bc4e65cb91dba934f/genai_prices-0.0.72.tar.gz", hash = "sha256:a7e481d0ea85922fcf48df6864f2491fc81a4f03dcf0ecbd9aa8c2c6d9fcdbe8", size = 82753, upload-time = "2026-07-22T21:11:03.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/2e/1e2c31666f7e4e2d0327a80f1c920fd8764ccd27672c4bc389d79219ece6/genai_prices-0.0.72-py3-none-any.whl", hash = "sha256:21281d8df34d9bfbb736e0763a60ceda02226938cd47379ae437a44fcf8ba23d", size = 85238 }, + { url = "https://files.pythonhosted.org/packages/22/2e/1e2c31666f7e4e2d0327a80f1c920fd8764ccd27672c4bc389d79219ece6/genai_prices-0.0.72-py3-none-any.whl", hash = "sha256:21281d8df34d9bfbb736e0763a60ceda02226938cd47379ae437a44fcf8ba23d", size = 85238, upload-time = "2026-07-22T21:11:02.911Z" }, ] [[package]] name = "genson" version = "1.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/53/de162dc8e03fccd9ebe59d17c7812378fe8bd2b604f6b1b94d00165140ac/genson-1.4.0.tar.gz", hash = "sha256:bc7f1c1bae87a21ca44d81149aec95a3f4468d676de9b8b08caa064f3c50b3da", size = 47908 } +sdist = { url = "https://files.pythonhosted.org/packages/f8/53/de162dc8e03fccd9ebe59d17c7812378fe8bd2b604f6b1b94d00165140ac/genson-1.4.0.tar.gz", hash = "sha256:bc7f1c1bae87a21ca44d81149aec95a3f4468d676de9b8b08caa064f3c50b3da", size = 47908, upload-time = "2026-07-06T08:21:50.331Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/02/767f744ab6d4cb7761e5008acc3d534b7a0481af62563d52e391fbcb2140/genson-1.4.0-py3-none-any.whl", hash = "sha256:03bc71bbe52defde70660cc4dcd1ea1097997da5a1cbb90a9dbd3acc7c9e1b65", size = 24484 }, + { url = "https://files.pythonhosted.org/packages/16/02/767f744ab6d4cb7761e5008acc3d534b7a0481af62563d52e391fbcb2140/genson-1.4.0-py3-none-any.whl", hash = "sha256:03bc71bbe52defde70660cc4dcd1ea1097997da5a1cbb90a9dbd3acc7c9e1b65", size = 24484, upload-time = "2026-07-06T08:21:49.046Z" }, ] [[package]] @@ -1404,9 +1404,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] [[package]] @@ -1419,9 +1419,9 @@ dependencies = [ { name = "platformdirs" }, { name = "semver" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/7f/17e58ec656fd79d4a3b02517923e24a93b28fe2e4518568aedb4ce038fb9/git_changelog-2.9.4.tar.gz", hash = "sha256:06a38a0d174a069b760430ec41493bf53307be13f16b0e48ce379be1646ea9a0", size = 92601 } +sdist = { url = "https://files.pythonhosted.org/packages/01/7f/17e58ec656fd79d4a3b02517923e24a93b28fe2e4518568aedb4ce038fb9/git_changelog-2.9.4.tar.gz", hash = "sha256:06a38a0d174a069b760430ec41493bf53307be13f16b0e48ce379be1646ea9a0", size = 92601, upload-time = "2026-04-29T14:01:39.575Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/a6/fb6713ce5c128078492b5ce691bd27b8a7c6f2ad84aca99d3b847ab1721d/git_changelog-2.9.4-py3-none-any.whl", hash = "sha256:c19a099640518fbbc545cb240250cf8129e55e668d8374ba8061ea5456da204b", size = 40604 }, + { url = "https://files.pythonhosted.org/packages/ec/a6/fb6713ce5c128078492b5ce691bd27b8a7c6f2ad84aca99d3b847ab1721d/git_changelog-2.9.4-py3-none-any.whl", hash = "sha256:c19a099640518fbbc545cb240250cf8129e55e668d8374ba8061ea5456da204b", size = 40604, upload-time = "2026-04-29T14:01:38.195Z" }, ] [[package]] @@ -1431,9 +1431,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "smmap" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684 } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794 }, + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, ] [[package]] @@ -1444,9 +1444,9 @@ dependencies = [ { name = "universal-pathlib" }, { name = "upathtools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/16/00/fffef32d9dd820616d346964c9cabb91fa3fc404483e3575c3a62f3137b8/githarbor-1.0.2.tar.gz", hash = "sha256:6df681fa04d8e951f8b1af508a44362d48cf88440037714b07be7fa589a6018e", size = 66500 } +sdist = { url = "https://files.pythonhosted.org/packages/16/00/fffef32d9dd820616d346964c9cabb91fa3fc404483e3575c3a62f3137b8/githarbor-1.0.2.tar.gz", hash = "sha256:6df681fa04d8e951f8b1af508a44362d48cf88440037714b07be7fa589a6018e", size = 66500, upload-time = "2025-12-10T11:47:05.767Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bb/70/d546991d92c686dd3ee594497b52721f6750f5233abad5133b021fbab35e/githarbor-1.0.2-py3-none-any.whl", hash = "sha256:5c1ec1eb78d028a3f68188cec1e32ca5f6ffae4a90749a81513172afcfb3394e", size = 91646 }, + { url = "https://files.pythonhosted.org/packages/bb/70/d546991d92c686dd3ee594497b52721f6750f5233abad5133b021fbab35e/githarbor-1.0.2-py3-none-any.whl", hash = "sha256:5c1ec1eb78d028a3f68188cec1e32ca5f6ffae4a90749a81513172afcfb3394e", size = 91646, upload-time = "2025-12-10T11:47:07.481Z" }, ] [[package]] @@ -1456,9 +1456,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b2/ab/ba0d29f2fa2277ed6256b2ac09003494045355f3a10bf32f351761287870/gitpython-3.1.55.tar.gz", hash = "sha256:781e3b1624dad81b24e9524bf0297b69786a0706db2cbceec1e2b05c38e5152f", size = 225071 } +sdist = { url = "https://files.pythonhosted.org/packages/b2/ab/ba0d29f2fa2277ed6256b2ac09003494045355f3a10bf32f351761287870/gitpython-3.1.55.tar.gz", hash = "sha256:781e3b1624dad81b24e9524bf0297b69786a0706db2cbceec1e2b05c38e5152f", size = 225071, upload-time = "2026-07-23T02:52:43.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/6a/d3b8208d2f8aac66abe8ccc1c23fa2c89464ec42cc71a601e95d05902428/gitpython-3.1.55-py3-none-any.whl", hash = "sha256:7c9ec1e69c158c081632ab35c41471e302c96db2ae42165036a5d2403378812e", size = 216590 }, + { url = "https://files.pythonhosted.org/packages/20/6a/d3b8208d2f8aac66abe8ccc1c23fa2c89464ec42cc71a601e95d05902428/gitpython-3.1.55-py3-none-any.whl", hash = "sha256:7c9ec1e69c158c081632ab35c41471e302c96db2ae42165036a5d2403378812e", size = 216590, upload-time = "2026-07-23T02:52:41.932Z" }, ] [[package]] @@ -1469,9 +1469,9 @@ dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/33/dbc946a407401b975f0719658f18e664ece2109f79ffd1ff3bf226c205f4/google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051", size = 365820 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/33/dbc946a407401b975f0719658f18e664ece2109f79ffd1ff3bf226c205f4/google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051", size = 365820, upload-time = "2026-07-21T21:53:28.188Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/63/50636aae68c9bf17c891c7eb18b49baa9bd6b31d2a97b8de4813a9fc8d1c/google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6", size = 258588 }, + { url = "https://files.pythonhosted.org/packages/88/63/50636aae68c9bf17c891c7eb18b49baa9bd6b31d2a97b8de4813a9fc8d1c/google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6", size = 258588, upload-time = "2026-07-21T21:53:26.399Z" }, ] [package.optional-dependencies] @@ -1495,9 +1495,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/df/4f820054c99f29f2fe3de4a8a7c9534dd795302e4a07483a0cb07c3a29b6/google_genai-2.14.0.tar.gz", hash = "sha256:a9d1f4f362d76280f1be1340fcb3c86e63dbca128f6a4ae09d86ab47ff7148e8", size = 641055 } +sdist = { url = "https://files.pythonhosted.org/packages/dc/df/4f820054c99f29f2fe3de4a8a7c9534dd795302e4a07483a0cb07c3a29b6/google_genai-2.14.0.tar.gz", hash = "sha256:a9d1f4f362d76280f1be1340fcb3c86e63dbca128f6a4ae09d86ab47ff7148e8", size = 641055, upload-time = "2026-07-22T21:35:44.717Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/86/5ac5fb53e44cca4a6607fb917eb331fa237c65a103b9ec2e8e8acc8a42db/google_genai-2.14.0-py3-none-any.whl", hash = "sha256:ae7172cdd35695189b516b33a878e4132e5daa2dbc03a5b44cddfa8a82fad664", size = 1030738 }, + { url = "https://files.pythonhosted.org/packages/ba/86/5ac5fb53e44cca4a6607fb917eb331fa237c65a103b9ec2e8e8acc8a42db/google_genai-2.14.0-py3-none-any.whl", hash = "sha256:ae7172cdd35695189b516b33a878e4132e5daa2dbc03a5b44cddfa8a82fad664", size = 1030738, upload-time = "2026-07-22T21:35:42.785Z" }, ] [[package]] @@ -1507,27 +1507,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035 } +sdist = { url = "https://files.pythonhosted.org/packages/b5/c8/f439cffde755cffa462bfbb156278fa6f9d09119719af9814b858fd4f81f/googleapis_common_protos-1.75.0.tar.gz", hash = "sha256:53a062ff3c32552fbd62c11fe23768b78e4ddf0494d5e5fd97d3f4689c75fbbd", size = 151035, upload-time = "2026-05-07T08:04:49.423Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631 }, + { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" }, ] [[package]] name = "greenlet" version = "3.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538 } +sdist = { url = "https://files.pythonhosted.org/packages/a3/74/b13368064b09053253555d3f2839cc2684d22d5aed0d2ccffbf7a6736558/greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20", size = 206538, upload-time = "2026-07-22T12:47:14.468Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831 }, - { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619 }, - { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021 }, - { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845 }, - { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002 }, - { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053 }, - { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533 }, - { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781 }, - { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133 }, - { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500 }, + { url = "https://files.pythonhosted.org/packages/c0/9a/e51225dcd58713f16ccbdcc501a8da21098ea14515b7870f1f94459e5ff5/greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02", size = 294831, upload-time = "2026-07-22T11:38:53.389Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ea/de50a50fadf979713ab18b46f22ad5ff5f2dcfc637a3ebdecf669801e1a5/greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356", size = 614619, upload-time = "2026-07-22T12:26:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/2aae27fea41205b8650294c301f042a2a4bb6155eea48c995b890a92f2c1/greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef", size = 627021, upload-time = "2026-07-22T12:29:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/1b/80/fb4d4788bbc8e54761f1fc88533af9523a6e86299fa113d6e8a8503ed9fc/greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c", size = 632845, upload-time = "2026-07-22T12:43:45.19Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/79fd826f9ccaae0b84e1b4ef68dabba5e105bb044ffcd448a0b782fcba9a/greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0", size = 624002, upload-time = "2026-07-22T11:51:11.391Z" }, + { url = "https://files.pythonhosted.org/packages/42/e3/6086fa578ebb72772722cdc4bcd628459814b42e0c2db1e3cbd6552b3271/greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861", size = 435053, upload-time = "2026-07-22T12:39:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/27319f97e731298513dcba1a2e91b63e9d8811d9de22130f960b129b1bf1/greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd", size = 1581533, upload-time = "2026-07-22T12:25:05.322Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/24240bf562e9786dd2799ee0a4a4dadb4ded22510f41b20245099159ac8c/greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f", size = 1645781, upload-time = "2026-07-22T11:51:14.805Z" }, + { url = "https://files.pythonhosted.org/packages/c1/5a/442ab1a9ef7ca6bf7210e5397a95972206a91a31033a03c8900866a10039/greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c", size = 247133, upload-time = "2026-07-22T11:39:20.661Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/9160210222386b1a378ff94db846b9508ca24a121cf684991561fdb69280/greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f", size = 245500, upload-time = "2026-07-22T11:40:22.185Z" }, ] [[package]] @@ -1538,65 +1538,65 @@ dependencies = [ { name = "pathspec" }, { name = "tree-sitter-language-pack" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/82/a87079945a7c15d242cb586ae22e17952132439eaa9c878ec5fbdc61c54d/grep_ast-0.9.0.tar.gz", hash = "sha256:620a242a4493e6721338d1c9a6c234ae651f8774f4924a6dcf90f6865d4b2ee3", size = 14125 } +sdist = { url = "https://files.pythonhosted.org/packages/67/82/a87079945a7c15d242cb586ae22e17952132439eaa9c878ec5fbdc61c54d/grep_ast-0.9.0.tar.gz", hash = "sha256:620a242a4493e6721338d1c9a6c234ae651f8774f4924a6dcf90f6865d4b2ee3", size = 14125, upload-time = "2025-05-08T01:08:28.371Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/79/29f1373b2ce1eec37c03aefbc17194c2470d8b61ede288e5043231825999/grep_ast-0.9.0-py3-none-any.whl", hash = "sha256:a3973dca99f1abc026a01bbbc70e00a63860c8ff94a56182ff18b089836826d7", size = 13918 }, + { url = "https://files.pythonhosted.org/packages/40/79/29f1373b2ce1eec37c03aefbc17194c2470d8b61ede288e5043231825999/grep_ast-0.9.0-py3-none-any.whl", hash = "sha256:a3973dca99f1abc026a01bbbc70e00a63860c8ff94a56182ff18b089836826d7", size = 13918, upload-time = "2025-05-08T01:08:27.481Z" }, ] [[package]] name = "griffelib" version = "2.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234 } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560 }, + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, ] [[package]] name = "grimp" version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/73/ce58881177b003def779c87b5e10f396deef068933c97d6d206bd46d4cb7/grimp-3.15.tar.gz", hash = "sha256:91b57d4d801dc107ebfb5a7040d4777a152c579b5dc202426e1185e50931fe1e", size = 831734 } +sdist = { url = "https://files.pythonhosted.org/packages/b2/73/ce58881177b003def779c87b5e10f396deef068933c97d6d206bd46d4cb7/grimp-3.15.tar.gz", hash = "sha256:91b57d4d801dc107ebfb5a7040d4777a152c579b5dc202426e1185e50931fe1e", size = 831734, upload-time = "2026-07-03T12:09:36.244Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/6a/3e0a0760cc509cd09764d128de78a1f329740306c74e42dba82c58a99118/grimp-3.15-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f19b957053d1c736aaa0015eed2d4855bb2511637c5360d32b3c5ea045904e7a", size = 2143197 }, - { url = "https://files.pythonhosted.org/packages/c8/2e/127cbce04a5603d382c6a2bbc1a19b6889be49d60b88864bcdb174c8926d/grimp-3.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6480472c2d1f7f6a903906c92e9897d4e3dee5d69ce3881f04368d4ec6d2dde", size = 2088342 }, - { url = "https://files.pythonhosted.org/packages/b0/36/af9df683bf6c8711e0e9136876ca130f9971102d945ff3a36d0c45dae2ec/grimp-3.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c14b64dbf2e4397df2e35fa8aa7028533621ecf1f8ea7ec24bc296a9c695ea4", size = 2254509 }, - { url = "https://files.pythonhosted.org/packages/02/f5/e712633b68ea14d04e7de84ede4f8ddbba763d5f2d29ae8d6af721f84870/grimp-3.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26364c2c9f7db88243365299b4d1c4d948b74304ff03c8a6e4f028147fed3b22", size = 2193831 }, - { url = "https://files.pythonhosted.org/packages/13/74/151bdd73d6a60bd77d4b956f36d03dd558dd46a9b5ec8f5ad15921b503d7/grimp-3.15-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69331e1be693415596c6084342059b9ba3ecf1cfe2e3b1c761598dd2fe14d522", size = 2345289 }, - { url = "https://files.pythonhosted.org/packages/a1/b8/3fc950fa73b757cbc35f77542bd662f431b9a8f360e63196ded640771a33/grimp-3.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe397991c269868c2fb08114099b2aa4f1bc803d03fadaaf97e006019f9e5da2", size = 2604407 }, - { url = "https://files.pythonhosted.org/packages/0a/d8/98916b9dc0b89a3e89e0d714ce0872be859fff40ceee3e2cb6886b106eb4/grimp-3.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d556bd62664ee044c47cff64d737be132408064d4ba68ca9f756cb29b41cc2d", size = 2326769 }, - { url = "https://files.pythonhosted.org/packages/63/51/39595c5857f609e976e0bba1f19c1f45182ee4d8d2ea5cdfab72841eafbc/grimp-3.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9061c5b6f01130ff8639c49c80176d29d921acc36741b2ae0b763a7668106082", size = 2272498 }, - { url = "https://files.pythonhosted.org/packages/7d/d8/a44cc9db500ae80c45425238ee44d9556796aa88b8c0649cfa613fb88d14/grimp-3.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a9d04c195f0c6da4476361560d3d206a6a784b9eb0da7156fc6974511337e2e3", size = 2431075 }, - { url = "https://files.pythonhosted.org/packages/a9/41/544f197ddb44990789683c25740ffa72474a57f0b16bc6b4a544edf9a2a9/grimp-3.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:01fd68c74cfd08b110bfd338d77efaa470670530f7179e6977764f44cd74d5cc", size = 2467361 }, - { url = "https://files.pythonhosted.org/packages/3b/b1/8261018b9f1ab47fffbb7739d7af3f04c8b529555b7fb2ae8b742d42d3db/grimp-3.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bd1ddd428a124d6730bed49ea60fb2ca6c8e0640c8f5abe1d0f6fc27a92fd8bc", size = 2503500 }, - { url = "https://files.pythonhosted.org/packages/db/d6/99a2421c4c0de9f203a7e246030b13b676766e62e48504883863942646fb/grimp-3.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07e645e9d45ed43bb8ea8da5982c19eacf13ee685d8440e3dc280064c005599c", size = 2513834 }, - { url = "https://files.pythonhosted.org/packages/62/a5/263729e23d64541cd99d36dbb592e29c1ecea803deb3bb5c0c463b43c2ec/grimp-3.15-cp313-cp313-win32.whl", hash = "sha256:473646e0a74a554b4ab071d7fcbf5f442eb8cf87561770dae269818636b8edf2", size = 1855743 }, - { url = "https://files.pythonhosted.org/packages/1c/8c/a072bbea2e2e94da38f90bd8794037c90fb4eba389b49d01cdd2bb85e13c/grimp-3.15-cp313-cp313-win_amd64.whl", hash = "sha256:dbc2c15a1fbca2ff358f86cc90067176096dd73bec27d002515521b3125ba507", size = 1984546 }, + { url = "https://files.pythonhosted.org/packages/1e/6a/3e0a0760cc509cd09764d128de78a1f329740306c74e42dba82c58a99118/grimp-3.15-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f19b957053d1c736aaa0015eed2d4855bb2511637c5360d32b3c5ea045904e7a", size = 2143197, upload-time = "2026-07-03T12:08:51.685Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2e/127cbce04a5603d382c6a2bbc1a19b6889be49d60b88864bcdb174c8926d/grimp-3.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6480472c2d1f7f6a903906c92e9897d4e3dee5d69ce3881f04368d4ec6d2dde", size = 2088342, upload-time = "2026-07-03T12:08:44.227Z" }, + { url = "https://files.pythonhosted.org/packages/b0/36/af9df683bf6c8711e0e9136876ca130f9971102d945ff3a36d0c45dae2ec/grimp-3.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c14b64dbf2e4397df2e35fa8aa7028533621ecf1f8ea7ec24bc296a9c695ea4", size = 2254509, upload-time = "2026-07-03T12:07:35.809Z" }, + { url = "https://files.pythonhosted.org/packages/02/f5/e712633b68ea14d04e7de84ede4f8ddbba763d5f2d29ae8d6af721f84870/grimp-3.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26364c2c9f7db88243365299b4d1c4d948b74304ff03c8a6e4f028147fed3b22", size = 2193831, upload-time = "2026-07-03T12:07:46.309Z" }, + { url = "https://files.pythonhosted.org/packages/13/74/151bdd73d6a60bd77d4b956f36d03dd558dd46a9b5ec8f5ad15921b503d7/grimp-3.15-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69331e1be693415596c6084342059b9ba3ecf1cfe2e3b1c761598dd2fe14d522", size = 2345289, upload-time = "2026-07-03T12:08:19.458Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b8/3fc950fa73b757cbc35f77542bd662f431b9a8f360e63196ded640771a33/grimp-3.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe397991c269868c2fb08114099b2aa4f1bc803d03fadaaf97e006019f9e5da2", size = 2604407, upload-time = "2026-07-03T12:07:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/0a/d8/98916b9dc0b89a3e89e0d714ce0872be859fff40ceee3e2cb6886b106eb4/grimp-3.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1d556bd62664ee044c47cff64d737be132408064d4ba68ca9f756cb29b41cc2d", size = 2326769, upload-time = "2026-07-03T12:08:08.773Z" }, + { url = "https://files.pythonhosted.org/packages/63/51/39595c5857f609e976e0bba1f19c1f45182ee4d8d2ea5cdfab72841eafbc/grimp-3.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9061c5b6f01130ff8639c49c80176d29d921acc36741b2ae0b763a7668106082", size = 2272498, upload-time = "2026-07-03T12:08:32.03Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d8/a44cc9db500ae80c45425238ee44d9556796aa88b8c0649cfa613fb88d14/grimp-3.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a9d04c195f0c6da4476361560d3d206a6a784b9eb0da7156fc6974511337e2e3", size = 2431075, upload-time = "2026-07-03T12:08:58.935Z" }, + { url = "https://files.pythonhosted.org/packages/a9/41/544f197ddb44990789683c25740ffa72474a57f0b16bc6b4a544edf9a2a9/grimp-3.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:01fd68c74cfd08b110bfd338d77efaa470670530f7179e6977764f44cd74d5cc", size = 2467361, upload-time = "2026-07-03T12:09:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/8261018b9f1ab47fffbb7739d7af3f04c8b529555b7fb2ae8b742d42d3db/grimp-3.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bd1ddd428a124d6730bed49ea60fb2ca6c8e0640c8f5abe1d0f6fc27a92fd8bc", size = 2503500, upload-time = "2026-07-03T12:09:19.585Z" }, + { url = "https://files.pythonhosted.org/packages/db/d6/99a2421c4c0de9f203a7e246030b13b676766e62e48504883863942646fb/grimp-3.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07e645e9d45ed43bb8ea8da5982c19eacf13ee685d8440e3dc280064c005599c", size = 2513834, upload-time = "2026-07-03T12:09:29.758Z" }, + { url = "https://files.pythonhosted.org/packages/62/a5/263729e23d64541cd99d36dbb592e29c1ecea803deb3bb5c0c463b43c2ec/grimp-3.15-cp313-cp313-win32.whl", hash = "sha256:473646e0a74a554b4ab071d7fcbf5f442eb8cf87561770dae269818636b8edf2", size = 1855743, upload-time = "2026-07-03T12:09:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/1c/8c/a072bbea2e2e94da38f90bd8794037c90fb4eba389b49d01cdd2bb85e13c/grimp-3.15-cp313-cp313-win_amd64.whl", hash = "sha256:dbc2c15a1fbca2ff358f86cc90067176096dd73bec27d002515521b3125ba507", size = 1984546, upload-time = "2026-07-03T12:09:42.543Z" }, ] [[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] name = "hf-xet" version = "1.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674 } +sdist = { url = "https://files.pythonhosted.org/packages/63/39/67be8d71f900d9a55761b6022821d6679fb56c64f1b6063d5af2c2606727/hf_xet-1.5.2.tar.gz", hash = "sha256:73044bd31bae33c984af832d19c752a0dffb67518fee9ddbd91d616e1101cf47", size = 903674, upload-time = "2026-07-16T17:29:56.833Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760 }, - { url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438 }, - { url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006 }, - { url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099 }, - { url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766 }, - { url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716 }, - { url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373 }, - { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957 }, + { url = "https://files.pythonhosted.org/packages/de/ba/2b70603c7552db82baeb2623e2336898304a17328845151be4fe1f48d420/hf_xet-1.5.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f922b8f5fb84f1dd3d7ab7a1316354a1bca9b1c73ecfc19c76e51a2a49d29799", size = 4033760, upload-time = "2026-07-16T17:29:43.884Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/b097a86a1e4a6098f3a79382643ab09d5733d87ccc864877ad1e12b49b70/hf_xet-1.5.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:045f84440c55cdeb659cf1a1dd48c77bcd0d2e93632e2fea8f2c3bdee79f38ed", size = 3841438, upload-time = "2026-07-16T17:29:45.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/35/db860aa3a0780660324a506ad4b3d322ddc6ecbba4b9340aed0942cbf21c/hf_xet-1.5.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db78c39c83d6279daddc98e2238f373ab8980685556d42472b4ec51abcf03e8c", size = 4428006, upload-time = "2026-07-16T17:29:46.996Z" }, + { url = "https://files.pythonhosted.org/packages/af/6b/832dd980af4b0c3ae0660e309285f2ffcdff2faa38129390dbb47aa4a3f9/hf_xet-1.5.2-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7db73c810500c54c6760be8c39d4b2e476974de85424c50063efc22fdda13025", size = 4221099, upload-time = "2026-07-16T17:29:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/9e/05/ae50f0d34e3254e6c3e208beb2519f6b8673016fc4b3643badaf6450d186/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6395cfe3c9cbead4f16b31808b0e67eac428b66c656f856e99636adaddea878f", size = 4420766, upload-time = "2026-07-16T17:29:50.092Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/c050bc2743a2bcd68928bfee157b08681667a164a24ec95fbfcfcd717e08/hf_xet-1.5.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cde8cd167126bb6109b2ceb19b844433a4988643e8f3e01dd9dd0e4a34535097", size = 4636716, upload-time = "2026-07-16T17:29:51.62Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f8/68b01c5c2edb56ac9a67b3d076ffddcb90867abaee923923eb34e7a14e76/hf_xet-1.5.2-cp38-abi3-win_amd64.whl", hash = "sha256:ecf63d1cb69a9a7319910f8f83fcf9b46e7a32dfcf4b8f8eeddb55f647306e65", size = 3988373, upload-time = "2026-07-16T17:29:53.395Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/988383e9dc17294d536fcbcd6fd16eed882e411ad16c954984a53e47b09c/hf_xet-1.5.2-cp38-abi3-win_arm64.whl", hash = "sha256:1da28519496eb7c8094c11e4d25509b4a468457a0302d58136099db2fd9a671d", size = 3816957, upload-time = "2026-07-16T17:29:54.991Z" }, ] [[package]] @@ -1607,9 +1607,9 @@ dependencies = [ { name = "msgpack" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/6c/6fda06f82ec7adc959ec49d80a87e26d31ddce2a9a4c8582a8721c9ac8ec/hishel-1.3.0.tar.gz", hash = "sha256:5be585dca8c035866cffc745a50dbce6b277a3d1f4b07ef24f77c07f6b31ff92", size = 813532 } +sdist = { url = "https://files.pythonhosted.org/packages/9e/6c/6fda06f82ec7adc959ec49d80a87e26d31ddce2a9a4c8582a8721c9ac8ec/hishel-1.3.0.tar.gz", hash = "sha256:5be585dca8c035866cffc745a50dbce6b277a3d1f4b07ef24f77c07f6b31ff92", size = 813532, upload-time = "2026-06-11T08:41:29.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/fd/8d2ed31f94fbf6fd15923afdb40d0e4ed26286ecaa70c256033b240ca5b1/hishel-1.3.0-py3-none-any.whl", hash = "sha256:b803ad9f1d410085b61459a5dbbd273806215021e672198ba911c7d3db14adaa", size = 73626 }, + { url = "https://files.pythonhosted.org/packages/f5/fd/8d2ed31f94fbf6fd15923afdb40d0e4ed26286ecaa70c256033b240ca5b1/hishel-1.3.0-py3-none-any.whl", hash = "sha256:b803ad9f1d410085b61459a5dbbd273806215021e672198ba911c7d3db14adaa", size = 73626, upload-time = "2026-06-11T08:41:28.079Z" }, ] [package.optional-dependencies] @@ -1626,9 +1626,9 @@ dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] @@ -1639,24 +1639,24 @@ dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089 } +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809 }, + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, ] [[package]] name = "httptools" version = "0.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342 } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148 }, - { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368 }, - { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447 }, - { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448 }, - { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460 }, - { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312 }, - { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117 }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, ] [[package]] @@ -1669,9 +1669,9 @@ dependencies = [ { name = "httpcore" }, { name = "idna" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [package.optional-dependencies] @@ -1683,9 +1683,9 @@ socks = [ name = "httpx-sse" version = "0.4.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, ] [[package]] @@ -1698,9 +1698,9 @@ dependencies = [ { name = "idna" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458 } +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191 }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] @@ -1718,18 +1718,18 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/9b/d3bb4e7d792835daf34dd7091bbc7d7b4e0437d9388f1ea7239cce49f478/huggingface_hub-1.24.0.tar.gz", hash = "sha256:18431ff4daae0749aa9ba102fc952e314c98e1d30ebdec5319d85ca0a83e1ae5", size = 921848 } +sdist = { url = "https://files.pythonhosted.org/packages/df/9b/d3bb4e7d792835daf34dd7091bbc7d7b4e0437d9388f1ea7239cce49f478/huggingface_hub-1.24.0.tar.gz", hash = "sha256:18431ff4daae0749aa9ba102fc952e314c98e1d30ebdec5319d85ca0a83e1ae5", size = 921848, upload-time = "2026-07-17T09:54:01.022Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5f/c3/aeaaf3911d2529614be18d1c8b5496afc185560e76568063d517283318af/huggingface_hub-1.24.0-py3-none-any.whl", hash = "sha256:6ed4120a84a6beec900640aa7e346bd766a6b7341e41526fef5dc8bd81fb7d59", size = 771904 }, + { url = "https://files.pythonhosted.org/packages/5f/c3/aeaaf3911d2529614be18d1c8b5496afc185560e76568063d517283318af/huggingface_hub-1.24.0-py3-none-any.whl", hash = "sha256:6ed4120a84a6beec900640aa7e346bd766a6b7341e41526fef5dc8bd81fb7d59", size = 771904, upload-time = "2026-07-17T09:53:59.106Z" }, ] [[package]] name = "idna" version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711 } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455 }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -1742,9 +1742,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/c6/42962eb043df4d6984c1540220735b20442572fe37dab5e65ac807c939b9/import_linter-2.13.tar.gz", hash = "sha256:13af4a1d6b06044c58ea784e8732fd7fe48eec821a75feb4d6a1a2de36dd5c27", size = 1279761 } +sdist = { url = "https://files.pythonhosted.org/packages/97/c6/42962eb043df4d6984c1540220735b20442572fe37dab5e65ac807c939b9/import_linter-2.13.tar.gz", hash = "sha256:13af4a1d6b06044c58ea784e8732fd7fe48eec821a75feb4d6a1a2de36dd5c27", size = 1279761, upload-time = "2026-07-03T14:00:31.285Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/13/e7725e6eb32607fd4af51ccf3edfe835826e5cdf783b4d7fdc8f459196ae/import_linter-2.13-py3-none-any.whl", hash = "sha256:c0372e7ee5e15657bc06a8e841445e13237afd738a672d26863dc927af9f0bf5", size = 638185 }, + { url = "https://files.pythonhosted.org/packages/88/13/e7725e6eb32607fd4af51ccf3edfe835826e5cdf783b4d7fdc8f459196ae/import_linter-2.13-py3-none-any.whl", hash = "sha256:c0372e7ee5e15657bc06a8e841445e13237afd738a672d26863dc927af9f0bf5", size = 638185, upload-time = "2026-07-03T14:00:29.676Z" }, ] [[package]] @@ -1754,9 +1754,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107 } +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865 }, + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, ] [[package]] @@ -1767,18 +1767,18 @@ dependencies = [ { name = "more-itertools" }, { name = "typeguard" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751 } +sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197 }, + { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, ] [[package]] name = "iniconfig" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -1792,27 +1792,27 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/38a1b9c6323c5dff0ab6c74aa839f1c7d8b7057a861d80ec5bfe834be24e/inline_snapshot-0.35.2.tar.gz", hash = "sha256:6cfd2ea0b52d9cb9beb13c1e73d740ff86f630ba48d9ea6947e54e3ff8494876", size = 2534713 } +sdist = { url = "https://files.pythonhosted.org/packages/c6/b3/38a1b9c6323c5dff0ab6c74aa839f1c7d8b7057a861d80ec5bfe834be24e/inline_snapshot-0.35.2.tar.gz", hash = "sha256:6cfd2ea0b52d9cb9beb13c1e73d740ff86f630ba48d9ea6947e54e3ff8494876", size = 2534713, upload-time = "2026-07-16T10:51:58.826Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ea/de85fc264e4c76bda3552eee42904e8b7079fd826902aa5fa044bc5bbc3c/inline_snapshot-0.35.2-py3-none-any.whl", hash = "sha256:91b230b310c95a8c3b91cdbc637e064c7c2f98c13935b64875a7ba9ef174e161", size = 94669 }, + { url = "https://files.pythonhosted.org/packages/b7/ea/de85fc264e4c76bda3552eee42904e8b7079fd826902aa5fa044bc5bbc3c/inline_snapshot-0.35.2-py3-none-any.whl", hash = "sha256:91b230b310c95a8c3b91cdbc637e064c7c2f98c13935b64875a7ba9ef174e161", size = 94669, upload-time = "2026-07-16T10:51:57.457Z" }, ] [[package]] name = "installer" version = "1.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/fe/b9f481cf0cc867958a21338baa900357b7b7d86cac9b025948049d77923c/installer-1.0.1.tar.gz", hash = "sha256:052c7fc3721d54c696e2dea019be67539d7b144e924f559f54beb3121831c364", size = 481132 } +sdist = { url = "https://files.pythonhosted.org/packages/06/fe/b9f481cf0cc867958a21338baa900357b7b7d86cac9b025948049d77923c/installer-1.0.1.tar.gz", hash = "sha256:052c7fc3721d54c696e2dea019be67539d7b144e924f559f54beb3121831c364", size = 481132, upload-time = "2026-05-11T18:13:28.158Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/48/bedf3ba4163e7db392c9d4cbdfc8217423196c8fccbe5a404a0f094fde63/installer-1.0.1-py3-none-any.whl", hash = "sha256:011d045df8b954ced7dde3a7e42ae4418da40ecda7990f2d11d5ed7c146fd98b", size = 464455 }, + { url = "https://files.pythonhosted.org/packages/26/48/bedf3ba4163e7db392c9d4cbdfc8217423196c8fccbe5a404a0f094fde63/installer-1.0.1-py3-none-any.whl", hash = "sha256:011d045df8b954ced7dde3a7e42ae4418da40ecda7990f2d11d5ed7c146fd98b", size = 464455, upload-time = "2026-05-11T18:13:26.252Z" }, ] [[package]] name = "isort" version = "8.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592 } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733 }, + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, ] [[package]] @@ -1822,18 +1822,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, ] [[package]] name = "jaraco-context" version = "6.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801 } +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871 }, + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, ] [[package]] @@ -1843,18 +1843,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837 } +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677 }, + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, ] [[package]] name = "jeepney" version = "0.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758 } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 }, + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, ] [[package]] @@ -1864,9 +1864,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] [[package]] @@ -1881,9 +1881,9 @@ dependencies = [ { name = "universal-pathlib" }, { name = "upathtools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/90/14/6caaf04cefcf97fff8db5de10e5fc1f6f7af7f20acdb441f089a5dbe130c/jinjarope-1.0.7.tar.gz", hash = "sha256:876db5c9b087f8275f9a6ae2686ae12d57999d320500b8ddcbc9ecbf26e3c42b", size = 2686298 } +sdist = { url = "https://files.pythonhosted.org/packages/90/14/6caaf04cefcf97fff8db5de10e5fc1f6f7af7f20acdb441f089a5dbe130c/jinjarope-1.0.7.tar.gz", hash = "sha256:876db5c9b087f8275f9a6ae2686ae12d57999d320500b8ddcbc9ecbf26e3c42b", size = 2686298, upload-time = "2026-01-08T20:36:05.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/da/065560563ffa16799e488f1fddfca96834a905b47a3201a0071b33fac43f/jinjarope-1.0.7-py3-none-any.whl", hash = "sha256:c6a46558281f74fa2bde31bce368663100962ec0fa2bf68c9faf430030dea298", size = 2699207 }, + { url = "https://files.pythonhosted.org/packages/17/da/065560563ffa16799e488f1fddfca96834a905b47a3201a0071b33fac43f/jinjarope-1.0.7-py3-none-any.whl", hash = "sha256:c6a46558281f74fa2bde31bce368663100962ec0fa2bf68c9faf430030dea298", size = 2699207, upload-time = "2026-01-08T20:36:04.156Z" }, ] [package.optional-dependencies] @@ -1895,22 +1895,22 @@ icons = [ name = "jiter" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431 } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203 }, - { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489 }, - { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453 }, - { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625 }, - { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958 }, - { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017 }, - { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320 }, - { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520 }, - { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550 }, - { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424 }, - { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981 }, - { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853 }, - { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160 }, - { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862 }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, ] [[package]] @@ -1920,9 +1920,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/e0/27a6a081ae25420eda6768ceae05d7022a7f2447f420588843f2a44e4298/joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed", size = 234027 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e0/27a6a081ae25420eda6768ceae05d7022a7f2447f420588843f2a44e4298/joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed", size = 234027, upload-time = "2026-07-19T15:43:02.739Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000, upload-time = "2026-07-19T15:43:01.299Z" }, ] [[package]] @@ -1939,9 +1939,9 @@ dependencies = [ { name = "pyyaml" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/4c/83fc5c339cabfc0d14717c843f506ad2e70a197b6f2c139bb7b2a27e368b/json_schema_for_humans-1.5.1.tar.gz", hash = "sha256:a43023f6c1b99f8e75126cc824c22992d8f55e941060aa3fddfc7c38de3cb973", size = 256753 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/4c/83fc5c339cabfc0d14717c843f506ad2e70a197b6f2c139bb7b2a27e368b/json_schema_for_humans-1.5.1.tar.gz", hash = "sha256:a43023f6c1b99f8e75126cc824c22992d8f55e941060aa3fddfc7c38de3cb973", size = 256753, upload-time = "2025-11-21T14:55:54.536Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/97/1e9a730a7d7674d6022e9eaabff9218ba096b310cef712c004854248a64b/json_schema_for_humans-1.5.1-py3-none-any.whl", hash = "sha256:6b5bb65c8ccfb46219352f3da32312c9d904c08790bb96f43f109338c2141478", size = 285017 }, + { url = "https://files.pythonhosted.org/packages/a1/97/1e9a730a7d7674d6022e9eaabff9218ba096b310cef712c004854248a64b/json_schema_for_humans-1.5.1-py3-none-any.whl", hash = "sha256:6b5bb65c8ccfb46219352f3da32312c9d904c08790bb96f43f109338c2141478", size = 285017, upload-time = "2025-11-21T14:55:53.26Z" }, ] [[package]] @@ -1951,27 +1951,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/d8/423895b918706c80db1cee679c13fbe810200b9a9d9a9442c7a58d35c3f2/json_schema_to_pydantic-0.4.11.tar.gz", hash = "sha256:35448ed711a28dd33396b095c8492939b4925aa30eb31942e9b8e08d04279465", size = 56597 } +sdist = { url = "https://files.pythonhosted.org/packages/60/d8/423895b918706c80db1cee679c13fbe810200b9a9d9a9442c7a58d35c3f2/json_schema_to_pydantic-0.4.11.tar.gz", hash = "sha256:35448ed711a28dd33396b095c8492939b4925aa30eb31942e9b8e08d04279465", size = 56597, upload-time = "2026-03-09T20:53:55.692Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/64/7cfeb8c6d2a5e73e0f8d732032aa62be9a7724c04beb461d677de0b4beb3/json_schema_to_pydantic-0.4.11-py3-none-any.whl", hash = "sha256:da2ccc39d070ee03dbcf0517d16720e3e33f7aa8d61257ace09af8c51bd46348", size = 17842 }, + { url = "https://files.pythonhosted.org/packages/f3/64/7cfeb8c6d2a5e73e0f8d732032aa62be9a7724c04beb461d677de0b4beb3/json_schema_to_pydantic-0.4.11-py3-none-any.whl", hash = "sha256:da2ccc39d070ee03dbcf0517d16720e3e33f7aa8d61257ace09af8c51bd46348", size = 17842, upload-time = "2026-03-09T20:53:54.576Z" }, ] [[package]] name = "jsonpath-python" version = "1.1.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/18/4ca8742534a5993ff383f7602e325ce2d5d7cc93d72ac5e1cdedbea8a458/jsonpath_python-1.1.6.tar.gz", hash = "sha256:dded9932b4ec41fb8726e09c83afa4e6be618f938c2db287cc2a81723c639671", size = 88178 } +sdist = { url = "https://files.pythonhosted.org/packages/98/18/4ca8742534a5993ff383f7602e325ce2d5d7cc93d72ac5e1cdedbea8a458/jsonpath_python-1.1.6.tar.gz", hash = "sha256:dded9932b4ec41fb8726e09c83afa4e6be618f938c2db287cc2a81723c639671", size = 88178, upload-time = "2026-05-07T01:26:34.482Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/8a/1270a6803bd821cbfcdda387eaa13cb41a7b1f7b9bd145979b3bfb9d6cb7/jsonpath_python-1.1.6-py3-none-any.whl", hash = "sha256:a1c50afd8d3fbbaf47a4873bc890dcb3c15da96f5c020327977d844d8731a2d4", size = 14453 }, + { url = "https://files.pythonhosted.org/packages/55/8a/1270a6803bd821cbfcdda387eaa13cb41a7b1f7b9bd145979b3bfb9d6cb7/jsonpath_python-1.1.6-py3-none-any.whl", hash = "sha256:a1c50afd8d3fbbaf47a4873bc890dcb3c15da96f5c020327977d844d8731a2d4", size = 14453, upload-time = "2026-05-07T01:26:33.306Z" }, ] [[package]] name = "jsonref" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425 }, + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, ] [[package]] @@ -1984,9 +1984,9 @@ dependencies = [ { name = "referencing" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, ] [[package]] @@ -1999,9 +1999,9 @@ dependencies = [ { name = "pyyaml" }, { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597 } +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077 }, + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, ] [[package]] @@ -2011,9 +2011,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] [[package]] @@ -2028,9 +2028,9 @@ dependencies = [ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "secretstorage", marker = "sys_platform == 'linux'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516 } +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160 }, + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] [[package]] @@ -2040,9 +2040,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lance-namespace-urllib3-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/81/4cf8d0412e1f37b2bfa70d0aeb9c7ae4ab73607534e44d60b55efb485306/lance_namespace-0.9.0.tar.gz", hash = "sha256:f738b641cc615b17323baa4eb47900f184688739ee3d2ea9fe39396b9588e53d", size = 11637 } +sdist = { url = "https://files.pythonhosted.org/packages/00/81/4cf8d0412e1f37b2bfa70d0aeb9c7ae4ab73607534e44d60b55efb485306/lance_namespace-0.9.0.tar.gz", hash = "sha256:f738b641cc615b17323baa4eb47900f184688739ee3d2ea9fe39396b9588e53d", size = 11637, upload-time = "2026-07-01T07:42:41.78Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/fe/f38747c9610ade83dd9a99a0470b9432b6f21ce4e2bb5524edbe66f626fd/lance_namespace-0.9.0-py3-none-any.whl", hash = "sha256:f785ff10927e4ce0db69986576670fedd37f8a33521e8a4630c6be22db8061b2", size = 13501 }, + { url = "https://files.pythonhosted.org/packages/e1/fe/f38747c9610ade83dd9a99a0470b9432b6f21ce4e2bb5524edbe66f626fd/lance_namespace-0.9.0-py3-none-any.whl", hash = "sha256:f785ff10927e4ce0db69986576670fedd37f8a33521e8a4630c6be22db8061b2", size = 13501, upload-time = "2026-07-01T07:42:39.372Z" }, ] [[package]] @@ -2055,9 +2055,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/c3/32d0e2618549ace857c80a457e5915ef3e1145661baff876c8a5ec27be5b/lance_namespace_urllib3_client-0.9.0.tar.gz", hash = "sha256:cf796fa5307fa4dde91fe4bec2af28b90ba79191852d4394e8fe44276538e40f", size = 235805 } +sdist = { url = "https://files.pythonhosted.org/packages/d3/c3/32d0e2618549ace857c80a457e5915ef3e1145661baff876c8a5ec27be5b/lance_namespace_urllib3_client-0.9.0.tar.gz", hash = "sha256:cf796fa5307fa4dde91fe4bec2af28b90ba79191852d4394e8fe44276538e40f", size = 235805, upload-time = "2026-07-01T07:42:42.563Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/ab/c8754da0a1efc817f8480100cfe12b7e04034df834759de8ecf02beff3cc/lance_namespace_urllib3_client-0.9.0-py3-none-any.whl", hash = "sha256:be819c8cffb1e460a3a504dbf52d1ca009560a48e7202b8c4279998e4adf9fe4", size = 405586 }, + { url = "https://files.pythonhosted.org/packages/cc/ab/c8754da0a1efc817f8480100cfe12b7e04034df834759de8ecf02beff3cc/lance_namespace_urllib3_client-0.9.0-py3-none-any.whl", hash = "sha256:be819c8cffb1e460a3a504dbf52d1ca009560a48e7202b8c4279998e4adf9fe4", size = 405586, upload-time = "2026-07-01T07:42:40.503Z" }, ] [[package]] @@ -2074,10 +2074,10 @@ dependencies = [ { name = "tqdm" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/df/f7/5262b9aa593f790757163c0165ab0da1dda054758901bea7e4f02c9cb633/lancedb-0.34.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c462f2e6f933cad659fd0179394eaab578acbc9151fe2ef41bc29b36ecca5058", size = 52654213 }, - { url = "https://files.pythonhosted.org/packages/69/99/05ea0d32229ebea695193ff20c15d6ecae25785ad82a9d4723d98832a284/lancedb-0.34.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:48829e88e708947d0520454ab9e4f8efa35f3e3626469eadd3a6e061b89cb223", size = 55434501 }, - { url = "https://files.pythonhosted.org/packages/cd/4e/4325c13d5afa93c466428a5a0f168ad4d96f5eb4a77bbe7c5100d39c9897/lancedb-0.34.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:05ba8a5b58e064edfbe5be71b1abf2e411b4eaf295d1a173dcb1a55c5bfb5285", size = 58659359 }, - { url = "https://files.pythonhosted.org/packages/d9/5d/8ca165f1386caf6c4d1c515afd52f345b66432264eecfdfb7fd33eefd9af/lancedb-0.34.0-cp39-abi3-win_amd64.whl", hash = "sha256:51cbc11808f9e3332819b9367c975b3a888541447a8e7bea09c57c852a279153", size = 63530726 }, + { url = "https://files.pythonhosted.org/packages/df/f7/5262b9aa593f790757163c0165ab0da1dda054758901bea7e4f02c9cb633/lancedb-0.34.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c462f2e6f933cad659fd0179394eaab578acbc9151fe2ef41bc29b36ecca5058", size = 52654213, upload-time = "2026-07-02T17:13:31.102Z" }, + { url = "https://files.pythonhosted.org/packages/69/99/05ea0d32229ebea695193ff20c15d6ecae25785ad82a9d4723d98832a284/lancedb-0.34.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:48829e88e708947d0520454ab9e4f8efa35f3e3626469eadd3a6e061b89cb223", size = 55434501, upload-time = "2026-07-02T17:13:34.81Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4e/4325c13d5afa93c466428a5a0f168ad4d96f5eb4a77bbe7c5100d39c9897/lancedb-0.34.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:05ba8a5b58e064edfbe5be71b1abf2e411b4eaf295d1a173dcb1a55c5bfb5285", size = 58659359, upload-time = "2026-07-02T17:13:38.424Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/8ca165f1386caf6c4d1c515afd52f345b66432264eecfdfb7fd33eefd9af/lancedb-0.34.0-cp39-abi3-win_amd64.whl", hash = "sha256:51cbc11808f9e3332819b9367c975b3a888541447a8e7bea09c57c852a279153", size = 63530726, upload-time = "2026-07-02T17:13:41.612Z" }, ] [[package]] @@ -2094,51 +2094,51 @@ dependencies = [ { name = "pydantic" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/61/bb7fa00f5334a670c9aae1f053bc725654a0ca5435fa17d0cb103df8c978/langfuse-4.14.1.tar.gz", hash = "sha256:576641820ae79aeca71453c8eff3c8c35d53f7e41729c918f59eb81f7499faf9", size = 381977 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/61/bb7fa00f5334a670c9aae1f053bc725654a0ca5435fa17d0cb103df8c978/langfuse-4.14.1.tar.gz", hash = "sha256:576641820ae79aeca71453c8eff3c8c35d53f7e41729c918f59eb81f7499faf9", size = 381977, upload-time = "2026-07-20T08:44:04.669Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/e4/6c305af16f1ce344271dd829383b7af586530cf60b55af1aaf274ca37ba9/langfuse-4.14.1-py3-none-any.whl", hash = "sha256:07d19f16338b8e21f8e5996b7e6c3ed150ee582fbaa6275ac9eeea297093f4be", size = 669448 }, + { url = "https://files.pythonhosted.org/packages/e6/e4/6c305af16f1ce344271dd829383b7af586530cf60b55af1aaf274ca37ba9/langfuse-4.14.1-py3-none-any.whl", hash = "sha256:07d19f16338b8e21f8e5996b7e6c3ed150ee582fbaa6275ac9eeea297093f4be", size = 669448, upload-time = "2026-07-20T08:44:05.682Z" }, ] [[package]] name = "lazy-object-proxy" version = "1.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681 } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745 }, - { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537 }, - { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141 }, - { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449 }, - { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744 }, - { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568 }, - { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391 }, - { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552 }, - { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857 }, - { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833 }, - { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516 }, - { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656 }, + { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" }, + { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" }, + { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" }, + { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" }, + { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" }, + { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" }, ] [[package]] name = "librt" version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402 } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027 }, - { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152 }, - { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499 }, - { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108 }, - { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576 }, - { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390 }, - { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053 }, - { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387 }, - { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970 }, - { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582 }, - { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189 }, - { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193 }, - { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962 }, - { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127 }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, ] [[package]] @@ -2154,9 +2154,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/b7/2b9670e893b5c662c23252bc52cafa2664b7c582b8c36b280693b2beaaa0/logfire-4.38.0.tar.gz", hash = "sha256:da62f7616f3411098cf5fb087f9ff24f54886a9087a238fe0e5fae8bbd609717", size = 1237040 } +sdist = { url = "https://files.pythonhosted.org/packages/18/b7/2b9670e893b5c662c23252bc52cafa2664b7c582b8c36b280693b2beaaa0/logfire-4.38.0.tar.gz", hash = "sha256:da62f7616f3411098cf5fb087f9ff24f54886a9087a238fe0e5fae8bbd609717", size = 1237040, upload-time = "2026-07-20T12:15:33.488Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/84/7593993a474fdea837577303840177864aaa2883859874d5fa6d037af03f/logfire-4.38.0-py3-none-any.whl", hash = "sha256:da4bffd43190930d0b889e06c596c0a7fce1c485a51de862ad9ff0d5033ecfb9", size = 403289 }, + { url = "https://files.pythonhosted.org/packages/5c/84/7593993a474fdea837577303840177864aaa2883859874d5fa6d037af03f/logfire-4.38.0-py3-none-any.whl", hash = "sha256:da4bffd43190930d0b889e06c596c0a7fce1c485a51de862ad9ff0d5033ecfb9", size = 403289, upload-time = "2026-07-20T12:15:30.304Z" }, ] [package.optional-dependencies] @@ -2171,9 +2171,9 @@ httpx = [ name = "logfire-api" version = "4.38.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/1a/17af258260cf8378fabd10bacef0c585e19697eab1deea25f8e4f4fb6462/logfire_api-4.38.0.tar.gz", hash = "sha256:e773cd4a26edb48ef4ec6f88661d1c670bb68c303a71c7043a236d64a9699cd2", size = 90364 } +sdist = { url = "https://files.pythonhosted.org/packages/10/1a/17af258260cf8378fabd10bacef0c585e19697eab1deea25f8e4f4fb6462/logfire_api-4.38.0.tar.gz", hash = "sha256:e773cd4a26edb48ef4ec6f88661d1c670bb68c303a71c7043a236d64a9699cd2", size = 90364, upload-time = "2026-07-20T12:15:34.913Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/26/22/598f988f293ce391e88baddeeaa1acaea625822653193935301e7149b5c6/logfire_api-4.38.0-py3-none-any.whl", hash = "sha256:6c0612d9200727b64b42eba6068d0d6afd45308f4b844ffd6290c2d33aa6a6f6", size = 140109 }, + { url = "https://files.pythonhosted.org/packages/26/22/598f988f293ce391e88baddeeaa1acaea625822653193935301e7149b5c6/logfire_api-4.38.0-py3-none-any.whl", hash = "sha256:6c0612d9200727b64b42eba6068d0d6afd45308f4b844ffd6290c2d33aa6a6f6", size = 140109, upload-time = "2026-07-20T12:15:31.973Z" }, ] [[package]] @@ -2184,9 +2184,9 @@ dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "win32-setctime", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 }, + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] [[package]] @@ -2194,11 +2194,11 @@ name = "macholib" version = "1.16.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "altgraph", marker = "sys_platform != 'win32'" }, + { name = "altgraph" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427 } +sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117 }, + { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, ] [[package]] @@ -2211,12 +2211,12 @@ dependencies = [ { name = "onnxruntime" }, { name = "python-dotenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/8fdd991142ad3e037179a494b153f463024e5a211ef3ad948b955c26b4de/magika-0.6.2.tar.gz", hash = "sha256:37eb6ae8020f6e68f231bc06052c0a0cbe8e6fa27492db345e8dc867dbceb067", size = 3036634 } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/8fdd991142ad3e037179a494b153f463024e5a211ef3ad948b955c26b4de/magika-0.6.2.tar.gz", hash = "sha256:37eb6ae8020f6e68f231bc06052c0a0cbe8e6fa27492db345e8dc867dbceb067", size = 3036634, upload-time = "2025-05-02T14:54:18.88Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/07/4f7748f34279f2852068256992377474f9700b6fbad6735d6be58605178f/magika-0.6.2-py3-none-any.whl", hash = "sha256:5ef72fbc07723029b3684ef81454bc224ac5f60986aa0fc5a28f4456eebcb5b2", size = 2967609 }, - { url = "https://files.pythonhosted.org/packages/64/6d/0783af677e601d8a42258f0fbc47663abf435f927e58a8d2928296743099/magika-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9109309328a1553886c8ff36c2ee9a5e9cfd36893ad81b65bf61a57debdd9d0e", size = 12404787 }, - { url = "https://files.pythonhosted.org/packages/8a/ad/42e39748ddc4bbe55c2dc1093ce29079c04d096ac0d844f8ae66178bc3ed/magika-0.6.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:57cd1d64897634d15de552bd6b3ae9c6ff6ead9c60d384dc46497c08288e4559", size = 15091089 }, - { url = "https://files.pythonhosted.org/packages/b0/1f/28e412d0ccedc068fbccdae6a6233faaa97ec3e5e2ffd242e49655b10064/magika-0.6.2-py3-none-win_amd64.whl", hash = "sha256:711f427a633e0182737dcc2074748004842f870643585813503ff2553b973b9f", size = 12385740 }, + { url = "https://files.pythonhosted.org/packages/c2/07/4f7748f34279f2852068256992377474f9700b6fbad6735d6be58605178f/magika-0.6.2-py3-none-any.whl", hash = "sha256:5ef72fbc07723029b3684ef81454bc224ac5f60986aa0fc5a28f4456eebcb5b2", size = 2967609, upload-time = "2025-05-02T14:54:09.696Z" }, + { url = "https://files.pythonhosted.org/packages/64/6d/0783af677e601d8a42258f0fbc47663abf435f927e58a8d2928296743099/magika-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9109309328a1553886c8ff36c2ee9a5e9cfd36893ad81b65bf61a57debdd9d0e", size = 12404787, upload-time = "2025-05-02T14:54:16.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ad/42e39748ddc4bbe55c2dc1093ce29079c04d096ac0d844f8ae66178bc3ed/magika-0.6.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:57cd1d64897634d15de552bd6b3ae9c6ff6ead9c60d384dc46497c08288e4559", size = 15091089, upload-time = "2025-05-02T14:54:11.59Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1f/28e412d0ccedc068fbccdae6a6233faaa97ec3e5e2ffd242e49655b10064/magika-0.6.2-py3-none-win_amd64.whl", hash = "sha256:711f427a633e0182737dcc2074748004842f870643585813503ff2553b973b9f", size = 12385740, upload-time = "2025-05-02T14:54:14.096Z" }, ] [[package]] @@ -2226,18 +2226,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219 } +sdist = { url = "https://files.pythonhosted.org/packages/00/62/791b31e69ae182791ec67f04850f2f062716bbd205483d63a215f3e062d3/mako-1.3.12.tar.gz", hash = "sha256:9f778e93289bd410bb35daadeb4fc66d95a746f0b75777b942088b7fd7af550a", size = 400219, upload-time = "2026-04-28T19:01:08.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521 }, + { url = "https://files.pythonhosted.org/packages/bc/b1/a0ec7a5a9db730a08daef1fdfb8090435b82465abbf758a596f0ea88727e/mako-1.3.12-py3-none-any.whl", hash = "sha256:8f61569480282dbf557145ce441e4ba888be453c30989f879f0d652e39f53ea9", size = 78521, upload-time = "2026-04-28T19:01:10.393Z" }, ] [[package]] name = "markdown" version = "3.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805 } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180 }, + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, ] [[package]] @@ -2247,18 +2247,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] name = "markdown2" version = "2.5.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/07d4a5fcaa5509221287d289323d75ac8eda5a5a4ac9de2accf7bbcc2b88/markdown2-2.5.5.tar.gz", hash = "sha256:001547e68f6e7fcf0f1cb83f7e82f48aa7d48b2c6a321f0cd20a853a8a2d1664", size = 157249 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/ae/07d4a5fcaa5509221287d289323d75ac8eda5a5a4ac9de2accf7bbcc2b88/markdown2-2.5.5.tar.gz", hash = "sha256:001547e68f6e7fcf0f1cb83f7e82f48aa7d48b2c6a321f0cd20a853a8a2d1664", size = 157249, upload-time = "2026-03-02T20:46:53.411Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/af/4b3891eb0a49d6cfd5cbf3e9bf514c943afc2b0f13e2c57cc57cd88ecc21/markdown2-2.5.5-py3-none-any.whl", hash = "sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941", size = 56250 }, + { url = "https://files.pythonhosted.org/packages/43/af/4b3891eb0a49d6cfd5cbf3e9bf514c943afc2b0f13e2c57cc57cd88ecc21/markdown2-2.5.5-py3-none-any.whl", hash = "sha256:be798587e09d1f52d2e4d96a649c4b82a778c75f9929aad52a2c95747fa26941", size = 56250, upload-time = "2026-03-02T20:46:52.032Z" }, ] [[package]] @@ -2269,9 +2269,9 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/92/ab/d1297139c0e2ceb151ae564c8c4f57ac0155d8f1f8b4cbd5d6523c82ea36/markdownify-1.2.3.tar.gz", hash = "sha256:1a176f05522c8a2cb1dd3ab9d307dcdadbed5c26ae717855bfc42b3b6d38d937", size = 18852 } +sdist = { url = "https://files.pythonhosted.org/packages/92/ab/d1297139c0e2ceb151ae564c8c4f57ac0155d8f1f8b4cbd5d6523c82ea36/markdownify-1.2.3.tar.gz", hash = "sha256:1a176f05522c8a2cb1dd3ab9d307dcdadbed5c26ae717855bfc42b3b6d38d937", size = 18852, upload-time = "2026-06-30T20:27:39.06Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/10/fa543d484e8b1199243fe20eedd02cc5af050edebce98a7293a5773df592/markdownify-1.2.3-py3-none-any.whl", hash = "sha256:a189a0bedfd14009030fde5f85bb6f77c56897cb839b5c25315dd7d4e3e290ba", size = 15732 }, + { url = "https://files.pythonhosted.org/packages/04/10/fa543d484e8b1199243fe20eedd02cc5af050edebce98a7293a5773df592/markdownify-1.2.3-py3-none-any.whl", hash = "sha256:a189a0bedfd14009030fde5f85bb6f77c56897cb839b5c25315dd7d4e3e290ba", size = 15732, upload-time = "2026-06-30T20:27:38.094Z" }, ] [[package]] @@ -2286,39 +2286,39 @@ dependencies = [ { name = "markdownify" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/b7/91fe0e2df07107ab701a15c8ad3213135707e4d6206ae9bd8f457a7ad86a/markitdown-0.1.6.tar.gz", hash = "sha256:e5bdbaffd971b29598c7c39ef0e9afce2f08c0751fbfa4e4257678ebaf8cfc7e", size = 50795 } +sdist = { url = "https://files.pythonhosted.org/packages/18/b7/91fe0e2df07107ab701a15c8ad3213135707e4d6206ae9bd8f457a7ad86a/markitdown-0.1.6.tar.gz", hash = "sha256:e5bdbaffd971b29598c7c39ef0e9afce2f08c0751fbfa4e4257678ebaf8cfc7e", size = 50795, upload-time = "2026-05-26T22:43:59.318Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/30/8031f183ee86ea8ac4e7ea1296bab4cca1bee2fd036a26df69764eb7ca74/markitdown-0.1.6-py3-none-any.whl", hash = "sha256:07b2d5bf87b5c53e13a9f2fdc440df8ccc85e26f40c1e557781727b700049775", size = 70032 }, + { url = "https://files.pythonhosted.org/packages/9e/30/8031f183ee86ea8ac4e7ea1296bab4cca1bee2fd036a26df69764eb7ca74/markitdown-0.1.6-py3-none-any.whl", hash = "sha256:07b2d5bf87b5c53e13a9f2fdc440df8ccc85e26f40c1e557781727b700049775", size = 70032, upload-time = "2026-05-26T22:44:03.209Z" }, ] [[package]] name = "markupsafe" version = "3.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, - { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, - { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, - { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, - { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, - { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, - { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, - { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, - { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, - { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, - { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, - { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, - { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, - { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, - { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, - { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, - { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, - { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, - { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, - { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, - { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, - { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, ] [[package]] @@ -2328,9 +2328,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095 } +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964 }, + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, ] [[package]] @@ -2353,9 +2353,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501 } +sdist = { url = "https://files.pythonhosted.org/packages/6e/77/9450b8f251a13affb6281997d0523c4615f8a8b35d0b21ff30db3a5aac9d/mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683", size = 638501, upload-time = "2026-06-26T12:57:29.093Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620 }, + { url = "https://files.pythonhosted.org/packages/e2/5e/d118fce19f87a2e7d8101c35c8ae0ec289098a4df0ff244cec23e415aca0/mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df", size = 222620, upload-time = "2026-06-26T12:57:27.218Z" }, ] [[package]] @@ -2367,9 +2367,9 @@ dependencies = [ { name = "mcp" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/5e/99422329beb04d4a4b7d150e469e7ae1d85e68d09888e077dee57ff5a509/mcp_run-0.5.0.tar.gz", hash = "sha256:f6270283dcb51328dfd97b984a4d1264d5d04853340d94dd0abaf7b53ad25f75", size = 13366 } +sdist = { url = "https://files.pythonhosted.org/packages/27/5e/99422329beb04d4a4b7d150e469e7ae1d85e68d09888e077dee57ff5a509/mcp_run-0.5.0.tar.gz", hash = "sha256:f6270283dcb51328dfd97b984a4d1264d5d04853340d94dd0abaf7b53ad25f75", size = 13366, upload-time = "2025-05-08T22:12:33.562Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/d4/b4f925a6f57c680e10fc4766af607de62cca8cfd28664a5cd67e3f0351a1/mcp_run-0.5.0-py3-none-any.whl", hash = "sha256:ba30756d7e6feb872e82d08fd0267638b9bd88c2c326bcd6ac5cb51cf59a3969", size = 14038 }, + { url = "https://files.pythonhosted.org/packages/02/d4/b4f925a6f57c680e10fc4766af607de62cca8cfd28664a5cd67e3f0351a1/mcp_run-0.5.0-py3-none-any.whl", hash = "sha256:ba30756d7e6feb872e82d08fd0267638b9bd88c2c326bcd6ac5cb51cf59a3969", size = 14038, upload-time = "2025-05-08T22:12:32.365Z" }, ] [[package]] @@ -2381,9 +2381,9 @@ dependencies = [ { name = "psutil" }, { name = "python-dotenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/86/05715a4edf13f591e6b8f9925e691abbadcaadabb9c1db176355995e510b/mcpx_py-0.7.0.tar.gz", hash = "sha256:f6726b6606debacbc86a6c2d76083179ebc530264d41e4c451db96000f7b3347", size = 8120 } +sdist = { url = "https://files.pythonhosted.org/packages/e0/86/05715a4edf13f591e6b8f9925e691abbadcaadabb9c1db176355995e510b/mcpx_py-0.7.0.tar.gz", hash = "sha256:f6726b6606debacbc86a6c2d76083179ebc530264d41e4c451db96000f7b3347", size = 8120, upload-time = "2025-05-08T22:17:43.698Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/1d/92b95bb02f8e39f0e31d6786edbf60ecfad4acb833a9804861bbef61de39/mcpx_py-0.7.0-py3-none-any.whl", hash = "sha256:72501095019401cffe6821466a8ebf585aa3f82a8e4f6130b31cedc9c61844dc", size = 7175 }, + { url = "https://files.pythonhosted.org/packages/79/1d/92b95bb02f8e39f0e31d6786edbf60ecfad4acb833a9804861bbef61de39/mcpx_py-0.7.0-py3-none-any.whl", hash = "sha256:72501095019401cffe6821466a8ebf585aa3f82a8e4f6130b31cedc9c61844dc", size = 7175, upload-time = "2025-05-08T22:17:42.471Z" }, ] [[package]] @@ -2395,27 +2395,27 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-ai" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/ce/a4587c7f41960eb243aa7298f0ba4eda357cdd20f843640dae2949f053dd/mcpx_pydantic_ai-0.7.0.tar.gz", hash = "sha256:c7d951c96fd8bc1ea2731562d6e1b76c5d274d3b84afc3fc8524be669ce3c81d", size = 2720 } +sdist = { url = "https://files.pythonhosted.org/packages/fd/ce/a4587c7f41960eb243aa7298f0ba4eda357cdd20f843640dae2949f053dd/mcpx_pydantic_ai-0.7.0.tar.gz", hash = "sha256:c7d951c96fd8bc1ea2731562d6e1b76c5d274d3b84afc3fc8524be669ce3c81d", size = 2720, upload-time = "2025-05-08T22:15:45.303Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/d9/cda159c1d06cb4a31bf38027492fcae91d44f026a3a813c9772ba0169106/mcpx_pydantic_ai-0.7.0-py3-none-any.whl", hash = "sha256:e50cc29a4219e33c4eba3737cf0106ded18c748590a2fa79264d97decd6a80bd", size = 2128 }, + { url = "https://files.pythonhosted.org/packages/46/d9/cda159c1d06cb4a31bf38027492fcae91d44f026a3a813c9772ba0169106/mcpx_pydantic_ai-0.7.0-py3-none-any.whl", hash = "sha256:e50cc29a4219e33c4eba3737cf0106ded18c748590a2fa79264d97decd6a80bd", size = 2128, upload-time = "2025-05-08T22:15:44.31Z" }, ] [[package]] name = "mdurl" version = "0.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] [[package]] name = "mergedeep" version = "1.3.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, ] [[package]] @@ -2425,14 +2425,14 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/d5/e5439dc08561f73656bfeb3340fc64ab63163e101426593d8fb9a025ff1e/miniaudio-1.71.tar.gz", hash = "sha256:ff51e2887bb673e2e757752b586b3dc924d59aa5fbcae9bbc45f4a111bd3262b", size = 1116480 } +sdist = { url = "https://files.pythonhosted.org/packages/d8/d5/e5439dc08561f73656bfeb3340fc64ab63163e101426593d8fb9a025ff1e/miniaudio-1.71.tar.gz", hash = "sha256:ff51e2887bb673e2e757752b586b3dc924d59aa5fbcae9bbc45f4a111bd3262b", size = 1116480, upload-time = "2026-04-29T21:20:38.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/85/44545f767ec21142ffed5f9108406d11dc8a19aafed9bd57621a0892bb60/miniaudio-1.71-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:61b86f26d653040db32d9d15b05446321dd10e45beba25b44f841e26935213d5", size = 377184 }, - { url = "https://files.pythonhosted.org/packages/bd/d1/071a560000c8ce903dc919968ecce40fbe7a73213ac399051b887184f8a3/miniaudio-1.71-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d9dc15eff711bcfc62a9d05e0c78e4bc34821a455595e049629f2fea7491a523", size = 351488 }, - { url = "https://files.pythonhosted.org/packages/46/24/5873a569451cae5686fb656ebd78ffe0b5eebe48ca21ef61e227d237d20a/miniaudio-1.71-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12bc33e7e61072b4b541c14e10ef76119d5643e6bbb98e2dec0c0738889438fb", size = 643552 }, - { url = "https://files.pythonhosted.org/packages/90/9b/25785525e6b5ff9afd7f4c4279215dc09c3317ea4d837275b1ae17912b36/miniaudio-1.71-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70fa2ea5353e6919aca59b8c5768144af009d18c3bca251749d66fb497424563", size = 645171 }, - { url = "https://files.pythonhosted.org/packages/b1/6d/cbfd55fdc40256231f7b0c861e2bf79cc289bfbbe5e15869317944e6d673/miniaudio-1.71-cp313-cp313-win32.whl", hash = "sha256:1bf93aeede652926f27f430f0fd69ef0cf8a949c07b537d6a2f295602c747037", size = 235088 }, - { url = "https://files.pythonhosted.org/packages/8d/8d/d5059c04b247b1079c0e48914a9ec20352910a3b9373060fb258dfd194ab/miniaudio-1.71-cp313-cp313-win_amd64.whl", hash = "sha256:4c849ccb1349f7b3553a77a66fe7e972315185f5c4c44a0bbda7ebcdd224db37", size = 274247 }, + { url = "https://files.pythonhosted.org/packages/3a/85/44545f767ec21142ffed5f9108406d11dc8a19aafed9bd57621a0892bb60/miniaudio-1.71-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:61b86f26d653040db32d9d15b05446321dd10e45beba25b44f841e26935213d5", size = 377184, upload-time = "2026-04-29T21:20:24.109Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d1/071a560000c8ce903dc919968ecce40fbe7a73213ac399051b887184f8a3/miniaudio-1.71-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d9dc15eff711bcfc62a9d05e0c78e4bc34821a455595e049629f2fea7491a523", size = 351488, upload-time = "2026-04-29T21:20:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/46/24/5873a569451cae5686fb656ebd78ffe0b5eebe48ca21ef61e227d237d20a/miniaudio-1.71-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12bc33e7e61072b4b541c14e10ef76119d5643e6bbb98e2dec0c0738889438fb", size = 643552, upload-time = "2026-04-29T21:20:26.211Z" }, + { url = "https://files.pythonhosted.org/packages/90/9b/25785525e6b5ff9afd7f4c4279215dc09c3317ea4d837275b1ae17912b36/miniaudio-1.71-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70fa2ea5353e6919aca59b8c5768144af009d18c3bca251749d66fb497424563", size = 645171, upload-time = "2026-04-29T21:20:27.494Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6d/cbfd55fdc40256231f7b0c861e2bf79cc289bfbbe5e15869317944e6d673/miniaudio-1.71-cp313-cp313-win32.whl", hash = "sha256:1bf93aeede652926f27f430f0fd69ef0cf8a949c07b537d6a2f295602c747037", size = 235088, upload-time = "2026-04-29T21:20:29.031Z" }, + { url = "https://files.pythonhosted.org/packages/8d/8d/d5059c04b247b1079c0e48914a9ec20352910a3b9373060fb258dfd194ab/miniaudio-1.71-cp313-cp313-win_amd64.whl", hash = "sha256:4c849ccb1349f7b3553a77a66fe7e972315185f5c4c44a0bbda7ebcdd224db37", size = 274247, upload-time = "2026-04-29T21:20:29.96Z" }, ] [[package]] @@ -2449,9 +2449,9 @@ dependencies = [ { name = "python-dateutil" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/f2/5a2fdcbee80e83674fab398cedbfa97e0b35f316fadd6ff85c37c8054e84/mistralai-2.7.2.tar.gz", hash = "sha256:184b7a502e69cc7cdeb0b095020d7e9407a9544c4496f0317984263dac3d7939", size = 535414 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/f2/5a2fdcbee80e83674fab398cedbfa97e0b35f316fadd6ff85c37c8054e84/mistralai-2.7.2.tar.gz", hash = "sha256:184b7a502e69cc7cdeb0b095020d7e9407a9544c4496f0317984263dac3d7939", size = 535414, upload-time = "2026-07-24T13:06:55.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/40/e2c0cca6da63716a384776a9e348ebcdb693e04a06e3c9db189439d0077c/mistralai-2.7.2-py3-none-any.whl", hash = "sha256:b2b26fa43670d6247ea352c23fe821605ecbbcf305755b629e804dbd3f6fd439", size = 1273387 }, + { url = "https://files.pythonhosted.org/packages/82/40/e2c0cca6da63716a384776a9e348ebcdb693e04a06e3c9db189439d0077c/mistralai-2.7.2-py3-none-any.whl", hash = "sha256:b2b26fa43670d6247ea352c23fe821605ecbbcf305755b629e804dbd3f6fd439", size = 1273387, upload-time = "2026-07-24T13:06:53.649Z" }, ] [[package]] @@ -2473,9 +2473,9 @@ dependencies = [ { name = "pyyaml-env-tag" }, { name = "watchdog" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, ] [[package]] @@ -2487,9 +2487,9 @@ dependencies = [ { name = "markupsafe" }, { name = "mkdocs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588 } +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530 }, + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, ] [[package]] @@ -2501,9 +2501,9 @@ dependencies = [ { name = "platformdirs" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047 } +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555 }, + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, ] [[package]] @@ -2523,18 +2523,18 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923 } +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438 }, + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, ] [[package]] name = "mkdocs-material-extensions" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, ] [[package]] @@ -2549,9 +2549,9 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "pymdown-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649 } +sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787 }, + { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, ] [package.optional-dependencies] @@ -2568,9 +2568,9 @@ dependencies = [ { name = "mkdocs-autorefs" }, { name = "mkdocstrings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951 }, + { url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" }, ] [[package]] @@ -2583,9 +2583,9 @@ dependencies = [ { name = "schemez" }, { name = "upathtools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/2f/7c01c56d2ece11838505baeb7169b1d37593be8dbc9958a665660e401ce1/mkdown-1.0.1.tar.gz", hash = "sha256:3d0591f1ff16d513eefa36e3f9de4e52121e2c5dccc35b583a803e994d9f7125", size = 16373 } +sdist = { url = "https://files.pythonhosted.org/packages/0c/2f/7c01c56d2ece11838505baeb7169b1d37593be8dbc9958a665660e401ce1/mkdown-1.0.1.tar.gz", hash = "sha256:3d0591f1ff16d513eefa36e3f9de4e52121e2c5dccc35b583a803e994d9f7125", size = 16373, upload-time = "2025-10-07T20:15:17.771Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/34/29cac5028652780cb9709944c5244660c30011812ade255cd4d7104402c8/mkdown-1.0.1-py3-none-any.whl", hash = "sha256:5d027f59f333670617e7f500c79377f4c022ec28be4500732f2a5750113da7e9", size = 18086 }, + { url = "https://files.pythonhosted.org/packages/0d/34/29cac5028652780cb9709944c5244660c30011812ade255cd4d7104402c8/mkdown-1.0.1-py3-none-any.whl", hash = "sha256:5d027f59f333670617e7f500c79377f4c022ec28be4500732f2a5750113da7e9", size = 18086, upload-time = "2025-10-07T20:15:16.512Z" }, ] [[package]] @@ -2621,111 +2621,111 @@ dependencies = [ { name = "yamling" }, { name = "zensical" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/4d/0fc0269801c55d3d434b391cd99b518a7e5a59739376b3db23ff6037cd55/mknodes-2.2.14.tar.gz", hash = "sha256:ab572a394121b309c05f572ad96f14e9c6defab5a3f7dfe7e8d95ea8168be4c6", size = 342702 } +sdist = { url = "https://files.pythonhosted.org/packages/12/4d/0fc0269801c55d3d434b391cd99b518a7e5a59739376b3db23ff6037cd55/mknodes-2.2.14.tar.gz", hash = "sha256:ab572a394121b309c05f572ad96f14e9c6defab5a3f7dfe7e8d95ea8168be4c6", size = 342702, upload-time = "2026-03-06T00:53:15.047Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/65/8e0828b326c2b50e95ba5d2dd5e9fe807acf45bdf96482e0d6d09deee8be/mknodes-2.2.14-py3-none-any.whl", hash = "sha256:ce544198ff5fcd29ad6b258877e8cbe76ea86cd9e4fde4fbb3f6afb843681baa", size = 472582 }, + { url = "https://files.pythonhosted.org/packages/83/65/8e0828b326c2b50e95ba5d2dd5e9fe807acf45bdf96482e0d6d09deee8be/mknodes-2.2.14-py3-none-any.whl", hash = "sha256:ce544198ff5fcd29ad6b258877e8cbe76ea86cd9e4fde4fbb3f6afb843681baa", size = 472582, upload-time = "2026-03-06T00:53:11.905Z" }, ] [[package]] name = "mmh3" version = "5.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832 }, - { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964 }, - { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114 }, - { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819 }, - { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330 }, - { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078 }, - { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498 }, - { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033 }, - { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320 }, - { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299 }, - { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222 }, - { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048 }, - { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742 }, - { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132 }, - { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686 }, - { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479 }, - { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030 }, - { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536 }, - { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769 }, - { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563 }, - { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310 }, +sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, + { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, + { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, ] [[package]] name = "more-itertools" version = "11.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772 } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226 }, + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, ] [[package]] name = "msgpack" version = "1.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960 } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151 }, - { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351 }, - { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518 }, - { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405 }, - { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257 }, - { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469 }, - { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802 }, - { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273 }, - { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471 }, - { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274 }, - { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795 }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, ] [[package]] name = "multidict" version = "6.7.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174 }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116 }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524 }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368 }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952 }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317 }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132 }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140 }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277 }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291 }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156 }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742 }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221 }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664 }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490 }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695 }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884 }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122 }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175 }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460 }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930 }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582 }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031 }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596 }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492 }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899 }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970 }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060 }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888 }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554 }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341 }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391 }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422 }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770 }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109 }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573 }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319 }, +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] @@ -2739,16 +2739,16 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104 } +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298 }, - { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768 }, - { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586 }, - { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411 }, - { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790 }, - { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919 }, - { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510 }, - { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292 }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, ] [package.optional-dependencies] @@ -2760,9 +2760,9 @@ faster-cache = [ name = "mypy-extensions" version = "1.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] @@ -2775,9 +2775,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/26/df8082b11169a8aa0169f67a00085c978f05d5f1a9f0e9895d13085e0220/nab_index-0.0.11.tar.gz", hash = "sha256:cdf3d3868c840896bba56bfc180c5ff5e6f6801ab9697def799208e37b4cf23e", size = 35229 } +sdist = { url = "https://files.pythonhosted.org/packages/99/26/df8082b11169a8aa0169f67a00085c978f05d5f1a9f0e9895d13085e0220/nab_index-0.0.11.tar.gz", hash = "sha256:cdf3d3868c840896bba56bfc180c5ff5e6f6801ab9697def799208e37b4cf23e", size = 35229, upload-time = "2026-07-19T21:22:15.143Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/00/0816a27374957bc87468e77b9c5f224a8e6d546939d8d672589f7240785e/nab_index-0.0.11-py3-none-any.whl", hash = "sha256:34c7f73f02a714aa3774767ebbf278e4560d50863fc81252f1aab148bd3061fd", size = 41617 }, + { url = "https://files.pythonhosted.org/packages/fe/00/0816a27374957bc87468e77b9c5f224a8e6d546939d8d672589f7240785e/nab_index-0.0.11-py3-none-any.whl", hash = "sha256:34c7f73f02a714aa3774767ebbf278e4560d50863fc81252f1aab148bd3061fd", size = 41617, upload-time = "2026-07-19T21:22:14.136Z" }, ] [[package]] @@ -2794,9 +2794,9 @@ dependencies = [ { name = "tomli-w" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bf/74/6cc630e82b80e255db1d293b1f053ba25d4985d4d8332ca2efb9fac02f78/nab_python-0.0.11.tar.gz", hash = "sha256:c034a432228150afe99c7280d64d4ef738f6182b9079532bc04a2fce94daa2f2", size = 663230 } +sdist = { url = "https://files.pythonhosted.org/packages/bf/74/6cc630e82b80e255db1d293b1f053ba25d4985d4d8332ca2efb9fac02f78/nab_python-0.0.11.tar.gz", hash = "sha256:c034a432228150afe99c7280d64d4ef738f6182b9079532bc04a2fce94daa2f2", size = 663230, upload-time = "2026-07-19T21:22:23.351Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/3f/79932bac990c85a1cf85fa34db9b2a0b98e9bc467203ad233f982ebeeb76/nab_python-0.0.11-py3-none-any.whl", hash = "sha256:92caea881a68862e941533f516f2cae2347c5f3f911ac4cb6fe73ab16dc4f352", size = 350925 }, + { url = "https://files.pythonhosted.org/packages/3a/3f/79932bac990c85a1cf85fa34db9b2a0b98e9bc467203ad233f982ebeeb76/nab_python-0.0.11-py3-none-any.whl", hash = "sha256:92caea881a68862e941533f516f2cae2347c5f3f911ac4cb6fe73ab16dc4f352", size = 350925, upload-time = "2026-07-19T21:22:21.786Z" }, ] [[package]] @@ -2806,55 +2806,55 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/25/e4/1c802661870ea6e677b3f48f5751970d5ec4218fe867fe09c9563b13e275/nab_resolver-0.0.11.tar.gz", hash = "sha256:9b32dc5b9936964d00c95e5b5e01ce85a9533b2aa95bd4ed96dd793bac61759c", size = 95136 } +sdist = { url = "https://files.pythonhosted.org/packages/25/e4/1c802661870ea6e677b3f48f5751970d5ec4218fe867fe09c9563b13e275/nab_resolver-0.0.11.tar.gz", hash = "sha256:9b32dc5b9936964d00c95e5b5e01ce85a9533b2aa95bd4ed96dd793bac61759c", size = 95136, upload-time = "2026-07-19T21:22:07.389Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/cb/e49eed4c8b31daa70b2bb7ab98a56738611fc2e7a82031513144a4eebe5a/nab_resolver-0.0.11-py3-none-any.whl", hash = "sha256:557862e8414658516ae3b3ae4f660dd40dbe13b9c5a16becaed49916bcdc339d", size = 34627 }, + { url = "https://files.pythonhosted.org/packages/17/cb/e49eed4c8b31daa70b2bb7ab98a56738611fc2e7a82031513144a4eebe5a/nab_resolver-0.0.11-py3-none-any.whl", hash = "sha256:557862e8414658516ae3b3ae4f660dd40dbe13b9c5a16becaed49916bcdc339d", size = 34627, upload-time = "2026-07-19T21:22:06.183Z" }, ] [[package]] name = "natsort" version = "8.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575 } +sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268 }, + { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, ] [[package]] name = "nest-asyncio" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 }, + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] [[package]] name = "numpy" version = "2.5.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553 } +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419 }, - { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832 }, - { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143 }, - { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749 }, - { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716 }, - { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440 }, - { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305 }, - { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008 }, - { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885 }, - { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674 }, - { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256 }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, ] [[package]] name = "oauthlib" version = "3.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 }, + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] [[package]] @@ -2868,13 +2868,13 @@ dependencies = [ { name = "protobuf" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/2b/54208fd03ad410480bc17edf4869376362da8bbf46fe186ddf4cb5cc20fe/onnxruntime-1.27.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:b3e5b58b8c89c2b20e086e890aa9527377e5c240dc3ecc1640d18e07705eeb1c", size = 18432958 }, - { url = "https://files.pythonhosted.org/packages/ce/88/24fc51fcbb126da6d032372314e47b55c3faad58f2aa78c0e199ccd20b9c/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b3d87eb560ff6a772240506f3c78d6d27c63cafedd5c775672e1194f968cfd", size = 16438180 }, - { url = "https://files.pythonhosted.org/packages/cb/19/14929c3c2fe0b79b41cce24463062bf3afa4cdd3c19dccf00319caa92bff/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6872443f236a554921cda6f318c900e2d0c226792cf3534d00e5057c6926e5d2", size = 18658445 }, - { url = "https://files.pythonhosted.org/packages/7f/76/59ed932b0244acd7bbbd6449480053a6d958ea66357f022f932872e19287/onnxruntime-1.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:760021bca514d64a811837820d351a08a41741f16f8b4c26450da708fecf14e6", size = 13357856 }, - { url = "https://files.pythonhosted.org/packages/79/51/d1ec60ec7b1e2ae2d7340ba52b8a13529140039cd4407ba8dddbbc046582/onnxruntime-1.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:2fdfa9df40a0ded0028ce6f9cd863264237f3970559dea2b81456e9ac4622b94", size = 13104412 }, - { url = "https://files.pythonhosted.org/packages/5e/7d/e6bb1c6445c94f708c38cd8fbb7bf0264108c33498b9445c93e60fe6d329/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54c0c4e9202c36c4ecdb1f3443f5dfbfd5ee3b54d1362c4b4c6134110e74fb32", size = 16443331 }, - { url = "https://files.pythonhosted.org/packages/72/1b/b18b31e806eabc41077810199fbbb36fbc2d5f19912416e5ccfbf73053d1/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b215aa662c8f983f7d6dedafe65a9be72c26e5338e0fe98b3e0422c32c85428", size = 18670967 }, + { url = "https://files.pythonhosted.org/packages/fb/2b/54208fd03ad410480bc17edf4869376362da8bbf46fe186ddf4cb5cc20fe/onnxruntime-1.27.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:b3e5b58b8c89c2b20e086e890aa9527377e5c240dc3ecc1640d18e07705eeb1c", size = 18432958, upload-time = "2026-06-15T22:42:53.105Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/24fc51fcbb126da6d032372314e47b55c3faad58f2aa78c0e199ccd20b9c/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b3d87eb560ff6a772240506f3c78d6d27c63cafedd5c775672e1194f968cfd", size = 16438180, upload-time = "2026-06-15T22:42:43.093Z" }, + { url = "https://files.pythonhosted.org/packages/cb/19/14929c3c2fe0b79b41cce24463062bf3afa4cdd3c19dccf00319caa92bff/onnxruntime-1.27.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6872443f236a554921cda6f318c900e2d0c226792cf3534d00e5057c6926e5d2", size = 18658445, upload-time = "2026-06-15T22:43:08.053Z" }, + { url = "https://files.pythonhosted.org/packages/7f/76/59ed932b0244acd7bbbd6449480053a6d958ea66357f022f932872e19287/onnxruntime-1.27.0-cp313-cp313-win_amd64.whl", hash = "sha256:760021bca514d64a811837820d351a08a41741f16f8b4c26450da708fecf14e6", size = 13357856, upload-time = "2026-06-15T22:43:37.315Z" }, + { url = "https://files.pythonhosted.org/packages/79/51/d1ec60ec7b1e2ae2d7340ba52b8a13529140039cd4407ba8dddbbc046582/onnxruntime-1.27.0-cp313-cp313-win_arm64.whl", hash = "sha256:2fdfa9df40a0ded0028ce6f9cd863264237f3970559dea2b81456e9ac4622b94", size = 13104412, upload-time = "2026-06-15T22:43:27.457Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7d/e6bb1c6445c94f708c38cd8fbb7bf0264108c33498b9445c93e60fe6d329/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54c0c4e9202c36c4ecdb1f3443f5dfbfd5ee3b54d1362c4b4c6134110e74fb32", size = 16443331, upload-time = "2026-06-15T22:42:45.649Z" }, + { url = "https://files.pythonhosted.org/packages/72/1b/b18b31e806eabc41077810199fbbb36fbc2d5f19912416e5ccfbf73053d1/onnxruntime-1.27.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b215aa662c8f983f7d6dedafe65a9be72c26e5338e0fe98b3e0422c32c85428", size = 18670967, upload-time = "2026-06-15T22:43:10.621Z" }, ] [[package]] @@ -2891,9 +2891,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/d4d1835488c0350424009dac5095b9a3e173bee12fd2e421ee27e2142c42/openai-2.48.0.tar.gz", hash = "sha256:231b1e7661dda14574986c2f71451e9d584b7fe69e0ee6480e12ed090b48fc16", size = 1093427 } +sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/d4d1835488c0350424009dac5095b9a3e173bee12fd2e421ee27e2142c42/openai-2.48.0.tar.gz", hash = "sha256:231b1e7661dda14574986c2f71451e9d584b7fe69e0ee6480e12ed090b48fc16", size = 1093427, upload-time = "2026-07-23T20:15:50.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/2a/dcb891114e303c4379d4a498f10222e33eee540bcef4e1e493bd0af2b242/openai-2.48.0-py3-none-any.whl", hash = "sha256:c98df30aaaf93c51979f64d3e7c5b76464f8be0173368266229eb8fe6bd30f2c", size = 1648520 }, + { url = "https://files.pythonhosted.org/packages/b5/2a/dcb891114e303c4379d4a498f10222e33eee540bcef4e1e493bd0af2b242/openai-2.48.0-py3-none-any.whl", hash = "sha256:c98df30aaaf93c51979f64d3e7c5b76464f8be0173368266229eb8fe6bd30f2c", size = 1648520, upload-time = "2026-07-23T20:15:48.052Z" }, ] [[package]] @@ -2903,9 +2903,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892 } +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381 }, + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, ] [[package]] @@ -2920,9 +2920,9 @@ dependencies = [ { name = "referencing" }, { name = "rfc3339-validator" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/e8/ab3f27dbca54ec645f7fab714b640907d5d36c2ebb07e87eebd30bd5c81b/openapi_schema_validator-0.9.0.tar.gz", hash = "sha256:b72db64315b89d21834cd3ffef37e3e6893bc876327be2d366e8424b1029afd3", size = 24686 } +sdist = { url = "https://files.pythonhosted.org/packages/98/e8/ab3f27dbca54ec645f7fab714b640907d5d36c2ebb07e87eebd30bd5c81b/openapi_schema_validator-0.9.0.tar.gz", hash = "sha256:b72db64315b89d21834cd3ffef37e3e6893bc876327be2d366e8424b1029afd3", size = 24686, upload-time = "2026-04-27T17:31:27.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/c0/5467967d95378b2cfce312e09cbd0c9ab64354a0922379b734f793edd04f/openapi_schema_validator-0.9.0-py3-none-any.whl", hash = "sha256:faa3bbe7c3aa8ca2087ad83f709dc3b7d920283153a570c03e24ea182558aa25", size = 19980 }, + { url = "https://files.pythonhosted.org/packages/90/c0/5467967d95378b2cfce312e09cbd0c9ab64354a0922379b734f793edd04f/openapi_schema_validator-0.9.0-py3-none-any.whl", hash = "sha256:faa3bbe7c3aa8ca2087ad83f709dc3b7d920283153a570c03e24ea182558aa25", size = 19980, upload-time = "2026-04-27T17:31:25.965Z" }, ] [[package]] @@ -2937,9 +2937,9 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/d2/640b5149cd5688bc0ad1fdbb4df6a2f7b84a093c8d787c27d566132f8b8b/openapi_spec_validator-0.9.0.tar.gz", hash = "sha256:6d648cff6490ebb799dcfe273792f2941c050158854c721f086599d845da78b8", size = 1756839 } +sdist = { url = "https://files.pythonhosted.org/packages/8f/d2/640b5149cd5688bc0ad1fdbb4df6a2f7b84a093c8d787c27d566132f8b8b/openapi_spec_validator-0.9.0.tar.gz", hash = "sha256:6d648cff6490ebb799dcfe273792f2941c050158854c721f086599d845da78b8", size = 1756839, upload-time = "2026-05-20T09:23:18.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/d8/321ff889330acca2e3097f3d4f80a40bcc41b6d34d302978ab32c449520b/openapi_spec_validator-0.9.0-py3-none-any.whl", hash = "sha256:222fecffc7714f6d0a6ad62c0e4b66cc2b7dbfafb7b93acfc6c308abbdb51af8", size = 50328 }, + { url = "https://files.pythonhosted.org/packages/95/d8/321ff889330acca2e3097f3d4f80a40bcc41b6d34d302978ab32c449520b/openapi_spec_validator-0.9.0-py3-none-any.whl", hash = "sha256:222fecffc7714f6d0a6ad62c0e4b66cc2b7dbfafb7b93acfc6c308abbdb51af8", size = 50328, upload-time = "2026-05-20T09:23:17.017Z" }, ] [[package]] @@ -2950,9 +2950,9 @@ dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767 } +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767, upload-time = "2025-12-11T13:32:39.182Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356 }, + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] [[package]] @@ -2962,9 +2962,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409 } +sdist = { url = "https://files.pythonhosted.org/packages/e9/9d/22d241b66f7bbde88a3bfa6847a351d2c46b84de23e71222c6aae25c7050/opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464", size = 20409, upload-time = "2025-12-11T13:32:40.885Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366 }, + { url = "https://files.pythonhosted.org/packages/8c/02/ffc3e143d89a27ac21fd557365b98bd0653b98de8a101151d5805b5d4c33/opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde", size = 18366, upload-time = "2025-12-11T13:32:20.2Z" }, ] [[package]] @@ -2980,9 +2980,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288 } +sdist = { url = "https://files.pythonhosted.org/packages/80/04/2a08fa9c0214ae38880df01e8bfae12b067ec0793446578575e5080d6545/opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb", size = 17288, upload-time = "2025-12-11T13:32:42.029Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641 }, + { url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" }, ] [[package]] @@ -2995,9 +2995,9 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706 } +sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096 }, + { url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" }, ] [[package]] @@ -3011,9 +3011,9 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/db/851fa88db7441da82d50bd80f2de5ee55213782e25dc858e04d0c9961d60/opentelemetry_instrumentation_asgi-0.60b1.tar.gz", hash = "sha256:16bfbe595cd24cda309a957456d0fc2523f41bc7b076d1f2d7e98a1ad9876d6f", size = 26107 } +sdist = { url = "https://files.pythonhosted.org/packages/77/db/851fa88db7441da82d50bd80f2de5ee55213782e25dc858e04d0c9961d60/opentelemetry_instrumentation_asgi-0.60b1.tar.gz", hash = "sha256:16bfbe595cd24cda309a957456d0fc2523f41bc7b076d1f2d7e98a1ad9876d6f", size = 26107, upload-time = "2025-12-11T13:36:47.015Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/76/76/1fb94367cef64420d2171157a6b9509582873bd09a6afe08a78a8d1f59d9/opentelemetry_instrumentation_asgi-0.60b1-py3-none-any.whl", hash = "sha256:d48def2dbed10294c99cfcf41ebbd0c414d390a11773a41f472d20000fcddc25", size = 16933 }, + { url = "https://files.pythonhosted.org/packages/76/76/1fb94367cef64420d2171157a6b9509582873bd09a6afe08a78a8d1f59d9/opentelemetry_instrumentation_asgi-0.60b1-py3-none-any.whl", hash = "sha256:d48def2dbed10294c99cfcf41ebbd0c414d390a11773a41f472d20000fcddc25", size = 16933, upload-time = "2025-12-11T13:35:40.462Z" }, ] [[package]] @@ -3027,9 +3027,9 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/e7/e7e5e50218cf488377209d85666b182fa2d4928bf52389411ceeee1b2b60/opentelemetry_instrumentation_fastapi-0.60b1.tar.gz", hash = "sha256:de608955f7ff8eecf35d056578346a5365015fd7d8623df9b1f08d1c74769c01", size = 24958 } +sdist = { url = "https://files.pythonhosted.org/packages/9c/e7/e7e5e50218cf488377209d85666b182fa2d4928bf52389411ceeee1b2b60/opentelemetry_instrumentation_fastapi-0.60b1.tar.gz", hash = "sha256:de608955f7ff8eecf35d056578346a5365015fd7d8623df9b1f08d1c74769c01", size = 24958, upload-time = "2025-12-11T13:36:59.35Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/cc/6e808328ba54662e50babdcab21138eae4250bc0fddf67d55526a615a2ca/opentelemetry_instrumentation_fastapi-0.60b1-py3-none-any.whl", hash = "sha256:af94b7a239ad1085fc3a820ecf069f67f579d7faf4c085aaa7bd9b64eafc8eaf", size = 13478 }, + { url = "https://files.pythonhosted.org/packages/7d/cc/6e808328ba54662e50babdcab21138eae4250bc0fddf67d55526a615a2ca/opentelemetry_instrumentation_fastapi-0.60b1-py3-none-any.whl", hash = "sha256:af94b7a239ad1085fc3a820ecf069f67f579d7faf4c085aaa7bd9b64eafc8eaf", size = 13478, upload-time = "2025-12-11T13:36:00.811Z" }, ] [[package]] @@ -3043,9 +3043,9 @@ dependencies = [ { name = "opentelemetry-util-http" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/08/11208bcfcab4fc2023252c3f322aa397fd9ad948355fea60f5fc98648603/opentelemetry_instrumentation_httpx-0.60b1.tar.gz", hash = "sha256:a506ebaf28c60112cbe70ad4f0338f8603f148938cb7b6794ce1051cd2b270ae", size = 20611 } +sdist = { url = "https://files.pythonhosted.org/packages/86/08/11208bcfcab4fc2023252c3f322aa397fd9ad948355fea60f5fc98648603/opentelemetry_instrumentation_httpx-0.60b1.tar.gz", hash = "sha256:a506ebaf28c60112cbe70ad4f0338f8603f148938cb7b6794ce1051cd2b270ae", size = 20611, upload-time = "2025-12-11T13:37:01.661Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/59/b98e84eebf745ffc75397eaad4763795bff8a30cbf2373a50ed4e70646c5/opentelemetry_instrumentation_httpx-0.60b1-py3-none-any.whl", hash = "sha256:f37636dd742ad2af83d896ba69601ed28da51fa4e25d1ab62fde89ce413e275b", size = 15701 }, + { url = "https://files.pythonhosted.org/packages/43/59/b98e84eebf745ffc75397eaad4763795bff8a30cbf2373a50ed4e70646c5/opentelemetry_instrumentation_httpx-0.60b1-py3-none-any.whl", hash = "sha256:f37636dd742ad2af83d896ba69601ed28da51fa4e25d1ab62fde89ce413e275b", size = 15701, upload-time = "2025-12-11T13:36:04.56Z" }, ] [[package]] @@ -3055,9 +3055,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152 } +sdist = { url = "https://files.pythonhosted.org/packages/49/1d/f25d76d8260c156c40c97c9ed4511ec0f9ce353f8108ca6e7561f82a06b2/opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8", size = 46152, upload-time = "2025-12-11T13:32:48.681Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535 }, + { url = "https://files.pythonhosted.org/packages/51/95/b40c96a7b5203005a0b03d8ce8cd212ff23f1793d5ba289c87a097571b18/opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007", size = 72535, upload-time = "2025-12-11T13:32:33.866Z" }, ] [[package]] @@ -3069,9 +3069,9 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/fb/c76080c9ba07e1e8235d24cdcc4d125ef7aa3edf23eb4e497c2e50889adc/opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6", size = 171460, upload-time = "2025-12-11T13:32:49.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565 }, + { url = "https://files.pythonhosted.org/packages/7c/98/e91cf858f203d86f4eccdf763dcf01cf03f1dae80c3750f7e635bfa206b6/opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c", size = 132565, upload-time = "2025-12-11T13:32:35.069Z" }, ] [[package]] @@ -3082,136 +3082,136 @@ dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935 } +sdist = { url = "https://files.pythonhosted.org/packages/91/df/553f93ed38bf22f4b999d9be9c185adb558982214f33eae539d3b5cd0858/opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953", size = 137935, upload-time = "2025-12-11T13:32:50.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982 }, + { url = "https://files.pythonhosted.org/packages/7a/5e/5958555e09635d09b75de3c4f8b9cae7335ca545d77392ffe7331534c402/opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb", size = 219982, upload-time = "2025-12-11T13:32:36.955Z" }, ] [[package]] name = "opentelemetry-util-http" version = "0.60b1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/fc/c47bb04a1d8a941a4061307e1eddfa331ed4d0ab13d8a9781e6db256940a/opentelemetry_util_http-0.60b1.tar.gz", hash = "sha256:0d97152ca8c8a41ced7172d29d3622a219317f74ae6bb3027cfbdcf22c3cc0d6", size = 11053 } +sdist = { url = "https://files.pythonhosted.org/packages/50/fc/c47bb04a1d8a941a4061307e1eddfa331ed4d0ab13d8a9781e6db256940a/opentelemetry_util_http-0.60b1.tar.gz", hash = "sha256:0d97152ca8c8a41ced7172d29d3622a219317f74ae6bb3027cfbdcf22c3cc0d6", size = 11053, upload-time = "2025-12-11T13:37:25.115Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947 }, + { url = "https://files.pythonhosted.org/packages/16/5c/d3f1733665f7cd582ef0842fb1d2ed0bc1fba10875160593342d22bba375/opentelemetry_util_http-0.60b1-py3-none-any.whl", hash = "sha256:66381ba28550c91bee14dcba8979ace443444af1ed609226634596b4b0faf199", size = 8947, upload-time = "2025-12-11T13:36:37.151Z" }, ] [[package]] name = "openviking-sdk" -version = "0.1.7" +version = "0.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c2/4b/cbd1a866deb35f9b8986904260d8fb99d769e7f47b91bbb2ca85b8e36341/openviking_sdk-0.1.7.tar.gz", hash = "sha256:90b1c3025c9b1192e549421b8d181855bb722e57e14f785a1a31a5eec4151bed", size = 43934 } +sdist = { url = "https://files.pythonhosted.org/packages/c2/76/6ce0a05cc11cd55191754773bbf9e48fee26467f5f2d2332981cc1c979ea/openviking_sdk-0.1.8.tar.gz", hash = "sha256:387776ce6da89b8ac2146f7cc51d4431a3f13317080bc34d736963296697ecab", size = 45598, upload-time = "2026-08-17T07:42:46.887Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/15/78fe7434da25b3327cea1e7682cb20a8532e9a94faf33832b5560ae7d8f6/openviking_sdk-0.1.7-py3-none-any.whl", hash = "sha256:f4bc373ce2f4ebf93216b3fbc8c33b8e3c39e852e451ade1b95b102886652847", size = 26350 }, + { url = "https://files.pythonhosted.org/packages/14/4b/70125f8f773b1bbf3977986d409f0533ed3f086e37cfe2ec558d2256e42f/openviking_sdk-0.1.8-py3-none-any.whl", hash = "sha256:9735fdd5425203124fa6e27bbe114f850bde9b36d4d132d68adf76556b90107c", size = 27129, upload-time = "2026-08-17T07:42:45.554Z" }, ] [[package]] name = "orjson" version = "3.11.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163 } +sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458 }, - { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368 }, - { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070 }, - { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892 }, - { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217 }, - { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980 }, - { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738 }, - { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033 }, - { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492 }, - { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087 }, - { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031 }, - { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915 }, - { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613 }, - { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086 }, - { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696 }, + { url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" }, + { url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" }, + { url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" }, + { url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" }, + { url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" }, + { url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" }, + { url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" }, + { url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" }, + { url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" }, + { url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" }, ] [[package]] name = "packaging" version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] name = "paginate" version = "0.5.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, ] [[package]] name = "pathable" version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970 } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983 }, + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, ] [[package]] name = "pathlib-abc" version = "0.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/cb/448649d7f25d228bf0be3a04590ab7afa77f15e056f8fa976ed05ec9a78f/pathlib_abc-0.5.2.tar.gz", hash = "sha256:fcd56f147234645e2c59c7ae22808b34c364bb231f685ddd9f96885aed78a94c", size = 33342 } +sdist = { url = "https://files.pythonhosted.org/packages/d6/cb/448649d7f25d228bf0be3a04590ab7afa77f15e056f8fa976ed05ec9a78f/pathlib_abc-0.5.2.tar.gz", hash = "sha256:fcd56f147234645e2c59c7ae22808b34c364bb231f685ddd9f96885aed78a94c", size = 33342, upload-time = "2025-10-10T18:37:20.524Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/29/c028a0731e202035f0e2e0bfbf1a3e46ad6c628cbb17f6f1cc9eea5d9ff1/pathlib_abc-0.5.2-py3-none-any.whl", hash = "sha256:4c9d94cf1b23af417ce7c0417b43333b06a106c01000b286c99de230d95eefbb", size = 19070 }, + { url = "https://files.pythonhosted.org/packages/b1/29/c028a0731e202035f0e2e0bfbf1a3e46ad6c628cbb17f6f1cc9eea5d9ff1/pathlib_abc-0.5.2-py3-none-any.whl", hash = "sha256:4c9d94cf1b23af417ce7c0417b43333b06a106c01000b286c99de230d95eefbb", size = 19070, upload-time = "2025-10-10T18:37:19.437Z" }, ] [[package]] name = "pathspec" version = "1.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180 } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] name = "pathvalidate" version = "3.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262 } +sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305 }, + { url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" }, ] [[package]] name = "pefile" version = "2024.8.26" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008 } +sdist = { url = "https://files.pythonhosted.org/packages/03/4f/2750f7f6f025a1507cd3b7218691671eecfd0bbebebe8b39aa0fe1d360b8/pefile-2024.8.26.tar.gz", hash = "sha256:3ff6c5d8b43e8c37bb6e6dd5085658d658a7a0bdcd20b6a07b1fcfc1c4e9d632", size = 76008, upload-time = "2024-08-26T20:58:38.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766 }, + { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, ] [[package]] name = "pillow" version = "12.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035 } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684 }, - { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487 }, - { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433 }, - { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889 }, - { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109 }, - { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736 }, - { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129 }, - { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562 }, - { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439 }, - { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287 }, - { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691 }, - { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185 }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, ] [[package]] @@ -3222,57 +3222,57 @@ dependencies = [ { name = "nab-index" }, { name = "nab-python" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/68/8b0bca63d885a34bc7f8bcbc001bb9ffdc937fdada1d01d09f9af7785250/pipdeptree-4.1.0.tar.gz", hash = "sha256:6eb7e671ffa06ef597d683680d483a4882a86a2a00494971733bd88a3404c44f", size = 135159 } +sdist = { url = "https://files.pythonhosted.org/packages/06/68/8b0bca63d885a34bc7f8bcbc001bb9ffdc937fdada1d01d09f9af7785250/pipdeptree-4.1.0.tar.gz", hash = "sha256:6eb7e671ffa06ef597d683680d483a4882a86a2a00494971733bd88a3404c44f", size = 135159, upload-time = "2026-07-21T13:27:29.357Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/81/c1faac2e66ea180149158082e0ecaaac9e21362f23e179d145ed24cb1b12/pipdeptree-4.1.0-cp310-abi3-macosx_10_15_x86_64.whl", hash = "sha256:dd43b6af5990fd2d22c9b2d8b51f0fcf75a9019b79c950d62a12629c0c796306", size = 1645717 }, - { url = "https://files.pythonhosted.org/packages/f0/60/bd1bfd035dc60b8b1e699da7210c11d23f55e14d5e110169c84dc3c42f88/pipdeptree-4.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:83a812e9fc901e511209b12cc358b73ba9372af0123901f523407c6d6d46f3ca", size = 1536600 }, - { url = "https://files.pythonhosted.org/packages/57/b4/5c99186ba900cd5fcabea5ceb15dfb54ba5f0f5c4ac36c8ec50bba39bdb6/pipdeptree-4.1.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4ac5c99e5305c916c9276689c4a2a00156bf3e72fb6d74da4c7cf9e908034e79", size = 1758348 }, - { url = "https://files.pythonhosted.org/packages/a8/c2/3e7d6554759990dab54ad0ce4312534b0ebb0d0a3db9f171f6445298734e/pipdeptree-4.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0b48a42f22d1588feab62ce50f65d20dc9d6f8daf70105e5f27fc0dfc553b13c", size = 1839269 }, - { url = "https://files.pythonhosted.org/packages/c1/07/5d1835791e82699df0383bc432f4c721e95a8aa40fd3e5b3a0d720d551aa/pipdeptree-4.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:24a011ed94b4fb3e7a3395e2a705f5b82661263148609704e871a4b820e07cba", size = 1674697 }, + { url = "https://files.pythonhosted.org/packages/66/81/c1faac2e66ea180149158082e0ecaaac9e21362f23e179d145ed24cb1b12/pipdeptree-4.1.0-cp310-abi3-macosx_10_15_x86_64.whl", hash = "sha256:dd43b6af5990fd2d22c9b2d8b51f0fcf75a9019b79c950d62a12629c0c796306", size = 1645717, upload-time = "2026-07-21T13:27:06.442Z" }, + { url = "https://files.pythonhosted.org/packages/f0/60/bd1bfd035dc60b8b1e699da7210c11d23f55e14d5e110169c84dc3c42f88/pipdeptree-4.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:83a812e9fc901e511209b12cc358b73ba9372af0123901f523407c6d6d46f3ca", size = 1536600, upload-time = "2026-07-21T13:27:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/57/b4/5c99186ba900cd5fcabea5ceb15dfb54ba5f0f5c4ac36c8ec50bba39bdb6/pipdeptree-4.1.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4ac5c99e5305c916c9276689c4a2a00156bf3e72fb6d74da4c7cf9e908034e79", size = 1758348, upload-time = "2026-07-21T13:27:09.866Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c2/3e7d6554759990dab54ad0ce4312534b0ebb0d0a3db9f171f6445298734e/pipdeptree-4.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0b48a42f22d1588feab62ce50f65d20dc9d6f8daf70105e5f27fc0dfc553b13c", size = 1839269, upload-time = "2026-07-21T13:27:11.329Z" }, + { url = "https://files.pythonhosted.org/packages/c1/07/5d1835791e82699df0383bc432f4c721e95a8aa40fd3e5b3a0d720d551aa/pipdeptree-4.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:24a011ed94b4fb3e7a3395e2a705f5b82661263148609704e871a4b820e07cba", size = 1674697, upload-time = "2026-07-21T13:27:12.761Z" }, ] [[package]] name = "platformdirs" version = "4.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953 } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247 }, + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, ] [[package]] name = "pluggy" version = "1.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] [[package]] name = "polyleven" version = "0.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/91/c7/e0b3bbe72e0003e5d02726e0d406ea47d523a2aec9c41d831817a8e0bce1/polyleven-0.11.0.tar.gz", hash = "sha256:d74d348387cf340051711c0dd6af993b4c264daa78470098de16f4a2b725785c", size = 6407 } +sdist = { url = "https://files.pythonhosted.org/packages/91/c7/e0b3bbe72e0003e5d02726e0d406ea47d523a2aec9c41d831817a8e0bce1/polyleven-0.11.0.tar.gz", hash = "sha256:d74d348387cf340051711c0dd6af993b4c264daa78470098de16f4a2b725785c", size = 6407, upload-time = "2026-02-09T09:41:49.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/16/5aec69609adc373f10087eb69b0b9d177ae721632715a86348b429030514/polyleven-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cb8ed97b536f9aada3ad45169ee7768c426498bf3fa608a4eabd055dfef795e", size = 7425 }, - { url = "https://files.pythonhosted.org/packages/bd/5b/0542c723aa83833a5090114bc4e5a8e60293873fe60ee8221a5888d87370/polyleven-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f975ab8cb81fd8eb5a647a3cefb0bb80bc307920a9307f66ab4019d88370ed2", size = 7505 }, - { url = "https://files.pythonhosted.org/packages/4a/8d/c317217734a5bd2011f1128c1a9056477a5148d8d95527fcab2fe3955876/polyleven-0.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16986fd58911d6075b5f63ea001197141145b7a6df48bc4ce4530e79227e74a2", size = 21035 }, - { url = "https://files.pythonhosted.org/packages/5f/8f/8a3e6e4a68dbd9de564fd3d16eee90e3f807a4380fd7192f40af4be47175/polyleven-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6a814629cc0468f9800b1333414a3be08fda9c5ce6b63e97154a9d21732e590", size = 21509 }, - { url = "https://files.pythonhosted.org/packages/6b/da/4097998bea845f0b3a67112200aa08c19d4da0a17d761b35484d695c21e2/polyleven-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88a35ec93ec3d81a7347fd49db314a914798a144dca3d22946d18bba9b597dec", size = 20536 }, - { url = "https://files.pythonhosted.org/packages/a1/71/67b7679ede99589ec749290d938693b87cdb6bb327b062c46d2129a5e6ec/polyleven-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:50bb7d68b790194d552ee1256a02e205486b27eb22ab333eeb0003e0271c4846", size = 20775 }, - { url = "https://files.pythonhosted.org/packages/e9/3e/6f7fad4fee748ba365cb3e1ba2e061a74e18d987eb554ead4757127df2ab/polyleven-0.11.0-cp313-cp313-win32.whl", hash = "sha256:ce264f6a9daa3265299d8ffcb180d8256517a8d9235613a3b267172da0bc1e06", size = 11629 }, - { url = "https://files.pythonhosted.org/packages/2f/cc/4877913dec8fb4f968a070c894254db5811b62128d3a69b05bcd1305b5c3/polyleven-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:4648732c8ad3955c8d7b1aa015d92936a150475aaa97ce704fe0c8e7fa7e0c4f", size = 10841 }, - { url = "https://files.pythonhosted.org/packages/59/e2/039cc477ce73d6184e12cf6341ac200bc9f4c5428254c399015ec30392e1/polyleven-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:166f6c9b161c6af92ff201c734d6437bc7ef74a32dab306c5d47a0bdb7a82d9f", size = 9424 }, + { url = "https://files.pythonhosted.org/packages/ce/16/5aec69609adc373f10087eb69b0b9d177ae721632715a86348b429030514/polyleven-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cb8ed97b536f9aada3ad45169ee7768c426498bf3fa608a4eabd055dfef795e", size = 7425, upload-time = "2026-02-09T09:41:06.542Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5b/0542c723aa83833a5090114bc4e5a8e60293873fe60ee8221a5888d87370/polyleven-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f975ab8cb81fd8eb5a647a3cefb0bb80bc307920a9307f66ab4019d88370ed2", size = 7505, upload-time = "2026-02-09T09:41:07.445Z" }, + { url = "https://files.pythonhosted.org/packages/4a/8d/c317217734a5bd2011f1128c1a9056477a5148d8d95527fcab2fe3955876/polyleven-0.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16986fd58911d6075b5f63ea001197141145b7a6df48bc4ce4530e79227e74a2", size = 21035, upload-time = "2026-02-09T09:41:08.32Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/8a3e6e4a68dbd9de564fd3d16eee90e3f807a4380fd7192f40af4be47175/polyleven-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6a814629cc0468f9800b1333414a3be08fda9c5ce6b63e97154a9d21732e590", size = 21509, upload-time = "2026-02-09T09:41:09.285Z" }, + { url = "https://files.pythonhosted.org/packages/6b/da/4097998bea845f0b3a67112200aa08c19d4da0a17d761b35484d695c21e2/polyleven-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:88a35ec93ec3d81a7347fd49db314a914798a144dca3d22946d18bba9b597dec", size = 20536, upload-time = "2026-02-09T09:41:10.211Z" }, + { url = "https://files.pythonhosted.org/packages/a1/71/67b7679ede99589ec749290d938693b87cdb6bb327b062c46d2129a5e6ec/polyleven-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:50bb7d68b790194d552ee1256a02e205486b27eb22ab333eeb0003e0271c4846", size = 20775, upload-time = "2026-02-09T09:41:11.692Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3e/6f7fad4fee748ba365cb3e1ba2e061a74e18d987eb554ead4757127df2ab/polyleven-0.11.0-cp313-cp313-win32.whl", hash = "sha256:ce264f6a9daa3265299d8ffcb180d8256517a8d9235613a3b267172da0bc1e06", size = 11629, upload-time = "2026-02-09T09:41:12.652Z" }, + { url = "https://files.pythonhosted.org/packages/2f/cc/4877913dec8fb4f968a070c894254db5811b62128d3a69b05bcd1305b5c3/polyleven-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:4648732c8ad3955c8d7b1aa015d92936a150475aaa97ce704fe0c8e7fa7e0c4f", size = 10841, upload-time = "2026-02-09T09:41:13.682Z" }, + { url = "https://files.pythonhosted.org/packages/59/e2/039cc477ce73d6184e12cf6341ac200bc9f4c5428254c399015ec30392e1/polyleven-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:166f6c9b161c6af92ff201c734d6437bc7ef74a32dab306c5d47a0bdb7a82d9f", size = 9424, upload-time = "2026-02-09T09:41:14.545Z" }, ] [[package]] name = "prometheus-client" version = "0.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035 } +sdist = { url = "https://files.pythonhosted.org/packages/1b/fb/d9aa83ffe43ce1f19e557c0971d04b90561b0cfd50762aafb01968285553/prometheus_client-0.25.0.tar.gz", hash = "sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28", size = 86035, upload-time = "2026-04-09T19:53:42.359Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154 }, + { url = "https://files.pythonhosted.org/packages/8d/9b/d4b1e644385499c8346fa9b622a3f030dce14cd6ef8a1871c221a17a67e7/prometheus_client-0.25.0-py3-none-any.whl", hash = "sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1", size = 64154, upload-time = "2026-04-09T19:53:41.324Z" }, ] [[package]] @@ -3282,9 +3282,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431 }, + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, ] [[package]] @@ -3297,9 +3297,9 @@ dependencies = [ { name = "schemez" }, { name = "universal-pathlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/9d/0581eadf715aa31d61bfe2b7463b65dc6f08bc3220d3b2432d688f1a7979/promptantic-1.0.0.tar.gz", hash = "sha256:84422e4c83bd372c4fd3f13cbff317b8867c3807c4050d88a94496bf5813e663", size = 24765 } +sdist = { url = "https://files.pythonhosted.org/packages/3e/9d/0581eadf715aa31d61bfe2b7463b65dc6f08bc3220d3b2432d688f1a7979/promptantic-1.0.0.tar.gz", hash = "sha256:84422e4c83bd372c4fd3f13cbff317b8867c3807c4050d88a94496bf5813e663", size = 24765, upload-time = "2025-10-07T20:25:38.313Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/d1/d4987d168b6942fee14cd49b00317658cc24f8db0fe121f4395576616290/promptantic-1.0.0-py3-none-any.whl", hash = "sha256:90ad4e7e860b1296427cd50a99abc1562e90ae482af104174acb99cb9dd6138b", size = 33945 }, + { url = "https://files.pythonhosted.org/packages/b2/d1/d4987d168b6942fee14cd49b00317658cc24f8db0fe121f4395576616290/promptantic-1.0.0-py3-none-any.whl", hash = "sha256:90ad4e7e860b1296427cd50a99abc1562e90ae482af104174acb99cb9dd6138b", size = 33945, upload-time = "2025-10-07T20:25:36.999Z" }, ] [[package]] @@ -3320,112 +3320,112 @@ dependencies = [ { name = "rich" }, { name = "tenacity" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/da/c93be46621337726ad33ed6f47bb28eba21ae2f95838ab980cfd4814cdbb/promptlayer-1.5.1.tar.gz", hash = "sha256:178dd2440003bc36e31c04833b6fc39ad07e02339499dc80b15d25483eb6e72c", size = 130611 } +sdist = { url = "https://files.pythonhosted.org/packages/39/da/c93be46621337726ad33ed6f47bb28eba21ae2f95838ab980cfd4814cdbb/promptlayer-1.5.1.tar.gz", hash = "sha256:178dd2440003bc36e31c04833b6fc39ad07e02339499dc80b15d25483eb6e72c", size = 130611, upload-time = "2026-07-22T20:08:20.922Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/06/d7c9bb1c878d413e02484df2108feb1dd1d8b52f83669c697194feecd30e/promptlayer-1.5.1-py3-none-any.whl", hash = "sha256:981e1d6df6e0c608f10fcaa6a6c687d502cd0813bc284efe4ac25bc94f8ca90a", size = 163429 }, + { url = "https://files.pythonhosted.org/packages/e1/06/d7c9bb1c878d413e02484df2108feb1dd1d8b52f83669c697194feecd30e/promptlayer-1.5.1-py3-none-any.whl", hash = "sha256:981e1d6df6e0c608f10fcaa6a6c687d502cd0813bc284efe4ac25bc94f8ca90a", size = 163429, upload-time = "2026-07-22T20:08:22.179Z" }, ] [[package]] name = "propcache" version = "0.5.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457 }, - { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835 }, - { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545 }, - { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886 }, - { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261 }, - { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184 }, - { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534 }, - { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500 }, - { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994 }, - { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884 }, - { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464 }, - { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588 }, - { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667 }, - { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463 }, - { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621 }, - { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649 }, - { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636 }, - { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872 }, - { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257 }, - { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696 }, - { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378 }, - { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283 }, - { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616 }, - { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773 }, - { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664 }, - { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643 }, - { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595 }, - { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711 }, - { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247 }, - { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102 }, - { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964 }, - { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546 }, - { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330 }, - { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521 }, - { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036 }, +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] [[package]] name = "protobuf" version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531 } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739 }, - { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089 }, - { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737 }, - { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610 }, - { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381 }, - { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436 }, - { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656 }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] name = "psutil" version = "7.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595 }, - { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082 }, - { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476 }, - { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062 }, - { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893 }, - { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589 }, - { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090 }, - { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859 }, - { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560 }, - { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997 }, - { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972 }, - { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266 }, - { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737 }, - { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617 }, + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] [[package]] name = "psygnal" version = "0.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/79/20c3e23e75272e9ddf018097cf872ab088bccba978888472656629efa4a3/psygnal-0.15.1.tar.gz", hash = "sha256:f64f62dee2306fc1c22050a59b6c6cdad126e04b0cf50e393ff858a1da719096", size = 123147 } +sdist = { url = "https://files.pythonhosted.org/packages/4e/79/20c3e23e75272e9ddf018097cf872ab088bccba978888472656629efa4a3/psygnal-0.15.1.tar.gz", hash = "sha256:f64f62dee2306fc1c22050a59b6c6cdad126e04b0cf50e393ff858a1da719096", size = 123147, upload-time = "2026-01-04T16:38:41.959Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/21/5a142165d27063abf5921807d3c3d973f5d44ab414a13b210839a43ead4d/psygnal-0.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2087aadc9404f007f79c2899e329932869e362c50de58b90631c5f49b4768cc5", size = 596768 }, - { url = "https://files.pythonhosted.org/packages/e1/25/c1712931d61c118691e73daf29ef708c679ea9ba187c797dd5deee360411/psygnal-0.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f3bf68ca42569dfdce20c6cf915d34b78b9e3ddddacb9f78728224fda6946b4", size = 574808 }, - { url = "https://files.pythonhosted.org/packages/2d/4f/3593e5adb88a188c798604aed95fbc1479f30230e7f51e8f2c770e6a3832/psygnal-0.15.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9fca977f5335deea39aed22e31d9795983e4f243e59a7d3c4105793adb7693d", size = 885616 }, - { url = "https://files.pythonhosted.org/packages/58/4c/14779ed4c3a1d71fa1a9a87ecfb184ad3335dd64681067f77c1c47b14ae9/psygnal-0.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c85b7d05b92ccbec47c75ab8a5545eda462e81a492c82424aba5ab81a3ad89d", size = 876516 }, - { url = "https://files.pythonhosted.org/packages/3e/bc/4f771e3cdcde4db4023dbf36d6f0aab44e02b9de719353c22954b655e2ff/psygnal-0.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac0e693b29e0a429e97315a52313321855bef6140e9975b7ae78b4d93c8fbb42", size = 419172 }, - { url = "https://files.pythonhosted.org/packages/46/49/7742544684bee728ec123515d2694cee859aa2a705951a461230b00f18cc/psygnal-0.15.1-py3-none-any.whl", hash = "sha256:4221140e633e45b076953c64bcb9b41a744833527f9a037c1ca98bc270798cbf", size = 90638 }, + { url = "https://files.pythonhosted.org/packages/46/21/5a142165d27063abf5921807d3c3d973f5d44ab414a13b210839a43ead4d/psygnal-0.15.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2087aadc9404f007f79c2899e329932869e362c50de58b90631c5f49b4768cc5", size = 596768, upload-time = "2026-01-04T16:38:27.053Z" }, + { url = "https://files.pythonhosted.org/packages/e1/25/c1712931d61c118691e73daf29ef708c679ea9ba187c797dd5deee360411/psygnal-0.15.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f3bf68ca42569dfdce20c6cf915d34b78b9e3ddddacb9f78728224fda6946b4", size = 574808, upload-time = "2026-01-04T16:38:28.779Z" }, + { url = "https://files.pythonhosted.org/packages/2d/4f/3593e5adb88a188c798604aed95fbc1479f30230e7f51e8f2c770e6a3832/psygnal-0.15.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e9fca977f5335deea39aed22e31d9795983e4f243e59a7d3c4105793adb7693d", size = 885616, upload-time = "2026-01-04T16:38:30.081Z" }, + { url = "https://files.pythonhosted.org/packages/58/4c/14779ed4c3a1d71fa1a9a87ecfb184ad3335dd64681067f77c1c47b14ae9/psygnal-0.15.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c85b7d05b92ccbec47c75ab8a5545eda462e81a492c82424aba5ab81a3ad89d", size = 876516, upload-time = "2026-01-04T16:38:31.422Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bc/4f771e3cdcde4db4023dbf36d6f0aab44e02b9de719353c22954b655e2ff/psygnal-0.15.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac0e693b29e0a429e97315a52313321855bef6140e9975b7ae78b4d93c8fbb42", size = 419172, upload-time = "2026-01-04T16:38:32.82Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/7742544684bee728ec123515d2694cee859aa2a705951a461230b00f18cc/psygnal-0.15.1-py3-none-any.whl", hash = "sha256:4221140e633e45b076953c64bcb9b41a744833527f9a037c1ca98bc270798cbf", size = 90638, upload-time = "2026-01-04T16:38:40.841Z" }, ] [[package]] name = "ptyprocess" version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, ] [[package]] @@ -3436,9 +3436,9 @@ dependencies = [ { name = "beartype" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547 } +sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005 }, + { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, ] [package.optional-dependencies] @@ -3464,42 +3464,42 @@ redis = [ name = "py-rust-stemmers" version = "0.1.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/c1/9763f9fb1cd73f9c317a83feeed6e0d4af320c6bbddab47b4a94f3a47d0c/py_rust_stemmers-0.1.8.tar.gz", hash = "sha256:6b0f6f48bc54d607aed802de872fcd5a71bae969a6760976dc78ce55e8eaf3da", size = 9732 } +sdist = { url = "https://files.pythonhosted.org/packages/6b/c1/9763f9fb1cd73f9c317a83feeed6e0d4af320c6bbddab47b4a94f3a47d0c/py_rust_stemmers-0.1.8.tar.gz", hash = "sha256:6b0f6f48bc54d607aed802de872fcd5a71bae969a6760976dc78ce55e8eaf3da", size = 9732, upload-time = "2026-05-22T11:00:24.358Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/98/f078f3930311e7b6154ccdf9166c4e30a416c7d199e136b5f09265d58a35/py_rust_stemmers-0.1.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5bd15b89203ecd886960e237124d1aa6e55498d76418c36c967d3b12168d43dc", size = 290427 }, - { url = "https://files.pythonhosted.org/packages/c9/46/21d784a3f1db6a23051ffd5826d8ee667d26a64587c1cfbda0443ed87fff/py_rust_stemmers-0.1.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c92733b020534470ca5a0d7fe8b85c85622ff383d4f37fec75a1c677aa84921", size = 275628 }, - { url = "https://files.pythonhosted.org/packages/57/d5/701c73a4f6a7fecfd96a6588f0cafe98d6b0acde93adf8a2e45535f3d1d5/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ab605a86c950ba7e8ab1392cf91296c0bec3084babb897a4aecf90a10c82395", size = 316656 }, - { url = "https://files.pythonhosted.org/packages/9d/0d/c58fe98153cfdb6abf4dfb6ac335c923000d4af4e736080c3a3045b7aea7/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:21ed8055cec1f78d666afad8ffd7a51775ba419d2c615b8a1df7b32ca7f33e2b", size = 319377 }, - { url = "https://files.pythonhosted.org/packages/5c/d7/e60d04849e90aa3ad457211cc4999c30401f433341f9a5588c12b81f9877/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae773e1d01e9aa328d175f461475d0cd7074a82bfcc71de6dc5765e51f1cc9f7", size = 323719 }, - { url = "https://files.pythonhosted.org/packages/6a/48/c0e4fb955db784cc354e0756354602f7043ff4c10fcbd9d901a2f8fe3239/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5cc8fab9d0f1b274a26935a632362b8278f03e81b65e8b8644d5ca3f62a5a1a4", size = 324110 }, - { url = "https://files.pythonhosted.org/packages/48/eb/981b26baff37cf7a26ee206763cc4d2fb3e1db8f0f86ec030074431fae05/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35570098da02eb439afcd7270a12bf850bbe874b85cb912e0fb2d87a6e703920", size = 494645 }, - { url = "https://files.pythonhosted.org/packages/6d/af/f16e805b7aefc2257b192b83a89300c8360b0fdffd3dfefa92dee4ec9b15/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0a68745d4b3c7f5abc778ca967e8711df6154873abcfe4e62a6631fa2363cc32", size = 596124 }, - { url = "https://files.pythonhosted.org/packages/76/8c/e7a2c940ba00e0792ae346aed5e755d51d37cf6d6853f6b141e5380e285d/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7cc0cc0b8eb45d2158c28ea43e2f338c110aad63052ad3bd00bc7446a595e12f", size = 541771 }, - { url = "https://files.pythonhosted.org/packages/c2/a0/dd7c5fc6ade6d2a2a49e49937f06f2d488511454e8ab1b313d277ee8c3b1/py_rust_stemmers-0.1.8-cp313-cp313-win_amd64.whl", hash = "sha256:15af4e12e1288de2e5241eec375afc6ad6be4c125a28ca010599d9f92db23f01", size = 212438 }, + { url = "https://files.pythonhosted.org/packages/c2/98/f078f3930311e7b6154ccdf9166c4e30a416c7d199e136b5f09265d58a35/py_rust_stemmers-0.1.8-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5bd15b89203ecd886960e237124d1aa6e55498d76418c36c967d3b12168d43dc", size = 290427, upload-time = "2026-05-22T10:59:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c9/46/21d784a3f1db6a23051ffd5826d8ee667d26a64587c1cfbda0443ed87fff/py_rust_stemmers-0.1.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6c92733b020534470ca5a0d7fe8b85c85622ff383d4f37fec75a1c677aa84921", size = 275628, upload-time = "2026-05-22T10:59:56.687Z" }, + { url = "https://files.pythonhosted.org/packages/57/d5/701c73a4f6a7fecfd96a6588f0cafe98d6b0acde93adf8a2e45535f3d1d5/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9ab605a86c950ba7e8ab1392cf91296c0bec3084babb897a4aecf90a10c82395", size = 316656, upload-time = "2026-05-22T10:59:57.67Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0d/c58fe98153cfdb6abf4dfb6ac335c923000d4af4e736080c3a3045b7aea7/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:21ed8055cec1f78d666afad8ffd7a51775ba419d2c615b8a1df7b32ca7f33e2b", size = 319377, upload-time = "2026-05-22T10:59:58.664Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d7/e60d04849e90aa3ad457211cc4999c30401f433341f9a5588c12b81f9877/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae773e1d01e9aa328d175f461475d0cd7074a82bfcc71de6dc5765e51f1cc9f7", size = 323719, upload-time = "2026-05-22T10:59:59.845Z" }, + { url = "https://files.pythonhosted.org/packages/6a/48/c0e4fb955db784cc354e0756354602f7043ff4c10fcbd9d901a2f8fe3239/py_rust_stemmers-0.1.8-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5cc8fab9d0f1b274a26935a632362b8278f03e81b65e8b8644d5ca3f62a5a1a4", size = 324110, upload-time = "2026-05-22T11:00:01.26Z" }, + { url = "https://files.pythonhosted.org/packages/48/eb/981b26baff37cf7a26ee206763cc4d2fb3e1db8f0f86ec030074431fae05/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35570098da02eb439afcd7270a12bf850bbe874b85cb912e0fb2d87a6e703920", size = 494645, upload-time = "2026-05-22T11:00:02.737Z" }, + { url = "https://files.pythonhosted.org/packages/6d/af/f16e805b7aefc2257b192b83a89300c8360b0fdffd3dfefa92dee4ec9b15/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0a68745d4b3c7f5abc778ca967e8711df6154873abcfe4e62a6631fa2363cc32", size = 596124, upload-time = "2026-05-22T11:00:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/76/8c/e7a2c940ba00e0792ae346aed5e755d51d37cf6d6853f6b141e5380e285d/py_rust_stemmers-0.1.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7cc0cc0b8eb45d2158c28ea43e2f338c110aad63052ad3bd00bc7446a595e12f", size = 541771, upload-time = "2026-05-22T11:00:06.081Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a0/dd7c5fc6ade6d2a2a49e49937f06f2d488511454e8ab1b313d277ee8c3b1/py_rust_stemmers-0.1.8-cp313-cp313-win_amd64.whl", hash = "sha256:15af4e12e1288de2e5241eec375afc6ad6be4c125a28ca010599d9f92db23f01", size = 212438, upload-time = "2026-05-22T11:00:07.244Z" }, ] [[package]] name = "pyarrow" version = "25.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181 } +sdist = { url = "https://files.pythonhosted.org/packages/27/f3/95428098d1fa7d04432fb750eed06b41304c2f6a5d3319985e64db2d9d41/pyarrow-25.0.0.tar.gz", hash = "sha256:d2d697008b5ec06d75952ef260c2e9a8a0f6ccfce24266c04c9c8ade927cb3b4", size = 1199181, upload-time = "2026-07-10T08:29:50.116Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/c8/098ce17d778fd9d29e40bb8c5f19a40cc90c3f0b46c9057b0d7993f42f54/pyarrow-25.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8831a3ba52fa7cdb78d368d968b1dcd06171e6dff5461e16d90de91d371e47bc", size = 35844549 }, - { url = "https://files.pythonhosted.org/packages/bc/66/24c28877219abf6263d909b1592c97ff82c59f13a59acbed11fc87c0654f/pyarrow-25.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:5f4bacb60f91dd2fca6c52f1b9a0012cd090e0294f1f781dc1881a247a352f8e", size = 37610397 }, - { url = "https://files.pythonhosted.org/packages/53/55/6d1d5f5aff317ec5de9421594679ed51ed828fe7e2ce209327f819d801e4/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:59516c822d5fd8e544aaa0dfe72f36fed5d4c24ea8390aab1bcd31d7e959c6be", size = 46841701 }, - { url = "https://files.pythonhosted.org/packages/b5/5d/f790fb6965ab54c9da0dda7856abc75fd0d7648d865f8d603c111d203a64/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f9dbd83e91c239a1f5ee7ce13f108b5f6c0efbe40a4375260d8f08b43ad05e9", size = 50090118 }, - { url = "https://files.pythonhosted.org/packages/0c/8c/faf025357ebf31bc96777f234277aa31e2aeca6dd4ecaa391f29085473c2/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18dcc8cc50b5e72eae6fcbfc6c8776c21a007176b27a3cdec5c2f5bcf126708d", size = 49945559 }, - { url = "https://files.pythonhosted.org/packages/07/a1/bd051871708ea99a5e0fc711926c26c6f2c6d0130c7aaac8093e34998af6/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4ec1895a87aa834c3b99b7a1e758747eb8bb57f922b32c0e0fa04afb8d6998b1", size = 53114238 }, - { url = "https://files.pythonhosted.org/packages/7c/31/737f0c3cffcd6af647849477d1dd68045deac2e3963c3f9f211bedc48540/pyarrow-25.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:77c8d1ae46a44b4006e8db1cc977bbcc6ce4873c92f74137d68e45503b97fb18", size = 27861162 }, + { url = "https://files.pythonhosted.org/packages/6c/c8/098ce17d778fd9d29e40bb8c5f19a40cc90c3f0b46c9057b0d7993f42f54/pyarrow-25.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:8831a3ba52fa7cdb78d368d968b1dcd06171e6dff5461e16d90de91d371e47bc", size = 35844549, upload-time = "2026-07-10T08:27:37.956Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/24c28877219abf6263d909b1592c97ff82c59f13a59acbed11fc87c0654f/pyarrow-25.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:5f4bacb60f91dd2fca6c52f1b9a0012cd090e0294f1f781dc1881a247a352f8e", size = 37610397, upload-time = "2026-07-10T08:27:43.803Z" }, + { url = "https://files.pythonhosted.org/packages/53/55/6d1d5f5aff317ec5de9421594679ed51ed828fe7e2ce209327f819d801e4/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:59516c822d5fd8e544aaa0dfe72f36fed5d4c24ea8390aab1bcd31d7e959c6be", size = 46841701, upload-time = "2026-07-10T08:27:49.741Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5d/f790fb6965ab54c9da0dda7856abc75fd0d7648d865f8d603c111d203a64/pyarrow-25.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6f9dbd83e91c239a1f5ee7ce13f108b5f6c0efbe40a4375260d8f08b43ad05e9", size = 50090118, upload-time = "2026-07-10T08:27:56.051Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8c/faf025357ebf31bc96777f234277aa31e2aeca6dd4ecaa391f29085473c2/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18dcc8cc50b5e72eae6fcbfc6c8776c21a007176b27a3cdec5c2f5bcf126708d", size = 49945559, upload-time = "2026-07-10T08:28:01.927Z" }, + { url = "https://files.pythonhosted.org/packages/07/a1/bd051871708ea99a5e0fc711926c26c6f2c6d0130c7aaac8093e34998af6/pyarrow-25.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4ec1895a87aa834c3b99b7a1e758747eb8bb57f922b32c0e0fa04afb8d6998b1", size = 53114238, upload-time = "2026-07-10T08:28:08.594Z" }, + { url = "https://files.pythonhosted.org/packages/7c/31/737f0c3cffcd6af647849477d1dd68045deac2e3963c3f9f211bedc48540/pyarrow-25.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:77c8d1ae46a44b4006e8db1cc977bbcc6ce4873c92f74137d68e45503b97fb18", size = 27861162, upload-time = "2026-07-10T08:28:12.975Z" }, ] [[package]] name = "pyasn1" version = "0.6.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262 } +sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410 }, + { url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" }, ] [[package]] @@ -3509,9 +3509,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyasn1" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, ] [[package]] @@ -3521,18 +3521,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fd/7f/94d424dc756a6287271cf40cf1b2a44c10e3f137bf3246a2b4a7416ca3d3/pyconify-0.2.1.tar.gz", hash = "sha256:8dd53757d9fbed41711434460932b2b5dbc25da720cd9f9a44af0187b2dfc07d", size = 22478 } +sdist = { url = "https://files.pythonhosted.org/packages/fd/7f/94d424dc756a6287271cf40cf1b2a44c10e3f137bf3246a2b4a7416ca3d3/pyconify-0.2.1.tar.gz", hash = "sha256:8dd53757d9fbed41711434460932b2b5dbc25da720cd9f9a44af0187b2dfc07d", size = 22478, upload-time = "2025-02-06T13:20:53.592Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/72/40/50dd2e8bfec81676e4619903bd452c10dc0d8efac1533e79e67cc76759b5/pyconify-0.2.1-py3-none-any.whl", hash = "sha256:d3b53eee1f8a2d60c1d135610f42e789774dbe71c6d8af68af0a21d3b3ec9eb7", size = 19459 }, + { url = "https://files.pythonhosted.org/packages/72/40/50dd2e8bfec81676e4619903bd452c10dc0d8efac1533e79e67cc76759b5/pyconify-0.2.1-py3-none-any.whl", hash = "sha256:d3b53eee1f8a2d60c1d135610f42e789774dbe71c6d8af68af0a21d3b3ec9eb7", size = 19459, upload-time = "2025-02-06T13:20:51.613Z" }, ] [[package]] name = "pycparser" version = "3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] [[package]] @@ -3545,9 +3545,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775 } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262 }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [package.optional-dependencies] @@ -3562,9 +3562,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic-ai-slim", extra = ["anthropic", "cli", "evals", "google", "logfire", "mcp", "openai", "retries", "web"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/4a/749ffa1e82989fbe46452aefa667d4b97e6471f24de6c60e563a8953c289/pydantic_ai-2.17.0.tar.gz", hash = "sha256:32bfa1eb91bedf611f3dea59063765b4fce8d644c85bccb8e79bbb2f58e9ffa2", size = 18760 } +sdist = { url = "https://files.pythonhosted.org/packages/47/4a/749ffa1e82989fbe46452aefa667d4b97e6471f24de6c60e563a8953c289/pydantic_ai-2.17.0.tar.gz", hash = "sha256:32bfa1eb91bedf611f3dea59063765b4fce8d644c85bccb8e79bbb2f58e9ffa2", size = 18760, upload-time = "2026-07-24T01:55:37.467Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/af/07b7dc9c078a48201aadd8ce48c96e57c6936f8518b30fdb810b59adf62a/pydantic_ai-2.17.0-py3-none-any.whl", hash = "sha256:f780f7fde4b302efbf311b03a15a4b08836efc1b07f1497065200b656f72e2fa", size = 7733 }, + { url = "https://files.pythonhosted.org/packages/9e/af/07b7dc9c078a48201aadd8ce48c96e57c6936f8518b30fdb810b59adf62a/pydantic_ai-2.17.0-py3-none-any.whl", hash = "sha256:f780f7fde4b302efbf311b03a15a4b08836efc1b07f1497065200b656f72e2fa", size = 7733, upload-time = "2026-07-24T01:55:28.876Z" }, ] [[package]] @@ -3580,9 +3580,9 @@ dependencies = [ { name = "pydantic-graph" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/52/ab9c7617b42727ba6ef6cc71f9f76f188cfe9387c42e9cdbf0ebe7f5d45d/pydantic_ai_slim-2.17.0.tar.gz", hash = "sha256:2ce0e81a270c99d7c8fb01b9675fe3fc6097efc99943f314bbf4b33f87adfab8", size = 893164 } +sdist = { url = "https://files.pythonhosted.org/packages/01/52/ab9c7617b42727ba6ef6cc71f9f76f188cfe9387c42e9cdbf0ebe7f5d45d/pydantic_ai_slim-2.17.0.tar.gz", hash = "sha256:2ce0e81a270c99d7c8fb01b9675fe3fc6097efc99943f314bbf4b33f87adfab8", size = 893164, upload-time = "2026-07-24T01:55:39.431Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/27/dc5d0e29bd24c73fa10fa45d4c4416f7cf7212f8ebdf139f5f134a9d2483/pydantic_ai_slim-2.17.0-py3-none-any.whl", hash = "sha256:9d236918df3504452f961b237266e3c025575ea366a155faefe3c04805fd2fa3", size = 1080947 }, + { url = "https://files.pythonhosted.org/packages/3d/27/dc5d0e29bd24c73fa10fa45d4c4416f7cf7212f8ebdf139f5f134a9d2483/pydantic_ai_slim-2.17.0-py3-none-any.whl", hash = "sha256:9d236918df3504452f961b237266e3c025575ea366a155faefe3c04805fd2fa3", size = 1080947, upload-time = "2026-07-24T01:55:31.877Z" }, ] [package.optional-dependencies] @@ -3631,23 +3631,23 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306 }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906 }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802 }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446 }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757 }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275 }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467 }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417 }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782 }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782 }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334 }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986 }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693 }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819 }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411 }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, ] [[package]] @@ -3662,9 +3662,9 @@ dependencies = [ { name = "pyyaml" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/70/77768745b74a35d254d0e3c75ad8bd5bcc3c3d9383c6d38ad10bd331f1d8/pydantic_evals-2.17.0.tar.gz", hash = "sha256:4db59787f178a4ee12815a2e176774aa1c3c64df27b2037f05fec9677b08f200", size = 85147 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/70/77768745b74a35d254d0e3c75ad8bd5bcc3c3d9383c6d38ad10bd331f1d8/pydantic_evals-2.17.0.tar.gz", hash = "sha256:4db59787f178a4ee12815a2e176774aa1c3c64df27b2037f05fec9677b08f200", size = 85147, upload-time = "2026-07-24T01:55:41.6Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/23/7d/302c489844fb12396cae20804c161e71210f028436e89027d3d3ace8809d/pydantic_evals-2.17.0-py3-none-any.whl", hash = "sha256:be2ee15fa8d33646e0f2ab537df3e7feda15f87f641d763773ca378735e931bd", size = 100440 }, + { url = "https://files.pythonhosted.org/packages/23/7d/302c489844fb12396cae20804c161e71210f028436e89027d3d3ace8809d/pydantic_evals-2.17.0-py3-none-any.whl", hash = "sha256:be2ee15fa8d33646e0f2ab537df3e7feda15f87f641d763773ca378735e931bd", size = 100440, upload-time = "2026-07-24T01:55:33.665Z" }, ] [[package]] @@ -3677,9 +3677,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/59/60/796977f734a7a089bd0ae3a2c33bdb4dec8ab4201aad5928b620c27df26c/pydantic_graph-2.17.0.tar.gz", hash = "sha256:28162287995cf46eb2f34d5e06e8da96f5fe167534b811bf6e80db382f065a91", size = 43982 } +sdist = { url = "https://files.pythonhosted.org/packages/59/60/796977f734a7a089bd0ae3a2c33bdb4dec8ab4201aad5928b620c27df26c/pydantic_graph-2.17.0.tar.gz", hash = "sha256:28162287995cf46eb2f34d5e06e8da96f5fe167534b811bf6e80db382f065a91", size = 43982, upload-time = "2026-07-24T01:55:42.52Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/a9/f4ea2cba136af41c5f69e089a3557a1d6b91771f59b43af5f9595105ca24/pydantic_graph-2.17.0-py3-none-any.whl", hash = "sha256:38e40ce971552fde4bc72f58d2e8f90478fbb3f9282be43dc471b48ba52660da", size = 51661 }, + { url = "https://files.pythonhosted.org/packages/90/a9/f4ea2cba136af41c5f69e089a3557a1d6b91771f59b43af5f9595105ca24/pydantic_graph-2.17.0-py3-none-any.whl", hash = "sha256:38e40ce971552fde4bc72f58d2e8f90478fbb3f9282be43dc471b48ba52660da", size = 51661, upload-time = "2026-07-24T01:55:35.212Z" }, ] [[package]] @@ -3691,9 +3691,9 @@ dependencies = [ { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700 } +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715 }, + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, ] [[package]] @@ -3704,9 +3704,9 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-core" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/87/085533e0873b2f80bdeb12156ffe5dddae7ee55573e2057ad574d73e3b6a/pydantic_xml-2.21.0.tar.gz", hash = "sha256:678ba9149c24e190ad94d6e3ce47f94cbb1409fd88d7df65d3d26939ff669ed0", size = 26825 } +sdist = { url = "https://files.pythonhosted.org/packages/13/87/085533e0873b2f80bdeb12156ffe5dddae7ee55573e2057ad574d73e3b6a/pydantic_xml-2.21.0.tar.gz", hash = "sha256:678ba9149c24e190ad94d6e3ce47f94cbb1409fd88d7df65d3d26939ff669ed0", size = 26825, upload-time = "2026-05-17T15:24:19.534Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/b1/278da37b539595b2cb6bb4fb3da1d69ff084c7bf5cc143f0a444550dc41a/pydantic_xml-2.21.0-py3-none-any.whl", hash = "sha256:966b8990746528304ebb96c97db7603ee3faf38f06266247b1fa444dbc16f8d6", size = 43449 }, + { url = "https://files.pythonhosted.org/packages/8e/b1/278da37b539595b2cb6bb4fb3da1d69ff084c7bf5cc143f0a444550dc41a/pydantic_xml-2.21.0-py3-none-any.whl", hash = "sha256:966b8990746528304ebb96c97db7603ee3faf38f06266247b1fa444dbc16f8d6", size = 43449, upload-time = "2026-05-17T15:24:21.182Z" }, ] [[package]] @@ -3728,18 +3728,18 @@ dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "uncalled-for" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/72/f5a0960249d32a213fb26297a55f842ce8bc90bb19adbf47fefebb9c99da/pydocket-0.23.1.tar.gz", hash = "sha256:136c1cc8b7aa7c4c08ea12431e966ae75eb093bb6ce6e0ac9db3391ba3d6034b", size = 425621 } +sdist = { url = "https://files.pythonhosted.org/packages/f8/72/f5a0960249d32a213fb26297a55f842ce8bc90bb19adbf47fefebb9c99da/pydocket-0.23.1.tar.gz", hash = "sha256:136c1cc8b7aa7c4c08ea12431e966ae75eb093bb6ce6e0ac9db3391ba3d6034b", size = 425621, upload-time = "2026-07-20T18:49:24.592Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/67/b57ac7cfd2c2f30d58ff2c996591880d200078fc828cbbad8da69d634c0e/pydocket-0.23.1-py3-none-any.whl", hash = "sha256:3da279978fd0d07eb96d4119e1a213de61d6b4acd1792a28bc62e67f3e7e5929", size = 129230 }, + { url = "https://files.pythonhosted.org/packages/e6/67/b57ac7cfd2c2f30d58ff2c996591880d200078fc828cbbad8da69d634c0e/pydocket-0.23.1-py3-none-any.whl", hash = "sha256:3da279978fd0d07eb96d4119e1a213de61d6b4acd1792a28bc62e67f3e7e5929", size = 129230, upload-time = "2026-07-20T18:49:23.243Z" }, ] [[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -3755,19 +3755,19 @@ dependencies = [ { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, { name = "setuptools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519 } +sdist = { url = "https://files.pythonhosted.org/packages/d5/4d/ec706c3fcf39e26888c35b39615ff4d5865d184069666c47492cff1fbe50/pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd", size = 4061519, upload-time = "2026-06-13T14:15:06.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/4a/53cf98bf66daed012dc9cd78c8203f19a675d696f2fc12afcf8c5049a0e0/pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", size = 1052350 }, - { url = "https://files.pythonhosted.org/packages/30/83/b591295c352ef464c50b4c6ffff1c4f771d875c9e833f578d1b9f564f6b3/pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", size = 748477 }, - { url = "https://files.pythonhosted.org/packages/3d/8f/88fff4e403873b1e22286911350e75ff00db014aa08e57045da9d4328993/pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", size = 760877 }, - { url = "https://files.pythonhosted.org/packages/8a/13/f0e48fbdfd1d05d948157121cea8b1b823dcb89efe6934b71fdd8bdb3f0f/pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", size = 759194 }, - { url = "https://files.pythonhosted.org/packages/dd/d5/ea7878cf9924ed30d946d8288777424e6d069d94f5bde56b4d0890069664/pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", size = 754979 }, - { url = "https://files.pythonhosted.org/packages/9f/09/51b8905714b733bac66dbc041a7821372d70d888d273ae474c4037d4202d/pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", size = 754812 }, - { url = "https://files.pythonhosted.org/packages/4b/43/d77779439d8c6c2e27a77bcfbd1d5cc0f568ebb611bb472b11af81b5f177/pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", size = 753887 }, - { url = "https://files.pythonhosted.org/packages/51/8f/c22df1f6837784ac349057ba693f08e7b1ca7a0e06f9c33c63bc6280007b/pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", size = 753779 }, - { url = "https://files.pythonhosted.org/packages/c9/76/1ce8a27ce62ba8cf3a87c9ce6d575610f4e55d7cb0123e7512fc3f4b921a/pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", size = 1336462 }, - { url = "https://files.pythonhosted.org/packages/c1/fa/ca1d7e5257dd8566a9dfc0dfb02f8a8075eeb53d4b2d3c579f1276759042/pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", size = 1397487 }, - { url = "https://files.pythonhosted.org/packages/dc/75/21b51523ce8d96629b71311775a0a65f5f5a872124ab0de33e5c848f8bff/pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025", size = 1346094 }, + { url = "https://files.pythonhosted.org/packages/0c/4a/53cf98bf66daed012dc9cd78c8203f19a675d696f2fc12afcf8c5049a0e0/pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda", size = 1052350, upload-time = "2026-06-13T14:13:55.88Z" }, + { url = "https://files.pythonhosted.org/packages/30/83/b591295c352ef464c50b4c6ffff1c4f771d875c9e833f578d1b9f564f6b3/pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251", size = 748477, upload-time = "2026-06-13T14:14:00.327Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8f/88fff4e403873b1e22286911350e75ff00db014aa08e57045da9d4328993/pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2", size = 760877, upload-time = "2026-06-13T14:14:04.836Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/f0e48fbdfd1d05d948157121cea8b1b823dcb89efe6934b71fdd8bdb3f0f/pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad", size = 759194, upload-time = "2026-06-13T14:14:10.61Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d5/ea7878cf9924ed30d946d8288777424e6d069d94f5bde56b4d0890069664/pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee", size = 754979, upload-time = "2026-06-13T14:14:15.226Z" }, + { url = "https://files.pythonhosted.org/packages/9f/09/51b8905714b733bac66dbc041a7821372d70d888d273ae474c4037d4202d/pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f", size = 754812, upload-time = "2026-06-13T14:14:20.264Z" }, + { url = "https://files.pythonhosted.org/packages/4b/43/d77779439d8c6c2e27a77bcfbd1d5cc0f568ebb611bb472b11af81b5f177/pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1", size = 753887, upload-time = "2026-06-13T14:14:25.268Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/c22df1f6837784ac349057ba693f08e7b1ca7a0e06f9c33c63bc6280007b/pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74", size = 753779, upload-time = "2026-06-13T14:14:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/1ce8a27ce62ba8cf3a87c9ce6d575610f4e55d7cb0123e7512fc3f4b921a/pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9", size = 1336462, upload-time = "2026-06-13T14:14:35.785Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/ca1d7e5257dd8566a9dfc0dfb02f8a8075eeb53d4b2d3c579f1276759042/pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7", size = 1397487, upload-time = "2026-06-13T14:14:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/dc/75/21b51523ce8d96629b71311775a0a65f5f5a872124ab0de33e5c848f8bff/pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025", size = 1346094, upload-time = "2026-06-13T14:14:48.914Z" }, ] [[package]] @@ -3777,34 +3777,34 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354 } +sdist = { url = "https://files.pythonhosted.org/packages/94/5b/c9fe0db5e83ee1c39b2258fa21d23b15e1a60786b6c5990ee5074ead8bb6/pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725", size = 173354, upload-time = "2026-06-08T22:37:16.152Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159 }, + { url = "https://files.pythonhosted.org/packages/e7/31/f2d7343d8ed5f7c4678377886f6ce533e6eaaa131b252ce950114c2a7efa/pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3", size = 457159, upload-time = "2026-06-08T22:37:14.722Z" }, ] [[package]] name = "pyinstrument" version = "5.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/32/7f/d3c4ef7c43f3294bd5a475dfa6f295a9fee5243c292d5c8122044fa83bcb/pyinstrument-5.1.2.tar.gz", hash = "sha256:af149d672da9493fa37334a1cc68f7b80c3e6cb9fd99b9e426c447db5c650bf0", size = 266889 } +sdist = { url = "https://files.pythonhosted.org/packages/32/7f/d3c4ef7c43f3294bd5a475dfa6f295a9fee5243c292d5c8122044fa83bcb/pyinstrument-5.1.2.tar.gz", hash = "sha256:af149d672da9493fa37334a1cc68f7b80c3e6cb9fd99b9e426c447db5c650bf0", size = 266889, upload-time = "2026-01-04T18:38:58.464Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/8e/b9aea969eec67c129652000446384d550a0df45c297adc9fd74da2f8482c/pyinstrument-5.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b8bab2334bf1d4c9e92d61db574300b914b594588a6b6dd67c45450152dfc29", size = 131418 }, - { url = "https://files.pythonhosted.org/packages/8f/62/76418eb29b5591f3e5500369a6777ce928135c3aa6ccdb0c861a9c6ca93b/pyinstrument-5.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:13dcc138a61298ef4994b7aebff509d2c06db89dfd6e2021f0b9cd96aaa44ec3", size = 124448 }, - { url = "https://files.pythonhosted.org/packages/07/73/874bccc04bcf6f4babc3de1a9568e209e7e40998563974f5030b0fb4d3e0/pyinstrument-5.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8abd4a7ffa2e7f9e00039a5e549e8eebc80d7ca8d43f0fb51a50ff2b117ce4a", size = 149853 }, - { url = "https://files.pythonhosted.org/packages/cf/85/268446c4388d77ff4abdeaff202356e1527b3ff9576f5587443a24980bec/pyinstrument-5.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3a05108edebc30f31e2c69c904576042f1158b2513ab80adc08f7848a7a8f0", size = 148641 }, - { url = "https://files.pythonhosted.org/packages/fc/15/4f8dea3381483e68d00582a9b823a21a088acfe77a847a7991a1a8feed76/pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f70d588b53f3f35829d1d1ddfa05e07fcebf1434b3b1509d542ca317d8e9a2a5", size = 148674 }, - { url = "https://files.pythonhosted.org/packages/fa/61/72c180454b6511d5b90166f8828e1bab3b45d0489952a1fe48c5c585233d/pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b007327e0d6a6a01d5064883dd27c19996f044ce7488d507826fee7884e6a32e", size = 148315 }, - { url = "https://files.pythonhosted.org/packages/2c/f0/4c27cebddf22a8840bd8b419366bb321ce41f921ca1893e309c932ab28bf/pyinstrument-5.1.2-cp313-cp313-win32.whl", hash = "sha256:9ba0e6b17a7e86c3dc02d208e4c25506e8f914d9964ae89449f1f37f0b70abc0", size = 125926 }, - { url = "https://files.pythonhosted.org/packages/6c/20/6b1bee88ddef065b0df3a3ba4ba60ed8a9ca443d5cded7152a8a9750914f/pyinstrument-5.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:660d7fc486a839814db0b2f716bc13d8b99b9c780aaeb47f74a70a34adc02a7b", size = 126678 }, + { url = "https://files.pythonhosted.org/packages/b4/8e/b9aea969eec67c129652000446384d550a0df45c297adc9fd74da2f8482c/pyinstrument-5.1.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7b8bab2334bf1d4c9e92d61db574300b914b594588a6b6dd67c45450152dfc29", size = 131418, upload-time = "2026-01-04T18:37:58.642Z" }, + { url = "https://files.pythonhosted.org/packages/8f/62/76418eb29b5591f3e5500369a6777ce928135c3aa6ccdb0c861a9c6ca93b/pyinstrument-5.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:13dcc138a61298ef4994b7aebff509d2c06db89dfd6e2021f0b9cd96aaa44ec3", size = 124448, upload-time = "2026-01-04T18:37:59.95Z" }, + { url = "https://files.pythonhosted.org/packages/07/73/874bccc04bcf6f4babc3de1a9568e209e7e40998563974f5030b0fb4d3e0/pyinstrument-5.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8abd4a7ffa2e7f9e00039a5e549e8eebc80d7ca8d43f0fb51a50ff2b117ce4a", size = 149853, upload-time = "2026-01-04T18:38:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/cf/85/268446c4388d77ff4abdeaff202356e1527b3ff9576f5587443a24980bec/pyinstrument-5.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb3a05108edebc30f31e2c69c904576042f1158b2513ab80adc08f7848a7a8f0", size = 148641, upload-time = "2026-01-04T18:38:03.086Z" }, + { url = "https://files.pythonhosted.org/packages/fc/15/4f8dea3381483e68d00582a9b823a21a088acfe77a847a7991a1a8feed76/pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f70d588b53f3f35829d1d1ddfa05e07fcebf1434b3b1509d542ca317d8e9a2a5", size = 148674, upload-time = "2026-01-04T18:38:04.805Z" }, + { url = "https://files.pythonhosted.org/packages/fa/61/72c180454b6511d5b90166f8828e1bab3b45d0489952a1fe48c5c585233d/pyinstrument-5.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b007327e0d6a6a01d5064883dd27c19996f044ce7488d507826fee7884e6a32e", size = 148315, upload-time = "2026-01-04T18:38:06.114Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f0/4c27cebddf22a8840bd8b419366bb321ce41f921ca1893e309c932ab28bf/pyinstrument-5.1.2-cp313-cp313-win32.whl", hash = "sha256:9ba0e6b17a7e86c3dc02d208e4c25506e8f914d9964ae89449f1f37f0b70abc0", size = 125926, upload-time = "2026-01-04T18:38:07.507Z" }, + { url = "https://files.pythonhosted.org/packages/6c/20/6b1bee88ddef065b0df3a3ba4ba60ed8a9ca443d5cded7152a8a9750914f/pyinstrument-5.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:660d7fc486a839814db0b2f716bc13d8b99b9c780aaeb47f74a70a34adc02a7b", size = 126678, upload-time = "2026-01-04T18:38:08.826Z" }, ] [[package]] name = "pyjwt" version = "2.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515 } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274 }, + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] [package.optional-dependencies] @@ -3820,45 +3820,45 @@ dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113 } +sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455 }, + { url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" }, ] [[package]] name = "pypdf" version = "6.14.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/72/7dfd5ff1c9c37de97a731701f51af091325f123d9d4270361c9c69e4431f/pypdf-6.14.2.tar.gz", hash = "sha256:7873f502fe4385e79539b21d872392dc0c4e3714327c15881cbc7fbfd1f95b25", size = 6491182 } +sdist = { url = "https://files.pythonhosted.org/packages/03/72/7dfd5ff1c9c37de97a731701f51af091325f123d9d4270361c9c69e4431f/pypdf-6.14.2.tar.gz", hash = "sha256:7873f502fe4385e79539b21d872392dc0c4e3714327c15881cbc7fbfd1f95b25", size = 6491182, upload-time = "2026-06-23T14:18:30.859Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/e6/136aa8993a2ae7214e0b0ef2edaa0d2e08d1d4e4982635b08a835ff31ec8/pypdf-6.14.2-py3-none-any.whl", hash = "sha256:3f07891af76dc002657e04993ab9b4de81de29f9013b9761d0b7968bff12e946", size = 349514 }, + { url = "https://files.pythonhosted.org/packages/49/e6/136aa8993a2ae7214e0b0ef2edaa0d2e08d1d4e4982635b08a835ff31ec8/pypdf-6.14.2-py3-none-any.whl", hash = "sha256:3f07891af76dc002657e04993ab9b4de81de29f9013b9761d0b7968bff12e946", size = 349514, upload-time = "2026-06-23T14:18:28.867Z" }, ] [[package]] name = "pyperclip" version = "1.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063 }, + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, ] [[package]] name = "pyproject-hooks" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216 }, + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, ] [[package]] name = "pyreadline3" version = "3.5.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368 } +sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243 }, + { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" }, ] [[package]] @@ -3869,7 +3869,7 @@ dependencies = [ { name = "requests" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/a0/d0638470df605ce266991fb04f74c69ab1bed3b90ac3838e9c3c8b69b66a/Pysher-1.0.8.tar.gz", hash = "sha256:7849c56032b208e49df67d7bd8d49029a69042ab0bb45b2ed59fa08f11ac5988", size = 9071 } +sdist = { url = "https://files.pythonhosted.org/packages/12/a0/d0638470df605ce266991fb04f74c69ab1bed3b90ac3838e9c3c8b69b66a/Pysher-1.0.8.tar.gz", hash = "sha256:7849c56032b208e49df67d7bd8d49029a69042ab0bb45b2ed59fa08f11ac5988", size = 9071, upload-time = "2022-10-10T13:41:09.936Z" } [[package]] name = "pytest" @@ -3882,9 +3882,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -3894,9 +3894,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514 } +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 }, + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] [[package]] @@ -3908,9 +3908,9 @@ dependencies = [ { name = "pluggy" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592 } +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876 }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -3921,9 +3921,9 @@ dependencies = [ { name = "attrs" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/05/b7e47dc3e01b505838372e296bd780180b3b699a9a134bb8d6be85f3d567/pytest_docker-3.2.5.tar.gz", hash = "sha256:c9662567522911280b394af4da2edd57facaf644494601fac962ff1e396d7ab6", size = 13717 } +sdist = { url = "https://files.pythonhosted.org/packages/66/05/b7e47dc3e01b505838372e296bd780180b3b699a9a134bb8d6be85f3d567/pytest_docker-3.2.5.tar.gz", hash = "sha256:c9662567522911280b394af4da2edd57facaf644494601fac962ff1e396d7ab6", size = 13717, upload-time = "2025-11-12T13:42:19.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/e4/3a76a393f808edb0ee08ecc25e5c00bce0522a45b8fcf8693ec9441739c8/pytest_docker-3.2.5-py3-none-any.whl", hash = "sha256:79f3d209f928f45d4385cb825944861bc8a8cccd309804d9c9cd63bcef03edba", size = 8724 }, + { url = "https://files.pythonhosted.org/packages/9f/e4/3a76a393f808edb0ee08ecc25e5c00bce0522a45b8fcf8693ec9441739c8/pytest_docker-3.2.5-py3-none-any.whl", hash = "sha256:79f3d209f928f45d4385cb825944861bc8a8cccd309804d9c9cd63bcef03edba", size = 8724, upload-time = "2025-11-12T13:42:18.631Z" }, ] [[package]] @@ -3934,9 +3934,9 @@ dependencies = [ { name = "pytest" }, { name = "vcrpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/32/9c/f4027c5f1693847b06d11caf4b4f6bb09f22c1581ada4663877ec166b8c6/pytest_recording-0.13.4.tar.gz", hash = "sha256:568d64b2a85992eec4ae0a419c855d5fd96782c5fb016784d86f18053792768c", size = 26576 } +sdist = { url = "https://files.pythonhosted.org/packages/32/9c/f4027c5f1693847b06d11caf4b4f6bb09f22c1581ada4663877ec166b8c6/pytest_recording-0.13.4.tar.gz", hash = "sha256:568d64b2a85992eec4ae0a419c855d5fd96782c5fb016784d86f18053792768c", size = 26576, upload-time = "2025-05-08T10:41:11.231Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/c2/ce34735972cc42d912173e79f200fe66530225190c06655c5632a9d88f1e/pytest_recording-0.13.4-py3-none-any.whl", hash = "sha256:ad49a434b51b1c4f78e85b1e6b74fdcc2a0a581ca16e52c798c6ace971f7f439", size = 13723 }, + { url = "https://files.pythonhosted.org/packages/42/c2/ce34735972cc42d912173e79f200fe66530225190c06655c5632a9d88f1e/pytest_recording-0.13.4-py3-none-any.whl", hash = "sha256:ad49a434b51b1c4f78e85b1e6b74fdcc2a0a581ca16e52c798c6ace971f7f439", size = 13723, upload-time = "2025-05-08T10:41:09.684Z" }, ] [[package]] @@ -3947,9 +3947,9 @@ dependencies = [ { name = "packaging" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/60/a90ca1cc6cffcb97b4260ed0ad2b7934b999d7c48abe4ea0840344862a3b/pytest_rerunfailures-16.4.tar.gz", hash = "sha256:8222d17c37eb7b9e4d6fc96a3c724ff4e1a5c97a5cc7cbb2c19e9282cfd21a11", size = 36635 } +sdist = { url = "https://files.pythonhosted.org/packages/47/60/a90ca1cc6cffcb97b4260ed0ad2b7934b999d7c48abe4ea0840344862a3b/pytest_rerunfailures-16.4.tar.gz", hash = "sha256:8222d17c37eb7b9e4d6fc96a3c724ff4e1a5c97a5cc7cbb2c19e9282cfd21a11", size = 36635, upload-time = "2026-07-01T06:30:56.813Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/93/3cdcc4033444e822e01b573414b03fd37fd5533070c750477b8f5fa5224b/pytest_rerunfailures-16.4-py3-none-any.whl", hash = "sha256:f69b5beb39622c90d1e44bd945d826eff6db545dcf0b68f52b7e4ad15eaf6d6c", size = 16955 }, + { url = "https://files.pythonhosted.org/packages/ce/93/3cdcc4033444e822e01b573414b03fd37fd5533070c750477b8f5fa5224b/pytest_rerunfailures-16.4-py3-none-any.whl", hash = "sha256:f69b5beb39622c90d1e44bd945d826eff6db545dcf0b68f52b7e4ad15eaf6d6c", size = 16955, upload-time = "2026-07-01T06:30:55.333Z" }, ] [[package]] @@ -3959,9 +3959,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973 } +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382 }, + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, ] [[package]] @@ -3972,9 +3972,9 @@ dependencies = [ { name = "execnet" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069 } +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396 }, + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, ] [[package]] @@ -3984,36 +3984,36 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] [[package]] name = "python-dotenv" version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135 } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101 }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-json-logger" version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573 } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021 }, + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, ] [[package]] name = "python-multipart" version = "0.0.32" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881 } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042 }, + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] [[package]] @@ -4023,9 +4023,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "text-unidecode" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921 } +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051 }, + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, ] [[package]] @@ -4035,9 +4035,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/77/153517bb1ac1bba670c6fb1dbf09e1fd0730494b1705934e715391413a0d/python_telegram_bot-22.8.tar.gz", hash = "sha256:f9d3847fcb23ee603477e442800b33bb4adf851a73e0619d2050be879decf1ef", size = 1551700 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/77/153517bb1ac1bba670c6fb1dbf09e1fd0730494b1705934e715391413a0d/python_telegram_bot-22.8.tar.gz", hash = "sha256:f9d3847fcb23ee603477e442800b33bb4adf851a73e0619d2050be879decf1ef", size = 1551700, upload-time = "2026-06-12T08:10:29.1Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/7c/ed7d4dd94280bd434173cae9f7a7aedaaab9af128ae4f494423a5687c820/python_telegram_bot-22.8-py3-none-any.whl", hash = "sha256:42373918097f1b837cc4e717d588c19ea79651497ec712bb5b0c76e5e63c50e1", size = 769397 }, + { url = "https://files.pythonhosted.org/packages/60/7c/ed7d4dd94280bd434173cae9f7a7aedaaab9af128ae4f494423a5687c820/python_telegram_bot-22.8-py3-none-any.whl", hash = "sha256:42373918097f1b837cc4e717d588c19ea79651497ec712bb5b0c76e5e63c50e1", size = 769397, upload-time = "2026-06-12T08:10:27.066Z" }, ] [package.optional-dependencies] @@ -4049,23 +4049,23 @@ socks = [ name = "pytokens" version = "0.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015 } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720 }, - { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204 }, - { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423 }, - { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859 }, - { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520 }, - { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729 }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, ] [[package]] name = "pytz" version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141 }, + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] [[package]] @@ -4073,36 +4073,36 @@ name = "pywin32" version = "312" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184 }, - { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298 }, - { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640 }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, ] [[package]] name = "pywin32-ctypes" version = "0.2.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, ] [[package]] name = "pyyaml" version = "6.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] [[package]] @@ -4112,9 +4112,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722 }, + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] [[package]] @@ -4125,18 +4125,18 @@ dependencies = [ { name = "fsspec" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/8c/4bdc1bd9676e9eb49237b3750562e9794b7585281448909fa1837c92ca27/pyyaml_include-2.2.tar.gz", hash = "sha256:6f0c7e2ac56cdd9cc305b04122817b55514e6ce8584869fae2bc2a4ef2e0d40f", size = 29854 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/8c/4bdc1bd9676e9eb49237b3750562e9794b7585281448909fa1837c92ca27/pyyaml_include-2.2.tar.gz", hash = "sha256:6f0c7e2ac56cdd9cc305b04122817b55514e6ce8584869fae2bc2a4ef2e0d40f", size = 29854, upload-time = "2024-11-09T09:36:16.915Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/1f/ae83ff547e70cd3aeefe2ebaeeef41c4e1fd5ed2453396d249d17c3a7ead/pyyaml_include-2.2-py3-none-any.whl", hash = "sha256:489fff69f78bad8b9509d006297a0140fd91382a66775b8b1da0ce7e126c1815", size = 29565 }, + { url = "https://files.pythonhosted.org/packages/56/1f/ae83ff547e70cd3aeefe2ebaeeef41c4e1fd5ed2453396d249d17c3a7ead/pyyaml_include-2.2-py3-none-any.whl", hash = "sha256:489fff69f78bad8b9509d006297a0140fd91382a66775b8b1da0ce7e126c1815", size = 29565, upload-time = "2024-11-09T09:36:15.241Z" }, ] [[package]] name = "redis" version = "8.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170 } +sdist = { url = "https://files.pythonhosted.org/packages/cc/c3/928b290c2c0ca99ab96eea5b4ff8f30be8112b075301a7d3ba214a3c8c12/redis-8.0.1.tar.gz", hash = "sha256:afc5a7a2f5a084f5b1880dec548dd45be17db7e43c82a30d84f952aefb05cfb0", size = 5114170, upload-time = "2026-06-23T14:52:37.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406 }, + { url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" }, ] [[package]] @@ -4147,84 +4147,84 @@ dependencies = [ { name = "attrs" }, { name = "rpds-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] [[package]] name = "regex" version = "2026.7.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552 }, - { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983 }, - { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832 }, - { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775 }, - { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687 }, - { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962 }, - { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817 }, - { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908 }, - { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426 }, - { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600 }, - { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950 }, - { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794 }, - { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845 }, - { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135 }, - { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747 }, - { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129 }, - { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134 }, - { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418 }, - { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486 }, - { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643 }, - { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081 }, - { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372 }, - { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089 }, - { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206 }, - { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431 }, - { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906 }, - { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559 }, - { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739 }, - { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522 }, - { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141 }, - { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036 }, - { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394 }, +sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, + { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, + { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, + { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, + { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, + { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, + { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, + { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, + { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, + { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, + { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, + { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, + { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, + { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, + { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, ] [[package]] name = "regress" version = "2025.10.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/02/bf/faa406189856f9b566fa1c42d173188d3e86cd5116484a663922365e0004/regress-2025.10.1.tar.gz", hash = "sha256:dcc0a8af0cdbc3d6e0d4725f113335d0a5ffbba86ae3ca18d2b5b352c5f2c8ed", size = 11567 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/bd/f7ff13577c688efec36488554489635c135ab7c2b3652c00aaa3d74bd761/regress-2025.10.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9100da34f69e18ad6d545f5d74f8ee729e42ce200a73752dd6d94f8a373d0e71", size = 445469 }, - { url = "https://files.pythonhosted.org/packages/89/3d/e8fc72e7424c168dfc4cdf3a864f189f6d3afbb6e3ed66455774d1f5e2ba/regress-2025.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e25096697b034848d8fc7910cdb38b7abc2bd2d7ade8767893359918ed4efd0", size = 434712 }, - { url = "https://files.pythonhosted.org/packages/63/d0/a18cc39008765d2b964bc2eba423416545d97b4f2623b99549c5e97e4d37/regress-2025.10.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4c40a8c9f3e0119d2384a52b55dcc770461e1ead6ae7b41314999223116a15", size = 514752 }, - { url = "https://files.pythonhosted.org/packages/0b/11/57e64ba417867b39ad9ca55feaea80d3176735dba36e6a92ff21b8224fcb/regress-2025.10.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25d8518285aa3ce67ddbede90a1a9ca6a5d23a1b8275dd5a9722af0c64b37b2a", size = 497412 }, - { url = "https://files.pythonhosted.org/packages/72/6c/2fd3d3877c1957408548f7a380580f0b64ef8aff69f7fc2cc182a79b0b0b/regress-2025.10.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8051fac3696730bb84d7675c62c7073792a0e105233d4f8e1055f2cab9b04fce", size = 676998 }, - { url = "https://files.pythonhosted.org/packages/cd/54/acde146473453f3acb5dd29e5be0fb627d87ad020ec5674ee133cde24261/regress-2025.10.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e337ce3b150ca0ff599b1150b995ff6a2e32b5940e17ac3f30a29133960709e", size = 576129 }, - { url = "https://files.pythonhosted.org/packages/f2/b0/8007436b33496ec8f10d7b7a67a7a669569c82fc2a182d4f928ab4fd11da/regress-2025.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:290a15652c3fafe387db02061d4ed9a100804ed5a162d069b5a0ac28b5df162a", size = 507510 }, - { url = "https://files.pythonhosted.org/packages/4a/9e/6ac6cd1de7e34e34cb492df627847de0d01cee982c7dd7d100e67cf1aa93/regress-2025.10.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b4bc2008b59e5124c1672d7fd9f203e5d4a4ff88ebaf4666e3281141e2d8db20", size = 520595 }, - { url = "https://files.pythonhosted.org/packages/11/26/35556d1dd6f64122a16166db7651d1a5804edebea9835cbcd452d9b7c2ff/regress-2025.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:56ef2f8ef9a102a7d42cbbc2f3c0c5e1b186bd8eaa78d564da566b0bf20653dc", size = 695584 }, - { url = "https://files.pythonhosted.org/packages/e9/33/07650dfc042b55270fc269276112c592d458cdfb8d31c85447ff743dcf10/regress-2025.10.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0680ac0b0a0058acb55b64aa56956732cf56b0baa84ea95e3fa124ab16da58aa", size = 695277 }, - { url = "https://files.pythonhosted.org/packages/6a/6b/80f446103df39fc1ce6e271e7c274687bb830402de23e68795ba2ab13214/regress-2025.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47c03bfd853651241fc11436fb14ef7c9b312bb9a9c2828aa5c93943e945f1ef", size = 690951 }, - { url = "https://files.pythonhosted.org/packages/b5/10/43cd990934ddb2be3318380fa380f7ca19e0eda0c30793b73e8ab8839367/regress-2025.10.1-cp313-cp313-win32.whl", hash = "sha256:3d12e6a834ed5f6d9dc7e86ea8fd77d37bb13900129930151d87c3261c3a799e", size = 282592 }, - { url = "https://files.pythonhosted.org/packages/ab/22/4855af0447c98793d3a406ddf42680736e700b8c78336d6199dff9a5fd1b/regress-2025.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:ee3b7325ea849097d674020c72b2e6deb8f1018f085a7ecbd22831cd217b7f80", size = 301551 }, - { url = "https://files.pythonhosted.org/packages/82/99/cd16dce5d1cb98970a8d7c577529e5890d938b54ade223d532fad0343221/regress-2025.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:e5f35e04fe6382c236d60c98b3f0a4a22dea75398b99c9c9bf3fb9d386cd7ebb", size = 288909 }, - { url = "https://files.pythonhosted.org/packages/a3/32/35dbbdff29174dcc253bb00b2a0d2aaf3f47a2b1d174ab4ab828feca63f1/regress-2025.10.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:0ee12c4e1c4e6e3609f41dd58065bc241945e912772f7320238d6544f0745950", size = 444440 }, - { url = "https://files.pythonhosted.org/packages/6f/1b/d97cf19b3de5ff8d78d03e0c470af48f7e8b6814b197014fda3f8a3aaca5/regress-2025.10.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:389c2278848cbfed81753a04ce8ea6c037271179cb9ef4decac7d3c65ae3330b", size = 434626 }, - { url = "https://files.pythonhosted.org/packages/04/f1/c0900448a4fd6248cd05c04c83aebb31f22ba3886965a5667f6ac4edc2b0/regress-2025.10.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf743b00cac20f8e8d271f275df7bc192ebb4faa7aee8fe98484df338786dec6", size = 514470 }, - { url = "https://files.pythonhosted.org/packages/11/a1/6346a314230af2859d2f0af5c4534ee497b9b13983ee06489652d7e1895b/regress-2025.10.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5541da1f581377bc20d2f77e017453aa8f2c2f4bfe7679dee00e139ec700abe8", size = 497546 }, - { url = "https://files.pythonhosted.org/packages/3d/ff/09605137dff09594c9e12ce555a80d8405a6ad76faa1db702bfba57468d0/regress-2025.10.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a67216efa72bb27db170f637d67eb9acdc167da5d617163b057803de7aed1e6d", size = 676318 }, - { url = "https://files.pythonhosted.org/packages/26/37/4a65635c18844d687f9ad5bdd79b72fac8d15b131264616d6b1bb52614c2/regress-2025.10.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fb61e653a325aea4681cf7b96ba9bbabc1aeb3f3d8fe877a07800024907398b9", size = 576190 }, - { url = "https://files.pythonhosted.org/packages/93/87/90a0a77a0416d7e3255e606f848021949b2f53ca18c9e4e240edb42e0929/regress-2025.10.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2a0a7da1d42fdff8068ab976a66beea572621514a130b37592489ae134a0e27", size = 507768 }, - { url = "https://files.pythonhosted.org/packages/7d/62/82c8147027f0012dd7e743267d60dcf4218b2e061825b89cddddaa7fbe49/regress-2025.10.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:337ef62f785dc6e05c4a09be7a902980cf4d4f15346f4eca9eaf02745485440c", size = 520555 }, - { url = "https://files.pythonhosted.org/packages/09/ce/5dd5dda048173b644a475ae9f4643733b31c3543e861569af4257c60d32a/regress-2025.10.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:aaf5d102e05b109dde13363e705969b3ffdbbfeb880270187eed0871e75f0c8f", size = 695587 }, - { url = "https://files.pythonhosted.org/packages/9e/08/7f6615d8ce3088feb82843ff4d48191600602d624021dbef797d274a043d/regress-2025.10.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:adf331c8938e0d8705fc5d05d08fab09c11cb4bcdf8a64fa21902972c4cd38f4", size = 694933 }, - { url = "https://files.pythonhosted.org/packages/63/87/94524b996745f892b30dec6e770701956f7125459c6a6b2c075af4b8f0a6/regress-2025.10.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:287f86b5c0bf3bc9c0abd45bf6745ba9c6a5624c3132b07631bac4403b45143f", size = 691029 }, - { url = "https://files.pythonhosted.org/packages/64/e1/612c3b8afa448747e9133a95b1e18d0ee8f3f5e930f7048d1af03976f883/regress-2025.10.1-cp313-cp313t-win32.whl", hash = "sha256:877e05e7c570ee1e077e8b587cca8a318b7675f3c94c6c4e25d0d145abf7c0b6", size = 282136 }, - { url = "https://files.pythonhosted.org/packages/51/1d/9d5a476a6b16a41e724e864afb834947323bfa4b309978dbdc2bf8dbdb02/regress-2025.10.1-cp313-cp313t-win_amd64.whl", hash = "sha256:97307f87b128389d8b3f385c8e431fc318263281d1a1c0394606bc813aea05fc", size = 301674 }, +sdist = { url = "https://files.pythonhosted.org/packages/02/bf/faa406189856f9b566fa1c42d173188d3e86cd5116484a663922365e0004/regress-2025.10.1.tar.gz", hash = "sha256:dcc0a8af0cdbc3d6e0d4725f113335d0a5ffbba86ae3ca18d2b5b352c5f2c8ed", size = 11567, upload-time = "2025-10-09T07:09:48.397Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/bd/f7ff13577c688efec36488554489635c135ab7c2b3652c00aaa3d74bd761/regress-2025.10.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9100da34f69e18ad6d545f5d74f8ee729e42ce200a73752dd6d94f8a373d0e71", size = 445469, upload-time = "2025-10-09T07:08:16.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/3d/e8fc72e7424c168dfc4cdf3a864f189f6d3afbb6e3ed66455774d1f5e2ba/regress-2025.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e25096697b034848d8fc7910cdb38b7abc2bd2d7ade8767893359918ed4efd0", size = 434712, upload-time = "2025-10-09T07:08:17.817Z" }, + { url = "https://files.pythonhosted.org/packages/63/d0/a18cc39008765d2b964bc2eba423416545d97b4f2623b99549c5e97e4d37/regress-2025.10.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c4c40a8c9f3e0119d2384a52b55dcc770461e1ead6ae7b41314999223116a15", size = 514752, upload-time = "2025-10-09T07:08:19.148Z" }, + { url = "https://files.pythonhosted.org/packages/0b/11/57e64ba417867b39ad9ca55feaea80d3176735dba36e6a92ff21b8224fcb/regress-2025.10.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25d8518285aa3ce67ddbede90a1a9ca6a5d23a1b8275dd5a9722af0c64b37b2a", size = 497412, upload-time = "2025-10-09T07:08:20.448Z" }, + { url = "https://files.pythonhosted.org/packages/72/6c/2fd3d3877c1957408548f7a380580f0b64ef8aff69f7fc2cc182a79b0b0b/regress-2025.10.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8051fac3696730bb84d7675c62c7073792a0e105233d4f8e1055f2cab9b04fce", size = 676998, upload-time = "2025-10-09T07:08:21.881Z" }, + { url = "https://files.pythonhosted.org/packages/cd/54/acde146473453f3acb5dd29e5be0fb627d87ad020ec5674ee133cde24261/regress-2025.10.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8e337ce3b150ca0ff599b1150b995ff6a2e32b5940e17ac3f30a29133960709e", size = 576129, upload-time = "2025-10-09T07:08:23.354Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b0/8007436b33496ec8f10d7b7a67a7a669569c82fc2a182d4f928ab4fd11da/regress-2025.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:290a15652c3fafe387db02061d4ed9a100804ed5a162d069b5a0ac28b5df162a", size = 507510, upload-time = "2025-10-09T07:08:24.34Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/6ac6cd1de7e34e34cb492df627847de0d01cee982c7dd7d100e67cf1aa93/regress-2025.10.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b4bc2008b59e5124c1672d7fd9f203e5d4a4ff88ebaf4666e3281141e2d8db20", size = 520595, upload-time = "2025-10-09T07:08:25.35Z" }, + { url = "https://files.pythonhosted.org/packages/11/26/35556d1dd6f64122a16166db7651d1a5804edebea9835cbcd452d9b7c2ff/regress-2025.10.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:56ef2f8ef9a102a7d42cbbc2f3c0c5e1b186bd8eaa78d564da566b0bf20653dc", size = 695584, upload-time = "2025-10-09T07:08:26.52Z" }, + { url = "https://files.pythonhosted.org/packages/e9/33/07650dfc042b55270fc269276112c592d458cdfb8d31c85447ff743dcf10/regress-2025.10.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0680ac0b0a0058acb55b64aa56956732cf56b0baa84ea95e3fa124ab16da58aa", size = 695277, upload-time = "2025-10-09T07:08:27.705Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6b/80f446103df39fc1ce6e271e7c274687bb830402de23e68795ba2ab13214/regress-2025.10.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47c03bfd853651241fc11436fb14ef7c9b312bb9a9c2828aa5c93943e945f1ef", size = 690951, upload-time = "2025-10-09T07:08:29.295Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/43cd990934ddb2be3318380fa380f7ca19e0eda0c30793b73e8ab8839367/regress-2025.10.1-cp313-cp313-win32.whl", hash = "sha256:3d12e6a834ed5f6d9dc7e86ea8fd77d37bb13900129930151d87c3261c3a799e", size = 282592, upload-time = "2025-10-09T07:08:30.695Z" }, + { url = "https://files.pythonhosted.org/packages/ab/22/4855af0447c98793d3a406ddf42680736e700b8c78336d6199dff9a5fd1b/regress-2025.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:ee3b7325ea849097d674020c72b2e6deb8f1018f085a7ecbd22831cd217b7f80", size = 301551, upload-time = "2025-10-09T07:08:31.715Z" }, + { url = "https://files.pythonhosted.org/packages/82/99/cd16dce5d1cb98970a8d7c577529e5890d938b54ade223d532fad0343221/regress-2025.10.1-cp313-cp313-win_arm64.whl", hash = "sha256:e5f35e04fe6382c236d60c98b3f0a4a22dea75398b99c9c9bf3fb9d386cd7ebb", size = 288909, upload-time = "2025-10-09T07:08:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/a3/32/35dbbdff29174dcc253bb00b2a0d2aaf3f47a2b1d174ab4ab828feca63f1/regress-2025.10.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:0ee12c4e1c4e6e3609f41dd58065bc241945e912772f7320238d6544f0745950", size = 444440, upload-time = "2025-10-09T07:08:33.635Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1b/d97cf19b3de5ff8d78d03e0c470af48f7e8b6814b197014fda3f8a3aaca5/regress-2025.10.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:389c2278848cbfed81753a04ce8ea6c037271179cb9ef4decac7d3c65ae3330b", size = 434626, upload-time = "2025-10-09T07:08:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/04/f1/c0900448a4fd6248cd05c04c83aebb31f22ba3886965a5667f6ac4edc2b0/regress-2025.10.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf743b00cac20f8e8d271f275df7bc192ebb4faa7aee8fe98484df338786dec6", size = 514470, upload-time = "2025-10-09T07:08:35.75Z" }, + { url = "https://files.pythonhosted.org/packages/11/a1/6346a314230af2859d2f0af5c4534ee497b9b13983ee06489652d7e1895b/regress-2025.10.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5541da1f581377bc20d2f77e017453aa8f2c2f4bfe7679dee00e139ec700abe8", size = 497546, upload-time = "2025-10-09T07:08:36.739Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/09605137dff09594c9e12ce555a80d8405a6ad76faa1db702bfba57468d0/regress-2025.10.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a67216efa72bb27db170f637d67eb9acdc167da5d617163b057803de7aed1e6d", size = 676318, upload-time = "2025-10-09T07:08:37.72Z" }, + { url = "https://files.pythonhosted.org/packages/26/37/4a65635c18844d687f9ad5bdd79b72fac8d15b131264616d6b1bb52614c2/regress-2025.10.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fb61e653a325aea4681cf7b96ba9bbabc1aeb3f3d8fe877a07800024907398b9", size = 576190, upload-time = "2025-10-09T07:08:38.782Z" }, + { url = "https://files.pythonhosted.org/packages/93/87/90a0a77a0416d7e3255e606f848021949b2f53ca18c9e4e240edb42e0929/regress-2025.10.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2a0a7da1d42fdff8068ab976a66beea572621514a130b37592489ae134a0e27", size = 507768, upload-time = "2025-10-09T07:08:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/82c8147027f0012dd7e743267d60dcf4218b2e061825b89cddddaa7fbe49/regress-2025.10.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:337ef62f785dc6e05c4a09be7a902980cf4d4f15346f4eca9eaf02745485440c", size = 520555, upload-time = "2025-10-09T07:08:40.801Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/5dd5dda048173b644a475ae9f4643733b31c3543e861569af4257c60d32a/regress-2025.10.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:aaf5d102e05b109dde13363e705969b3ffdbbfeb880270187eed0871e75f0c8f", size = 695587, upload-time = "2025-10-09T07:08:42.136Z" }, + { url = "https://files.pythonhosted.org/packages/9e/08/7f6615d8ce3088feb82843ff4d48191600602d624021dbef797d274a043d/regress-2025.10.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:adf331c8938e0d8705fc5d05d08fab09c11cb4bcdf8a64fa21902972c4cd38f4", size = 694933, upload-time = "2025-10-09T07:08:43.171Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/94524b996745f892b30dec6e770701956f7125459c6a6b2c075af4b8f0a6/regress-2025.10.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:287f86b5c0bf3bc9c0abd45bf6745ba9c6a5624c3132b07631bac4403b45143f", size = 691029, upload-time = "2025-10-09T07:08:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/64/e1/612c3b8afa448747e9133a95b1e18d0ee8f3f5e930f7048d1af03976f883/regress-2025.10.1-cp313-cp313t-win32.whl", hash = "sha256:877e05e7c570ee1e077e8b587cca8a318b7675f3c94c6c4e25d0d145abf7c0b6", size = 282136, upload-time = "2025-10-09T07:08:45.477Z" }, + { url = "https://files.pythonhosted.org/packages/51/1d/9d5a476a6b16a41e724e864afb834947323bfa4b309978dbdc2bf8dbdb02/regress-2025.10.1-cp313-cp313t-win_amd64.whl", hash = "sha256:97307f87b128389d8b3f385c8e431fc318263281d1a1c0394606bc813aea05fc", size = 301674, upload-time = "2025-10-09T07:08:46.513Z" }, ] [[package]] @@ -4237,9 +4237,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856 } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -4250,9 +4250,9 @@ dependencies = [ { name = "oauthlib" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, ] [[package]] @@ -4262,9 +4262,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513 } +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490 }, + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, ] [[package]] @@ -4275,9 +4275,9 @@ dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] @@ -4288,83 +4288,83 @@ dependencies = [ { name = "pygments" }, { name = "rich" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732 } +sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987 }, + { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, ] [[package]] name = "ripgrep-rs" version = "0.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/8c/de0b9456c057f56454a0269e536949405001ce97b96d18797c99ea724767/ripgrep_rs-0.4.2.tar.gz", hash = "sha256:a109ad6a59f83a3cd71b274a16d722637414feb6a43ab43764fafe65206e5795", size = 61127 } +sdist = { url = "https://files.pythonhosted.org/packages/7d/8c/de0b9456c057f56454a0269e536949405001ce97b96d18797c99ea724767/ripgrep_rs-0.4.2.tar.gz", hash = "sha256:a109ad6a59f83a3cd71b274a16d722637414feb6a43ab43764fafe65206e5795", size = 61127, upload-time = "2026-02-07T09:50:39.88Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/32/4ac331c46b0cf3965c507b43e95802a709b41e8b5b07f033769d6d03c90b/ripgrep_rs-0.4.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:53127ef9b69a97f34fd2ba61810fa20e7ff98541c4adaa7b4d46cbbbb8163582", size = 1774241 }, - { url = "https://files.pythonhosted.org/packages/91/d4/dadfbcdf81393834889002e4ff2bcbb6218c90c154cf96693153ebe928ea/ripgrep_rs-0.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b50783ef15ec317f86049eea0fe33f8b277b89d0e3eff394300e3db9abc63d1f", size = 1728477 }, - { url = "https://files.pythonhosted.org/packages/05/1a/b8961b8bc0878883e0ca404345a81ae5c694ceeeda917f529bd76f77cb83/ripgrep_rs-0.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cba85d71eeaaef013687073035ee1347c10a017948091d2853cf47d13f310f2e", size = 1925332 }, - { url = "https://files.pythonhosted.org/packages/44/31/130c04ad66e09cdec9dc34af35910be75572cc8b257aeeae4fd9d3911061/ripgrep_rs-0.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8834718d3f0228d9d5bfd817bb2154f74891abc257553c6014117e9cfc374d1", size = 1932723 }, - { url = "https://files.pythonhosted.org/packages/4d/63/1db6dbd943bf0110baa794e4c478d832afb740fecdd27f7800123fde081b/ripgrep_rs-0.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:fcde19c665343365625a790c81e33b9190cebe445af2135329eb243cda5aee33", size = 1583932 }, - { url = "https://files.pythonhosted.org/packages/d7/c6/3e0c9cd1ffb98bf25c087db3e5d588f9a58b13b11b576dba5d6e4d2e2905/ripgrep_rs-0.4.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a78cee10e18ac93ecef63bd88267f0e193e73b31cb64ee3ec155885253221e26", size = 1774040 }, - { url = "https://files.pythonhosted.org/packages/f5/5d/5f2fb2c148c37a848d16b3605366ff41662663d3d1c936c6a81ef952f9b6/ripgrep_rs-0.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:161592c6749d5ef04f8240e400ab2b4fe8087d0ee4f3199c30f17ee0ba73b284", size = 1727369 }, - { url = "https://files.pythonhosted.org/packages/73/4e/bf49e3ee7b0359abd1b99e0eac0b701d44cb0645df9eaa6e4ce9ee2761c9/ripgrep_rs-0.4.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5cf0c934f00d3acfb646509ae8ea9f9a12be6f85ba4deaae180e29ba7f9fe8c", size = 1926431 }, - { url = "https://files.pythonhosted.org/packages/ab/ba/f4f5e2a1fd49da57cb07b752ab61e9ec8afcdff5af423a8c698fa64a523d/ripgrep_rs-0.4.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:683138a58ca29641519f7932c8ca3bff6bf9a78b8744145f68299b3c818dab33", size = 1932875 }, + { url = "https://files.pythonhosted.org/packages/10/32/4ac331c46b0cf3965c507b43e95802a709b41e8b5b07f033769d6d03c90b/ripgrep_rs-0.4.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:53127ef9b69a97f34fd2ba61810fa20e7ff98541c4adaa7b4d46cbbbb8163582", size = 1774241, upload-time = "2026-02-07T09:50:10.046Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/dadfbcdf81393834889002e4ff2bcbb6218c90c154cf96693153ebe928ea/ripgrep_rs-0.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b50783ef15ec317f86049eea0fe33f8b277b89d0e3eff394300e3db9abc63d1f", size = 1728477, upload-time = "2026-02-07T09:50:11.856Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/b8961b8bc0878883e0ca404345a81ae5c694ceeeda917f529bd76f77cb83/ripgrep_rs-0.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cba85d71eeaaef013687073035ee1347c10a017948091d2853cf47d13f310f2e", size = 1925332, upload-time = "2026-02-07T09:50:13.765Z" }, + { url = "https://files.pythonhosted.org/packages/44/31/130c04ad66e09cdec9dc34af35910be75572cc8b257aeeae4fd9d3911061/ripgrep_rs-0.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8834718d3f0228d9d5bfd817bb2154f74891abc257553c6014117e9cfc374d1", size = 1932723, upload-time = "2026-02-07T09:50:15.159Z" }, + { url = "https://files.pythonhosted.org/packages/4d/63/1db6dbd943bf0110baa794e4c478d832afb740fecdd27f7800123fde081b/ripgrep_rs-0.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:fcde19c665343365625a790c81e33b9190cebe445af2135329eb243cda5aee33", size = 1583932, upload-time = "2026-02-07T09:50:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c6/3e0c9cd1ffb98bf25c087db3e5d588f9a58b13b11b576dba5d6e4d2e2905/ripgrep_rs-0.4.2-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a78cee10e18ac93ecef63bd88267f0e193e73b31cb64ee3ec155885253221e26", size = 1774040, upload-time = "2026-02-07T09:50:17.904Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5d/5f2fb2c148c37a848d16b3605366ff41662663d3d1c936c6a81ef952f9b6/ripgrep_rs-0.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:161592c6749d5ef04f8240e400ab2b4fe8087d0ee4f3199c30f17ee0ba73b284", size = 1727369, upload-time = "2026-02-07T09:50:19.705Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/bf49e3ee7b0359abd1b99e0eac0b701d44cb0645df9eaa6e4ce9ee2761c9/ripgrep_rs-0.4.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5cf0c934f00d3acfb646509ae8ea9f9a12be6f85ba4deaae180e29ba7f9fe8c", size = 1926431, upload-time = "2026-02-07T09:50:21.485Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ba/f4f5e2a1fd49da57cb07b752ab61e9ec8afcdff5af423a8c698fa64a523d/ripgrep_rs-0.4.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:683138a58ca29641519f7932c8ca3bff6bf9a78b8744145f68299b3c818dab33", size = 1932875, upload-time = "2026-02-07T09:50:23.304Z" }, ] [[package]] name = "rpds-py" version = "2026.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012 }, - { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203 }, - { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984 }, - { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815 }, - { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545 }, - { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828 }, - { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678 }, - { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811 }, - { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382 }, - { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832 }, - { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011 }, - { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431 }, - { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710 }, - { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454 }, - { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063 }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, ] [[package]] name = "ruamel-yaml" version = "0.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709 } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102 }, + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" }, ] [[package]] name = "ruff" version = "0.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557 } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633 }, - { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164 }, - { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846 }, - { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729 }, - { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275 }, - { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112 }, - { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008 }, - { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842 }, - { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718 }, - { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177 }, - { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126 }, - { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208 }, - { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329 }, - { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751 }, - { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885 }, - { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141 }, - { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407 }, + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] [[package]] @@ -4374,18 +4374,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/1a/545aa3a3e251e9da00e4acf0e5472b7fbbb15360f56d9d5342c1f93b8b93/rustworkx-0.18.0.tar.gz", hash = "sha256:5ca9cf8dbee50f8def012119ebb64771ae86916993417415aba9e845831cb436", size = 894567 } +sdist = { url = "https://files.pythonhosted.org/packages/ff/1a/545aa3a3e251e9da00e4acf0e5472b7fbbb15360f56d9d5342c1f93b8b93/rustworkx-0.18.0.tar.gz", hash = "sha256:5ca9cf8dbee50f8def012119ebb64771ae86916993417415aba9e845831cb436", size = 894567, upload-time = "2026-06-18T04:38:56.141Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/70/18b310752b0652d4a755b46853268c00c33401101a47f87697ad25966453/rustworkx-0.18.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7440ba70b87bd16811d92e57d76108840dbdd89aa2fc55e4d324fa2fb6b7f9c9", size = 2295936 }, - { url = "https://files.pythonhosted.org/packages/02/4e/09152f4422204f020346a8a2b0e2f05f12d8cc60eeeee99b24f649985514/rustworkx-0.18.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7fce5218ebfb8d8f5313def1f15aad8d3c3c2bbe03e33805dff8cf8bb70370a1", size = 2145057 }, - { url = "https://files.pythonhosted.org/packages/22/68/69198f41f7f9c39fb5b2e561bf41d4cbd117fa5dfdf4631d8ba28e5ff69b/rustworkx-0.18.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7e0c626f76bc71d414a02502be7ec0ac2dd6eca369886bc66a2650607f2d9de6", size = 2391659 }, - { url = "https://files.pythonhosted.org/packages/e1/81/8ac568efe0289b0ecd826cd697c3581b6a25e134145926cbfef762656167/rustworkx-0.18.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:91b37c5bb54e233e16c4f47383385f17be03e6a344821c2f6a39a0aebee54538", size = 2189469 }, - { url = "https://files.pythonhosted.org/packages/f2/a4/96492d7af2ddcd15368f80329e3514060b122f306c4406fac98c08a97865/rustworkx-0.18.0-cp310-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:bcd15f7a637ca0654f329ed42a6db0cf7eac15ed89e0b32cb9d16b1b21937979", size = 2504116 }, - { url = "https://files.pythonhosted.org/packages/4d/b0/005383bd7dc110b06ca731bd2115482f3a8c6389e0c8fe1f7f259380c4c8/rustworkx-0.18.0-cp310-abi3-manylinux_2_28_s390x.whl", hash = "sha256:567ace3b0d8ac709dc4ad4ce164472f5f59508739355221480170327a6736777", size = 3170967 }, - { url = "https://files.pythonhosted.org/packages/0d/2f/95ca9ef9285cc6793b3e041efca6c5a194ca6731b0af732631ee074a67b3/rustworkx-0.18.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b91cdcffeab5f498de44905a373e47e345da8f6c4f40fc969421548c4e7a5219", size = 2254008 }, - { url = "https://files.pythonhosted.org/packages/19/7b/9df6a80162e6f90fd9945e3f8063c2aa41285c27aefce1d171f99c2b829f/rustworkx-0.18.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03dd1ab42974bc0ff957eccc330c0c0d9887d88240c0cd050039e215af5a3c45", size = 2447140 }, - { url = "https://files.pythonhosted.org/packages/5d/b3/4d159b33bbc73dbebad050a0226dcfb0753d7f05fa55a8d5351e85c8c953/rustworkx-0.18.0-cp310-abi3-win32.whl", hash = "sha256:eb87a45864ffe36e4d86e826d12bb54dac9e4f76c214997dee624281d8711684", size = 2057237 }, - { url = "https://files.pythonhosted.org/packages/99/1b/b0dc8d3751c0c58d16db72bff2954a9d3871c166147007590a3aaec4d42e/rustworkx-0.18.0-cp310-abi3-win_amd64.whl", hash = "sha256:602df896b4479b83c6456f702f8ba2ac1cbb972b30723d5fe2e84e6ff3de7d70", size = 2289224 }, + { url = "https://files.pythonhosted.org/packages/2a/70/18b310752b0652d4a755b46853268c00c33401101a47f87697ad25966453/rustworkx-0.18.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7440ba70b87bd16811d92e57d76108840dbdd89aa2fc55e4d324fa2fb6b7f9c9", size = 2295936, upload-time = "2026-06-18T04:38:01.598Z" }, + { url = "https://files.pythonhosted.org/packages/02/4e/09152f4422204f020346a8a2b0e2f05f12d8cc60eeeee99b24f649985514/rustworkx-0.18.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7fce5218ebfb8d8f5313def1f15aad8d3c3c2bbe03e33805dff8cf8bb70370a1", size = 2145057, upload-time = "2026-06-18T04:38:03.253Z" }, + { url = "https://files.pythonhosted.org/packages/22/68/69198f41f7f9c39fb5b2e561bf41d4cbd117fa5dfdf4631d8ba28e5ff69b/rustworkx-0.18.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7e0c626f76bc71d414a02502be7ec0ac2dd6eca369886bc66a2650607f2d9de6", size = 2391659, upload-time = "2026-06-18T04:38:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/e1/81/8ac568efe0289b0ecd826cd697c3581b6a25e134145926cbfef762656167/rustworkx-0.18.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:91b37c5bb54e233e16c4f47383385f17be03e6a344821c2f6a39a0aebee54538", size = 2189469, upload-time = "2026-06-18T04:38:06.51Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a4/96492d7af2ddcd15368f80329e3514060b122f306c4406fac98c08a97865/rustworkx-0.18.0-cp310-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:bcd15f7a637ca0654f329ed42a6db0cf7eac15ed89e0b32cb9d16b1b21937979", size = 2504116, upload-time = "2026-06-18T04:38:43.441Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b0/005383bd7dc110b06ca731bd2115482f3a8c6389e0c8fe1f7f259380c4c8/rustworkx-0.18.0-cp310-abi3-manylinux_2_28_s390x.whl", hash = "sha256:567ace3b0d8ac709dc4ad4ce164472f5f59508739355221480170327a6736777", size = 3170967, upload-time = "2026-06-18T04:38:39.929Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2f/95ca9ef9285cc6793b3e041efca6c5a194ca6731b0af732631ee074a67b3/rustworkx-0.18.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b91cdcffeab5f498de44905a373e47e345da8f6c4f40fc969421548c4e7a5219", size = 2254008, upload-time = "2026-06-18T04:38:08.245Z" }, + { url = "https://files.pythonhosted.org/packages/19/7b/9df6a80162e6f90fd9945e3f8063c2aa41285c27aefce1d171f99c2b829f/rustworkx-0.18.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03dd1ab42974bc0ff957eccc330c0c0d9887d88240c0cd050039e215af5a3c45", size = 2447140, upload-time = "2026-06-18T04:38:09.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b3/4d159b33bbc73dbebad050a0226dcfb0753d7f05fa55a8d5351e85c8c953/rustworkx-0.18.0-cp310-abi3-win32.whl", hash = "sha256:eb87a45864ffe36e4d86e826d12bb54dac9e4f76c214997dee624281d8711684", size = 2057237, upload-time = "2026-06-18T04:38:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/99/1b/b0dc8d3751c0c58d16db72bff2954a9d3871c166147007590a3aaec4d42e/rustworkx-0.18.0-cp310-abi3-win_amd64.whl", hash = "sha256:602df896b4479b83c6456f702f8ba2ac1cbb972b30723d5fe2e84e6ff3de7d70", size = 2289224, upload-time = "2026-06-18T04:38:12.961Z" }, ] [[package]] @@ -4398,9 +4398,9 @@ dependencies = [ { name = "pydantic" }, { name = "universal-pathlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/f9/394ef53eca56a08448feebd4645fc1f10719e9286faaaf19b02277ec03be/schemez-2.2.29.tar.gz", hash = "sha256:801fd18eeda003c061f43d1d8943fe206fd3d64dc2da0531bda49334c9056579", size = 76147 } +sdist = { url = "https://files.pythonhosted.org/packages/55/f9/394ef53eca56a08448feebd4645fc1f10719e9286faaaf19b02277ec03be/schemez-2.2.29.tar.gz", hash = "sha256:801fd18eeda003c061f43d1d8943fe206fd3d64dc2da0531bda49334c9056579", size = 76147, upload-time = "2026-03-16T01:02:52.627Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/97/086c3f07ba3a0bbe13e553e59666bba49e7393f3a9cb451e6dc3867c2ea8/schemez-2.2.29-py3-none-any.whl", hash = "sha256:7517451ee5c0a8d3b3ebcd5327622ac3c8e9b09e3f7fad476c5ffd9e78f16d47", size = 94033 }, + { url = "https://files.pythonhosted.org/packages/d8/97/086c3f07ba3a0bbe13e553e59666bba49e7393f3a9cb451e6dc3867c2ea8/schemez-2.2.29-py3-none-any.whl", hash = "sha256:7517451ee5c0a8d3b3ebcd5327622ac3c8e9b09e3f7fad476c5ffd9e78f16d47", size = 94033, upload-time = "2026-03-16T01:02:51.055Z" }, ] [package.optional-dependencies] @@ -4418,9 +4418,9 @@ dependencies = [ { name = "schemez" }, { name = "universal-pathlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/99/adf947a5815289e0aea96080e2714b31212331701647cc8a12789ffd91cf/searchly-2.1.0.tar.gz", hash = "sha256:63b861aecfb2938b5484a4566eeb4e8909cd6b8ea468d80490af69bbd490fe7c", size = 17852 } +sdist = { url = "https://files.pythonhosted.org/packages/f0/99/adf947a5815289e0aea96080e2714b31212331701647cc8a12789ffd91cf/searchly-2.1.0.tar.gz", hash = "sha256:63b861aecfb2938b5484a4566eeb4e8909cd6b8ea468d80490af69bbd490fe7c", size = 17852, upload-time = "2026-01-04T06:50:14.096Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/67/69/02d619394e196cc4b57b841790f769c6fcf2050ad0249880a0e1b9064ab6/searchly-2.1.0-py3-none-any.whl", hash = "sha256:70272f8e7e6c08ffd6ac2963e9c7aba6769afe97295b1030fe88abc8cea6c310", size = 32982 }, + { url = "https://files.pythonhosted.org/packages/67/69/02d619394e196cc4b57b841790f769c6fcf2050ad0249880a0e1b9064ab6/searchly-2.1.0-py3-none-any.whl", hash = "sha256:70272f8e7e6c08ffd6ac2963e9c7aba6769afe97295b1030fe88abc8cea6c310", size = 32982, upload-time = "2026-01-04T06:50:15.455Z" }, ] [package.optional-dependencies] @@ -4435,57 +4435,57 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography", marker = "sys_platform != 'win32'" }, - { name = "jeepney", marker = "sys_platform != 'win32'" }, + { name = "cryptography" }, + { name = "jeepney" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884 } +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554 }, + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] [[package]] name = "semver" version = "3.0.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730 } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912 }, + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, ] [[package]] name = "setuptools" version = "83.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254 } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090 }, + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] [[package]] name = "shellingham" version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] [[package]] name = "six" version = "1.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] [[package]] name = "slack-sdk" version = "3.43.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07", size = 252769 } +sdist = { url = "https://files.pythonhosted.org/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07", size = 252769, upload-time = "2026-06-30T18:04:41.59Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585", size = 315866 }, + { url = "https://files.pythonhosted.org/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585", size = 315866, upload-time = "2026-06-30T18:04:39.636Z" }, ] [[package]] @@ -4495,9 +4495,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8d/07/e6f7fe419b21e7bc198f62c9074f7d5ab398b67dc455ccb3190dc31f91f7/slackify_markdown-0.2.4.tar.gz", hash = "sha256:90b57c0a4ef81306c2208573ab4d3afc945797eed0dd04c4876b4a0ca7222619", size = 13604 } +sdist = { url = "https://files.pythonhosted.org/packages/8d/07/e6f7fe419b21e7bc198f62c9074f7d5ab398b67dc455ccb3190dc31f91f7/slackify_markdown-0.2.4.tar.gz", hash = "sha256:90b57c0a4ef81306c2208573ab4d3afc945797eed0dd04c4876b4a0ca7222619", size = 13604, upload-time = "2026-05-25T15:04:08.074Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/36/b4f9788907b95b5f217fa9602c94f97ceb2af902aaa2396f37ee7b1c74a0/slackify_markdown-0.2.4-py3-none-any.whl", hash = "sha256:5dd7539facafb6c0add7d895bf3501ba25cd7539ee8e06fc0a3adfef237d647c", size = 7453 }, + { url = "https://files.pythonhosted.org/packages/b9/36/b4f9788907b95b5f217fa9602c94f97ceb2af902aaa2396f37ee7b1c74a0/slackify_markdown-0.2.4-py3-none-any.whl", hash = "sha256:5dd7539facafb6c0add7d895bf3501ba25cd7539ee8e06fc0a3adfef237d647c", size = 7453, upload-time = "2026-05-25T15:04:06.825Z" }, ] [[package]] @@ -4509,36 +4509,36 @@ dependencies = [ { name = "psygnal" }, { name = "universal-pathlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/08/667f754d5e9ae6b59fd93d5f91e4bb70a54ca6daff00daa117748c4e9a10/slashed-1.2.1.tar.gz", hash = "sha256:35928d84c7813ae6a744aa4a772d1526b56d69b7a8a8d42005ae3b1667505ee5", size = 48309 } +sdist = { url = "https://files.pythonhosted.org/packages/87/08/667f754d5e9ae6b59fd93d5f91e4bb70a54ca6daff00daa117748c4e9a10/slashed-1.2.1.tar.gz", hash = "sha256:35928d84c7813ae6a744aa4a772d1526b56d69b7a8a8d42005ae3b1667505ee5", size = 48309, upload-time = "2026-02-26T12:10:22.819Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/b6/906a4894d28eda3b15d2409aebf0943826b3aa1b611528ef5434c44b47f8/slashed-1.2.1-py3-none-any.whl", hash = "sha256:e6b381d6e5f6d6bb9529c2be0c8f9be83687d5797f99839609b78fc13f166848", size = 61284 }, + { url = "https://files.pythonhosted.org/packages/c3/b6/906a4894d28eda3b15d2409aebf0943826b3aa1b611528ef5434c44b47f8/slashed-1.2.1-py3-none-any.whl", hash = "sha256:e6b381d6e5f6d6bb9529c2be0c8f9be83687d5797f99839609b78fc13f166848", size = 61284, upload-time = "2026-02-26T12:10:21.45Z" }, ] [[package]] name = "smmap" version = "5.0.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506 } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390 }, + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, ] [[package]] name = "sniffio" version = "1.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] [[package]] name = "socksio" version = "1.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055 } +sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763 }, + { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" }, ] [[package]] @@ -4548,22 +4548,22 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191 } +sdist = { url = "https://files.pythonhosted.org/packages/2a/f9/2592608737553638fca98e21e54bfec40bf577bb98a61b2770c912aab25e/sounddevice-0.5.5.tar.gz", hash = "sha256:22487b65198cb5bf2208755105b524f78ad173e5ab6b445bdab1c989f6698df3", size = 143191, upload-time = "2026-01-23T18:36:43.529Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807 }, - { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557 }, - { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765 }, - { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324 }, - { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115 }, + { url = "https://files.pythonhosted.org/packages/1e/0a/478e441fd049002cf308520c0d62dd8333e7c6cc8d997f0dda07b9fbcc46/sounddevice-0.5.5-py3-none-any.whl", hash = "sha256:30ff99f6c107f49d25ad16a45cacd8d91c25a1bcdd3e81a206b921a3a6405b1f", size = 32807, upload-time = "2026-01-23T18:36:35.649Z" }, + { url = "https://files.pythonhosted.org/packages/56/f9/c037c35f6d0b6bc3bc7bfb314f1d6f1f9a341328ef47cd63fc4f850a7b27/sounddevice-0.5.5-py3-none-macosx_10_6_x86_64.macosx_10_6_universal2.whl", hash = "sha256:05eb9fd6c54c38d67741441c19164c0dae8ce80453af2d8c4ad2e7823d15b722", size = 108557, upload-time = "2026-01-23T18:36:37.41Z" }, + { url = "https://files.pythonhosted.org/packages/88/a1/d19dd9889cd4bce2e233c4fac007cd8daaf5b9fe6e6a5d432cf17be0b807/sounddevice-0.5.5-py3-none-win32.whl", hash = "sha256:1234cc9b4c9df97b6cbe748146ae0ec64dd7d6e44739e8e42eaa5b595313a103", size = 317765, upload-time = "2026-01-23T18:36:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0e/002ed7c4c1c2ab69031f78989d3b789fee3a7fba9e586eb2b81688bf4961/sounddevice-0.5.5-py3-none-win_amd64.whl", hash = "sha256:cfc6b2c49fb7f555591c78cb8ecf48d6a637fd5b6e1db5fec6ed9365d64b3519", size = 365324, upload-time = "2026-01-23T18:36:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/4e/39/a61d4b83a7746b70d23d9173be688c0c6bfc7173772344b7442c2c155497/sounddevice-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3861901ddd8230d2e0e8ae62ac320cdd4c688d81df89da036dcb812f757bb3e6", size = 317115, upload-time = "2026-01-23T18:36:42.235Z" }, ] [[package]] name = "soupsieve" version = "2.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261 } +sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404 }, + { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" }, ] [[package]] @@ -4574,16 +4574,16 @@ dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201 } +sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184 }, - { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735 }, - { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756 }, - { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055 }, - { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850 }, - { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721 }, - { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615 }, - { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334 }, + { url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" }, + { url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" }, + { url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" }, + { url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" }, ] [package.optional-dependencies] @@ -4602,9 +4602,9 @@ dependencies = [ { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/68/ee/22a0559283c3cf6048678e787ed5d4959dcd00dedd8ba4567eeae684eeb1/sqlmodel-0.0.39.tar.gz", hash = "sha256:23d8e50a8d8ee936032ed79c55023a5d618dd6bc3c510bbf4909d1a7a605a570", size = 91057 } +sdist = { url = "https://files.pythonhosted.org/packages/68/ee/22a0559283c3cf6048678e787ed5d4959dcd00dedd8ba4567eeae684eeb1/sqlmodel-0.0.39.tar.gz", hash = "sha256:23d8e50a8d8ee936032ed79c55023a5d618dd6bc3c510bbf4909d1a7a605a570", size = 91057, upload-time = "2026-06-25T13:01:38.475Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/7d/b9813a582d4eb310be35e1fc7dfaae71207d7b62e9e53be314ebd251b53b/sqlmodel-0.0.39-py3-none-any.whl", hash = "sha256:90ebe92ce5cc11d7fff8dc7cb594790a102333c8fe7c14865254f6fc5c939795", size = 29680 }, + { url = "https://files.pythonhosted.org/packages/cf/7d/b9813a582d4eb310be35e1fc7dfaae71207d7b62e9e53be314ebd251b53b/sqlmodel-0.0.39-py3-none-any.whl", hash = "sha256:90ebe92ce5cc11d7fff8dc7cb594790a102333c8fe7c14865254f6fc5c939795", size = 29680, upload-time = "2026-06-25T13:01:37.494Z" }, ] [[package]] @@ -4615,9 +4615,9 @@ dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542 } +sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516 }, + { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, ] [[package]] @@ -4625,7 +4625,7 @@ name = "sseclient-py" version = "1.9.0" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/2e/59920f7d66b7f9932a3d83dd0ec53fab001be1e058bf582606fe414a5198/sseclient_py-1.9.0-py3-none-any.whl", hash = "sha256:340062b1587fc2880892811e2ab5b176d98ef3eee98b3672ff3a3ba1e8ed0f6f", size = 8351 }, + { url = "https://files.pythonhosted.org/packages/4d/2e/59920f7d66b7f9932a3d83dd0ec53fab001be1e058bf582606fe414a5198/sseclient_py-1.9.0-py3-none-any.whl", hash = "sha256:340062b1587fc2880892811e2ab5b176d98ef3eee98b3672ff3a3ba1e8ed0f6f", size = 8351, upload-time = "2026-01-02T23:39:30.995Z" }, ] [[package]] @@ -4635,47 +4635,47 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240 } +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632 }, + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] [[package]] name = "structlog" version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881 } +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764 }, + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, ] [[package]] name = "sublime-search" version = "0.5.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c9/134ea2f0569f432439e8d5d4b834a9445064720bec13f9304aaa836cdb34/sublime_search-0.5.0.tar.gz", hash = "sha256:c316df5cc5481e602205a5abff5e669b1bb960afb25e6578abfc15a23bdd1ce1", size = 44314 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/c8/848124cf507fb58956ea4058045e654cbfac9fb684a85435e67d9d62c0e9/sublime_search-0.5.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6f7edd7dd33aac1036af793f0bf8fc0d65233803280a9ac9d7fb01c54a19cb9f", size = 970575 }, - { url = "https://files.pythonhosted.org/packages/60/5b/360045d55180358b242a913d11528745e5fa001575606dc7a88c8346c168/sublime_search-0.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dda52fc8c1cfd23764813a70f767f62fcd78fb703759e73fd69d5902c6a8df9", size = 923303 }, - { url = "https://files.pythonhosted.org/packages/90/36/e3d4542b8bc3ab244f0405e2da9e89f4fe60a07d7f6a6b427d5a9e815e63/sublime_search-0.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5eba812aaa5335b8d18f741ba6a8f8a9ce34fb277021e809738991ac88a34b5", size = 1044339 }, - { url = "https://files.pythonhosted.org/packages/d0/63/8b77d4bdf761abc41edd616dfda907b2bd0d82b6b43ad1c28958a9474150/sublime_search-0.5.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5393bc2abe371aa1c49c99ddcb170a2a2526f6e99cd724aeb2621e63e776d9ab", size = 999821 }, - { url = "https://files.pythonhosted.org/packages/e0/89/4c32e535172269899f1b92ec75b80e8b21a6f3f806831e46baa982ece336/sublime_search-0.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8586c501e25c8f148643052b3441729190e390115e480051da6c05f00848fa71", size = 1220622 }, - { url = "https://files.pythonhosted.org/packages/93/e6/e72fca8916a6e149bcd7f388b2487f4fecca0c2d888ffa5f53d75bb988f2/sublime_search-0.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18d02882d07a246092dd544ca56d52493dbf4333a006b4ba41a56c7dde22412c", size = 1108664 }, - { url = "https://files.pythonhosted.org/packages/08/94/0eaa0e2f7e1d664b0a538d2292fd11776d471322055970b778f4bfa622de/sublime_search-0.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4439832149ad6cac4cdd051feb584be31eccf3fa5fcf8a2259c4e0dd5113887d", size = 1065522 }, - { url = "https://files.pythonhosted.org/packages/5c/37/fc178eabbd15e7fd26cf3672ab10b45f657ec32b793a98e8d7e1ab73be66/sublime_search-0.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:203c79a7b1b1989acf215c850dd7b9070e3db8742cd782dc9f7186bc07f10592", size = 1093571 }, - { url = "https://files.pythonhosted.org/packages/0e/64/68f01cdcf27ee3b6da7a7829ade6fb6002f51ae4f1081ebda473c5fa3e99/sublime_search-0.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5c8d5798549bd60f11f99dc4a999ded9d572c6f26a4febe46f8ff11a2ca32936", size = 1220745 }, - { url = "https://files.pythonhosted.org/packages/2a/22/e417f582863e96ab8953dc026243ca5cb2b5b026ef5f85adfca0b8abd9b7/sublime_search-0.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bd07603402f0078d225513154bfbfde50d28566004f15b0097bb995767273e85", size = 1265335 }, - { url = "https://files.pythonhosted.org/packages/f1/cb/40df16111219449ef63a543371c5782e43a3a75dd844c7ff2056320495e4/sublime_search-0.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9fbb0abdae6f4241c91c041f23977a7836a3632da2efee76e170857393b813c6", size = 1275562 }, - { url = "https://files.pythonhosted.org/packages/e4/8a/f7dc6351e154fc50ca6f27c5a983f7b42b6ec991a2b328f3dcbb9e5b5d56/sublime_search-0.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d1ad4e2adbe6e8c744e67ee497e1cd4b149e9b1ccfea1d99e1697224d49d3d0", size = 1301079 }, - { url = "https://files.pythonhosted.org/packages/a8/fc/c6f965abc719c722c57ed04ea6d63ec6c0178d8118cf1b71952e43c29d1d/sublime_search-0.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:9822ba29d5f5e72d7f52fa8408cc3e8179be63476483aa6a97aa21a202b62f05", size = 819477 }, - { url = "https://files.pythonhosted.org/packages/31/98/6a5ce632354d0788f3d4b11c909f9c378431c2809bc4720e20b6d6271fcb/sublime_search-0.5.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b98724b9e6b0c64dcde9affa49bcce521970abb492e2f8a76d0a177d8b93236b", size = 1044689 }, - { url = "https://files.pythonhosted.org/packages/b1/e3/8b26728a1047c5aac97c0b337f4eed16098f95425bb055af40f22c0d21c3/sublime_search-0.5.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a4dd8dcfd68a966265202c8aa62be6ec61b51579b095eab728f84be23063c6b0", size = 997831 }, - { url = "https://files.pythonhosted.org/packages/1e/61/d11af20984ef94d50d28b36a1fb017c0c7c5bc1868ff10a400f8c693cc41/sublime_search-0.5.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:67f3329c45152e712fcdefc74d2e0f2d7382b72c62ff4b0e5750c25cc5edde61", size = 1220861 }, - { url = "https://files.pythonhosted.org/packages/f6/ec/ad95b318462bfcb43ea1117c7129b84cb8afedb83eea62522a9d5f0a767b/sublime_search-0.5.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48120a3b1f4c8383fbe43dadcea6a50e974ed162febbe407091dd028a5d228dc", size = 1108905 }, - { url = "https://files.pythonhosted.org/packages/b1/97/263582e7135c552d562fbd69a7ad216892331e445fc1c2acce3c2dde4af9/sublime_search-0.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a2ab2477cf307d301c133a0350426bb22a5727fd060e3fe201852910ccb26e13", size = 1220691 }, - { url = "https://files.pythonhosted.org/packages/39/93/3f88bbb6821332194e729af5add9eabc90c0a7e24359546e06313961c5a8/sublime_search-0.5.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c7670d48b647fda37c279013db03400dbcfd44cc508a69ae690fe9a9e00d7ee0", size = 1264224 }, - { url = "https://files.pythonhosted.org/packages/73/b7/48c07d3c293328d18f70b1ecba1ff781e9dc7bde9fc4a8b97aba2411c587/sublime_search-0.5.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3dc9511d4a47fc616656c3b82322a20ffaf283d29560134169673206c4133b1d", size = 1274424 }, - { url = "https://files.pythonhosted.org/packages/74/78/17b7947310115e9c006eb05e1b4f1458fedd12864245f675454dd7be9ce6/sublime_search-0.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:052bc85ba46b34dbb462d3e7a1c2b982fdccb88bf225ba95952ed2cef3ad898c", size = 1302705 }, +sdist = { url = "https://files.pythonhosted.org/packages/1a/c9/134ea2f0569f432439e8d5d4b834a9445064720bec13f9304aaa836cdb34/sublime_search-0.5.0.tar.gz", hash = "sha256:c316df5cc5481e602205a5abff5e669b1bb960afb25e6578abfc15a23bdd1ce1", size = 44314, upload-time = "2026-01-30T04:26:32.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/c8/848124cf507fb58956ea4058045e654cbfac9fb684a85435e67d9d62c0e9/sublime_search-0.5.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6f7edd7dd33aac1036af793f0bf8fc0d65233803280a9ac9d7fb01c54a19cb9f", size = 970575, upload-time = "2026-01-30T04:25:55.346Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/360045d55180358b242a913d11528745e5fa001575606dc7a88c8346c168/sublime_search-0.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dda52fc8c1cfd23764813a70f767f62fcd78fb703759e73fd69d5902c6a8df9", size = 923303, upload-time = "2026-01-30T04:25:49.275Z" }, + { url = "https://files.pythonhosted.org/packages/90/36/e3d4542b8bc3ab244f0405e2da9e89f4fe60a07d7f6a6b427d5a9e815e63/sublime_search-0.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5eba812aaa5335b8d18f741ba6a8f8a9ce34fb277021e809738991ac88a34b5", size = 1044339, upload-time = "2026-01-30T04:24:59.107Z" }, + { url = "https://files.pythonhosted.org/packages/d0/63/8b77d4bdf761abc41edd616dfda907b2bd0d82b6b43ad1c28958a9474150/sublime_search-0.5.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5393bc2abe371aa1c49c99ddcb170a2a2526f6e99cd724aeb2621e63e776d9ab", size = 999821, upload-time = "2026-01-30T04:25:08.129Z" }, + { url = "https://files.pythonhosted.org/packages/e0/89/4c32e535172269899f1b92ec75b80e8b21a6f3f806831e46baa982ece336/sublime_search-0.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8586c501e25c8f148643052b3441729190e390115e480051da6c05f00848fa71", size = 1220622, upload-time = "2026-01-30T04:25:18.464Z" }, + { url = "https://files.pythonhosted.org/packages/93/e6/e72fca8916a6e149bcd7f388b2487f4fecca0c2d888ffa5f53d75bb988f2/sublime_search-0.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18d02882d07a246092dd544ca56d52493dbf4333a006b4ba41a56c7dde22412c", size = 1108664, upload-time = "2026-01-30T04:25:28.086Z" }, + { url = "https://files.pythonhosted.org/packages/08/94/0eaa0e2f7e1d664b0a538d2292fd11776d471322055970b778f4bfa622de/sublime_search-0.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4439832149ad6cac4cdd051feb584be31eccf3fa5fcf8a2259c4e0dd5113887d", size = 1065522, upload-time = "2026-01-30T04:25:43.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/37/fc178eabbd15e7fd26cf3672ab10b45f657ec32b793a98e8d7e1ab73be66/sublime_search-0.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:203c79a7b1b1989acf215c850dd7b9070e3db8742cd782dc9f7186bc07f10592", size = 1093571, upload-time = "2026-01-30T04:25:37.107Z" }, + { url = "https://files.pythonhosted.org/packages/0e/64/68f01cdcf27ee3b6da7a7829ade6fb6002f51ae4f1081ebda473c5fa3e99/sublime_search-0.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5c8d5798549bd60f11f99dc4a999ded9d572c6f26a4febe46f8ff11a2ca32936", size = 1220745, upload-time = "2026-01-30T04:25:59.892Z" }, + { url = "https://files.pythonhosted.org/packages/2a/22/e417f582863e96ab8953dc026243ca5cb2b5b026ef5f85adfca0b8abd9b7/sublime_search-0.5.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bd07603402f0078d225513154bfbfde50d28566004f15b0097bb995767273e85", size = 1265335, upload-time = "2026-01-30T04:26:09.102Z" }, + { url = "https://files.pythonhosted.org/packages/f1/cb/40df16111219449ef63a543371c5782e43a3a75dd844c7ff2056320495e4/sublime_search-0.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9fbb0abdae6f4241c91c041f23977a7836a3632da2efee76e170857393b813c6", size = 1275562, upload-time = "2026-01-30T04:26:17.235Z" }, + { url = "https://files.pythonhosted.org/packages/e4/8a/f7dc6351e154fc50ca6f27c5a983f7b42b6ec991a2b328f3dcbb9e5b5d56/sublime_search-0.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d1ad4e2adbe6e8c744e67ee497e1cd4b149e9b1ccfea1d99e1697224d49d3d0", size = 1301079, upload-time = "2026-01-30T04:26:26.852Z" }, + { url = "https://files.pythonhosted.org/packages/a8/fc/c6f965abc719c722c57ed04ea6d63ec6c0178d8118cf1b71952e43c29d1d/sublime_search-0.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:9822ba29d5f5e72d7f52fa8408cc3e8179be63476483aa6a97aa21a202b62f05", size = 819477, upload-time = "2026-01-30T04:26:35.295Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/6a5ce632354d0788f3d4b11c909f9c378431c2809bc4720e20b6d6271fcb/sublime_search-0.5.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b98724b9e6b0c64dcde9affa49bcce521970abb492e2f8a76d0a177d8b93236b", size = 1044689, upload-time = "2026-01-30T04:25:00.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e3/8b26728a1047c5aac97c0b337f4eed16098f95425bb055af40f22c0d21c3/sublime_search-0.5.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a4dd8dcfd68a966265202c8aa62be6ec61b51579b095eab728f84be23063c6b0", size = 997831, upload-time = "2026-01-30T04:25:09.614Z" }, + { url = "https://files.pythonhosted.org/packages/1e/61/d11af20984ef94d50d28b36a1fb017c0c7c5bc1868ff10a400f8c693cc41/sublime_search-0.5.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:67f3329c45152e712fcdefc74d2e0f2d7382b72c62ff4b0e5750c25cc5edde61", size = 1220861, upload-time = "2026-01-30T04:25:19.918Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/ad95b318462bfcb43ea1117c7129b84cb8afedb83eea62522a9d5f0a767b/sublime_search-0.5.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48120a3b1f4c8383fbe43dadcea6a50e974ed162febbe407091dd028a5d228dc", size = 1108905, upload-time = "2026-01-30T04:25:29.601Z" }, + { url = "https://files.pythonhosted.org/packages/b1/97/263582e7135c552d562fbd69a7ad216892331e445fc1c2acce3c2dde4af9/sublime_search-0.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a2ab2477cf307d301c133a0350426bb22a5727fd060e3fe201852910ccb26e13", size = 1220691, upload-time = "2026-01-30T04:26:01.145Z" }, + { url = "https://files.pythonhosted.org/packages/39/93/3f88bbb6821332194e729af5add9eabc90c0a7e24359546e06313961c5a8/sublime_search-0.5.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c7670d48b647fda37c279013db03400dbcfd44cc508a69ae690fe9a9e00d7ee0", size = 1264224, upload-time = "2026-01-30T04:26:10.226Z" }, + { url = "https://files.pythonhosted.org/packages/73/b7/48c07d3c293328d18f70b1ecba1ff781e9dc7bde9fc4a8b97aba2411c587/sublime_search-0.5.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3dc9511d4a47fc616656c3b82322a20ffaf283d29560134169673206c4133b1d", size = 1274424, upload-time = "2026-01-30T04:26:18.85Z" }, + { url = "https://files.pythonhosted.org/packages/74/78/17b7947310115e9c006eb05e1b4f1458fedd12864245f675454dd7be9ce6/sublime_search-0.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:052bc85ba46b34dbb462d3e7a1c2b982fdccb88bf225ba95952ed2cef3ad898c", size = 1302705, upload-time = "2026-01-30T04:26:27.966Z" }, ] [[package]] @@ -4685,36 +4685,36 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e7/39/17dde9f0c76cc5abcdeef1b2243791bb850d498f784147b8e460dd23abe8/syrupy-5.5.3.tar.gz", hash = "sha256:fa21e4ae77c89ec5abfca513338d8a7eb916da6618ca0f8db301476e0768e57a", size = 91614 } +sdist = { url = "https://files.pythonhosted.org/packages/e7/39/17dde9f0c76cc5abcdeef1b2243791bb850d498f784147b8e460dd23abe8/syrupy-5.5.3.tar.gz", hash = "sha256:fa21e4ae77c89ec5abfca513338d8a7eb916da6618ca0f8db301476e0768e57a", size = 91614, upload-time = "2026-07-11T15:54:31.46Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/f9/617a194c1a4203279998e1859426cf2635042533a99a93d63d3ee1fb967e/syrupy-5.5.3-py3-none-any.whl", hash = "sha256:0b260a0c9dad55e1fb83818973dc36fbc1aea3fd5381592f442a986686c4171a", size = 54959 }, + { url = "https://files.pythonhosted.org/packages/ae/f9/617a194c1a4203279998e1859426cf2635042533a99a93d63d3ee1fb967e/syrupy-5.5.3-py3-none-any.whl", hash = "sha256:0b260a0c9dad55e1fb83818973dc36fbc1aea3fd5381592f442a986686c4171a", size = 54959, upload-time = "2026-07-11T15:54:29.836Z" }, ] [[package]] name = "tabulate" version = "0.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754 } +sdist = { url = "https://files.pythonhosted.org/packages/46/58/8c37dea7bbf769b20d58e7ace7e5edfe65b849442b00ffcdd56be88697c6/tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d", size = 91754, upload-time = "2026-03-04T18:55:34.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814 }, + { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] [[package]] name = "tenacity" version = "9.1.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413 } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926 }, + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] [[package]] name = "text-unidecode" version = "1.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885 } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154 }, + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, ] [[package]] @@ -4725,22 +4725,22 @@ dependencies = [ { name = "regex" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986 } +sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228 }, - { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978 }, - { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355 }, - { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772 }, - { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415 }, - { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879 }, - { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829 }, - { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600 }, - { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516 }, - { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518 }, - { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867 }, - { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826 }, - { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489 }, - { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820 }, + { url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" }, + { url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" }, + { url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" }, + { url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" }, + { url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" }, ] [[package]] @@ -4750,24 +4750,24 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748 } +sdist = { url = "https://files.pythonhosted.org/packages/c1/60/21f715d9faba5f5407ff759472ade058ec4a507ad62bcea47cb847239a73/tokenizers-0.23.1.tar.gz", hash = "sha256:1feeeadf865a7915adc25445dea30e9933e593c31bb96c277cee36de227c8bfa", size = 365748, upload-time = "2026-04-27T14:43:25.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732 }, - { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954 }, - { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081 }, - { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641 }, - { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624 }, - { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062 }, - { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098 }, - { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235 }, - { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398 }, - { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279 }, - { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986 }, - { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181 }, - { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853 }, - { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263 }, - { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223 }, - { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127 }, + { url = "https://files.pythonhosted.org/packages/87/39/b87a87d5bb9470610b80a2d31df42fcffeaf35118b8b97952b2aff598cc7/tokenizers-0.23.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e03d6ffcbe0d56ee9c1ccd070e70a13fa750727c0277e138152acbc0252c2224", size = 3146732, upload-time = "2026-04-27T14:43:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/068ed9f6e444c9d7e9d55ce134181325700f3d7f30410721bdc8f848d727/tokenizers-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e0948bbb1ac1d7cdfc9fb6d62c596e3b7550036ad60ecd654a66ad273326324e", size = 3054954, upload-time = "2026-04-27T14:43:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/6c/36/e006edf031154cba92b8416057d92c3abe3635e4c4b0aa0b5b9bb39dde70/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf13402aff9bc533c89cb849ec3b412dc3fbeacc9744840e423d7bf3f7dc0e3", size = 3374081, upload-time = "2026-04-27T14:43:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/7735d226f9c7f874a6bee5e3f27fb25ecabdf207d37b8cf45286d0795893/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f836ca703b89ae07919a309f9651f7a88fd5a33d5f718ba5ad0870ec0256bad6", size = 3247641, upload-time = "2026-04-27T14:43:03.856Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d9/24827036f6e21297bfffda0768e58eb6096a4f411e932964a01707857931/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae848657742035523fdf261773630cb819a26995fcd3d9ecae0c1daf6e5a4959", size = 3585624, upload-time = "2026-04-27T14:43:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/22f3582b3a4f49358293a5206e25317621ee4526bfe9cdaa0f07a12e770e/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53b09e85775d5187941e7bab30e941b4134ab4a7dd8c68e783d231fb7ca27c51", size = 3844062, upload-time = "2026-04-27T14:43:05.643Z" }, + { url = "https://files.pythonhosted.org/packages/7e/65/b8f8814eef95800f20721384136d9a1d22241d50b2874357cb70542c392f/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea5a0ce170074329faaa8ea3f6400ecde604b6678192688533af80980daae71a", size = 3460098, upload-time = "2026-04-27T14:43:08.854Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d5/1353e5f677ec27c2494fb6a6725e82d56c985f53e90ec511369e7e4f02c6/tokenizers-0.23.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b405006415ea148a992d093699c66eb01952bf59f4d5727089a98bda45a4", size = 3346235, upload-time = "2026-04-27T14:43:12.377Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/39b6b8fc073fb6d413d0147aa333dc7eff7be65639ac9d19930a0b21bf33/tokenizers-0.23.1-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:56f3a77de629917652f876294dc9fe6bad4a0c43bc229dc72e59bb23a0f4729a", size = 3426398, upload-time = "2026-04-27T14:43:07.264Z" }, + { url = "https://files.pythonhosted.org/packages/0f/80/127c854da64827e5b79264ce524993a90dddcb320e5cd42412c5c02f9e8a/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9d10a6d957ef01896dc274e890eee27d41bd0e74ef31e60616f0fc311345184e", size = 9823279, upload-time = "2026-04-27T14:43:17.222Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ba/44c2502feb1a058f096ddfb4e0996ef3225a01a388e1a9b094e91689fe93/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1974288a609c343774f1b897c8b482c791ab17b75ab5c8c2b1737565c1d82288", size = 9644986, upload-time = "2026-04-27T14:43:19.45Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c1/464019a9fb059870bfe4eebb4ba12208f3042035e258bf5e782906bd3847/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:120468fb4c24faf0543c835a4fabafa4deb3f20a035c9b6e83d0b553a97615d4", size = 9976181, upload-time = "2026-04-27T14:43:21.463Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/3ac1432bda31626071e9b6a12709b97ae05131c804b94c8f3ac622c5da32/tokenizers-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3d8f40ea6268047de7046906326abed5134f27d4e8447b23763afe5808c8a96", size = 10113853, upload-time = "2026-04-27T14:43:23.617Z" }, + { url = "https://files.pythonhosted.org/packages/6a/dd/631b21433c771b1382535326f0eca80b9c9cee2e64961dd993bc9ac4669e/tokenizers-0.23.1-cp310-abi3-win32.whl", hash = "sha256:93120a930b919416da7cd10a2f606ac9919cc69cacae7980fa2140e277660948", size = 2536263, upload-time = "2026-04-27T14:43:29.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/2553f72aaf65a2797d4229e37fa7fbe38ffbf3e32912d31bdd78b3323e59/tokenizers-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:e7bfaf995c1bdbbd21d13539decb6650967013759318627d85daeb7881af16b7", size = 2798223, upload-time = "2026-04-27T14:43:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" }, ] [[package]] @@ -4781,36 +4781,36 @@ dependencies = [ { name = "pydantic" }, { name = "schemez" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/0a/c8093f6836f898a9f587aa30786bba25f08e92c9c11f13f9e638b0b97688/tokonomics-1.2.18.tar.gz", hash = "sha256:6b40461c8c8ce71f4bc378c61a4743a55b20b5ddee50d27b9398c85c29279c17", size = 65636 } +sdist = { url = "https://files.pythonhosted.org/packages/2e/0a/c8093f6836f898a9f587aa30786bba25f08e92c9c11f13f9e638b0b97688/tokonomics-1.2.18.tar.gz", hash = "sha256:6b40461c8c8ce71f4bc378c61a4743a55b20b5ddee50d27b9398c85c29279c17", size = 65636, upload-time = "2026-02-25T21:23:44.719Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/c4/fc9b68acb9d10a44b2c7121c8e02094c46244f9e7b07fa86ef47ee2d1073/tokonomics-1.2.18-py3-none-any.whl", hash = "sha256:8ff42a59c16be309a0d8d29e66a079f0b1ed72dce4380a545d8f20c32e4c9ab8", size = 103907 }, + { url = "https://files.pythonhosted.org/packages/b1/c4/fc9b68acb9d10a44b2c7121c8e02094c46244f9e7b07fa86ef47ee2d1073/tokonomics-1.2.18-py3-none-any.whl", hash = "sha256:8ff42a59c16be309a0d8d29e66a079f0b1ed72dce4380a545d8f20c32e4c9ab8", size = 103907, upload-time = "2026-02-25T21:23:43.281Z" }, ] [[package]] name = "tomli" version = "2.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "tomli-w" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184 } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675 }, + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] [[package]] @@ -4824,9 +4824,9 @@ dependencies = [ { name = "pydantic" }, { name = "sqlmodel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/18/2a0be0481f39259e51781db49c5bb9b7fdb15883ae4794578f8668ecea5b/toprompt-1.0.0.tar.gz", hash = "sha256:9303c46a466c02152473d815e549ee507337b3b5c550fe1b5e5b65420f5464fa", size = 11336 } +sdist = { url = "https://files.pythonhosted.org/packages/1b/18/2a0be0481f39259e51781db49c5bb9b7fdb15883ae4794578f8668ecea5b/toprompt-1.0.0.tar.gz", hash = "sha256:9303c46a466c02152473d815e549ee507337b3b5c550fe1b5e5b65420f5464fa", size = 11336, upload-time = "2025-10-07T20:15:20.097Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/9b/92fb91f937412b7fc7fd1121bafcfe247fe01c4117984c084227419a664f/toprompt-1.0.0-py3-none-any.whl", hash = "sha256:97edd8dfb941dc44374fd84959cd5f9dae9111e58202da714346bae3e18138f1", size = 12109 }, + { url = "https://files.pythonhosted.org/packages/f6/9b/92fb91f937412b7fc7fd1121bafcfe247fe01c4117984c084227419a664f/toprompt-1.0.0-py3-none-any.whl", hash = "sha256:97edd8dfb941dc44374fd84959cd5f9dae9111e58202da714346bae3e18138f1", size = 12109, upload-time = "2025-10-07T20:15:19.077Z" }, ] [[package]] @@ -4836,103 +4836,103 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/84/da0e5038228fa34dfd77c5026b173ed035d2a3ba31f1077590c013de2bff/tqdm-4.69.1.tar.gz", hash = "sha256:2be21080a0ce17e902c2f1baeb6a74bf551b67bbdfa4bc0109fad471d0b4cb0d", size = 793046 } +sdist = { url = "https://files.pythonhosted.org/packages/dd/84/da0e5038228fa34dfd77c5026b173ed035d2a3ba31f1077590c013de2bff/tqdm-4.69.1.tar.gz", hash = "sha256:2be21080a0ce17e902c2f1baeb6a74bf551b67bbdfa4bc0109fad471d0b4cb0d", size = 793046, upload-time = "2026-07-24T14:22:02.08Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/50/5817619a0fca56aff06383dbfde7ae017b3ca383915b3f1e4713164273cf/tqdm-4.69.1-py3-none-any.whl", hash = "sha256:0a654b96f7a2660cceb615b56f307ec2bef96c515409014a429a561981ab52b4", size = 675452 }, + { url = "https://files.pythonhosted.org/packages/01/50/5817619a0fca56aff06383dbfde7ae017b3ca383915b3f1e4713164273cf/tqdm-4.69.1-py3-none-any.whl", hash = "sha256:0a654b96f7a2660cceb615b56f307ec2bef96c515409014a429a561981ab52b4", size = 675452, upload-time = "2026-07-24T14:22:00.048Z" }, ] [[package]] name = "tree-sitter" version = "0.26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/03/5600b84aff2e6c4fe80cfebb4063fe2f50299521befe5f6092ab8c082f4a/tree_sitter-0.26.0.tar.gz", hash = "sha256:b40c219edccc4564530c96f8f1556f6202b37cda964d1cbd7bd2b7e68b40a245", size = 191423 } +sdist = { url = "https://files.pythonhosted.org/packages/f7/03/5600b84aff2e6c4fe80cfebb4063fe2f50299521befe5f6092ab8c082f4a/tree_sitter-0.26.0.tar.gz", hash = "sha256:b40c219edccc4564530c96f8f1556f6202b37cda964d1cbd7bd2b7e68b40a245", size = 191423, upload-time = "2026-06-30T12:14:27.933Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b0/465257cf8f972ad9f9812ec1cbaa8ec210ebebb601ade9a15881aa2436b4/tree_sitter-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed0889dbed843ce45ede9f5169c0b2dea2222f12685844a03fadb81f12705867", size = 148893 }, - { url = "https://files.pythonhosted.org/packages/a1/ec/19d093e854b45e807fecfdd26105c266f43aeecc39c4dc97992a7074ad5a/tree_sitter-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6189c6c340c7384357711e3d92645e96bfb79f7a502f86de1ebdb23eb43f7dab", size = 140829 }, - { url = "https://files.pythonhosted.org/packages/9b/ee/87e74671ed63a837e7a1f17ab94aa3913871e033b27523d8e7b83d6f7ad0/tree_sitter-0.26.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ff2e0750b7daa722302838356d7b65e303829b7eb73c915df127ddba115e1d1", size = 639334 }, - { url = "https://files.pythonhosted.org/packages/66/e7/f7e04cd9dff6b6ac0adf23922796fbc76accd4cf4bcda50542748d485679/tree_sitter-0.26.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7075ef857ef86f327dbb72d1e2574dda78db5754b3a1fca6506acd7fe5d561a7", size = 668102 }, - { url = "https://files.pythonhosted.org/packages/d3/90/0bfb16b7894fea728c774a89d5af421a9368a2f913bbd4e8dcab7caaecfb/tree_sitter-0.26.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:26c996c1edfee86e977bb3f5462e74fcec0d0b0db1e85a3c475875763caa03be", size = 648560 }, - { url = "https://files.pythonhosted.org/packages/cd/e6/0fe05ba396e9623b0ae40ccf34171336b8701ec8d7bd0ee9f5224d638665/tree_sitter-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00289bfe7978f3e0dc0ce69813a20fa9f44ea4c100b3ec62043e5eb74ccfc3a2", size = 665121 }, - { url = "https://files.pythonhosted.org/packages/eb/d2/a944b1ca35bed6068dc84a9967aaf3049d8cc0b7a36179eea8787270a6ab/tree_sitter-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:93e220cab7e6a823efeb2046c49171427de92ef71c7c681c01820d14d8d3721f", size = 129615 }, - { url = "https://files.pythonhosted.org/packages/09/ef/c7ca48293580d2249f36940c4eed5b4ddeb9ce75baf9a4ef30621987e0c7/tree_sitter-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:b31a8195d2f224224c530ac814632d98c1dcc123d227442c07c736e86b70d564", size = 116525 }, + { url = "https://files.pythonhosted.org/packages/cb/b0/465257cf8f972ad9f9812ec1cbaa8ec210ebebb601ade9a15881aa2436b4/tree_sitter-0.26.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed0889dbed843ce45ede9f5169c0b2dea2222f12685844a03fadb81f12705867", size = 148893, upload-time = "2026-06-30T12:14:10.541Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ec/19d093e854b45e807fecfdd26105c266f43aeecc39c4dc97992a7074ad5a/tree_sitter-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6189c6c340c7384357711e3d92645e96bfb79f7a502f86de1ebdb23eb43f7dab", size = 140829, upload-time = "2026-06-30T12:14:11.626Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ee/87e74671ed63a837e7a1f17ab94aa3913871e033b27523d8e7b83d6f7ad0/tree_sitter-0.26.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8ff2e0750b7daa722302838356d7b65e303829b7eb73c915df127ddba115e1d1", size = 639334, upload-time = "2026-06-30T12:14:12.836Z" }, + { url = "https://files.pythonhosted.org/packages/66/e7/f7e04cd9dff6b6ac0adf23922796fbc76accd4cf4bcda50542748d485679/tree_sitter-0.26.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7075ef857ef86f327dbb72d1e2574dda78db5754b3a1fca6506acd7fe5d561a7", size = 668102, upload-time = "2026-06-30T12:14:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/d3/90/0bfb16b7894fea728c774a89d5af421a9368a2f913bbd4e8dcab7caaecfb/tree_sitter-0.26.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:26c996c1edfee86e977bb3f5462e74fcec0d0b0db1e85a3c475875763caa03be", size = 648560, upload-time = "2026-06-30T12:14:15.302Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e6/0fe05ba396e9623b0ae40ccf34171336b8701ec8d7bd0ee9f5224d638665/tree_sitter-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00289bfe7978f3e0dc0ce69813a20fa9f44ea4c100b3ec62043e5eb74ccfc3a2", size = 665121, upload-time = "2026-06-30T12:14:16.403Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/a944b1ca35bed6068dc84a9967aaf3049d8cc0b7a36179eea8787270a6ab/tree_sitter-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:93e220cab7e6a823efeb2046c49171427de92ef71c7c681c01820d14d8d3721f", size = 129615, upload-time = "2026-06-30T12:14:17.463Z" }, + { url = "https://files.pythonhosted.org/packages/09/ef/c7ca48293580d2249f36940c4eed5b4ddeb9ce75baf9a4ef30621987e0c7/tree_sitter-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:b31a8195d2f224224c530ac814632d98c1dcc123d227442c07c736e86b70d564", size = 116525, upload-time = "2026-06-30T12:14:18.53Z" }, ] [[package]] name = "tree-sitter-c" version = "0.24.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a6/c9/3834f3d9278251aea7312274971bc4c45b17aec2490fd4b884d93bd7019a/tree_sitter_c-0.24.2.tar.gz", hash = "sha256:1628584df0299b5a340aa63f8e67b6c97c91517f52fa7e7a4c557e40adb330a9", size = 228397 } +sdist = { url = "https://files.pythonhosted.org/packages/a6/c9/3834f3d9278251aea7312274971bc4c45b17aec2490fd4b884d93bd7019a/tree_sitter_c-0.24.2.tar.gz", hash = "sha256:1628584df0299b5a340aa63f8e67b6c97c91517f52fa7e7a4c557e40adb330a9", size = 228397, upload-time = "2026-04-22T08:06:14.491Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/c1/26ed17730ec2c17bedc1b673349e5e0a466c578e3eb0327c3b73cf52bf97/tree_sitter_c-0.24.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4d4579a8b54f0a442f903d88d3304cab77cd5c2031d4015baa4f2f8e15d6dcb7", size = 81016 }, - { url = "https://files.pythonhosted.org/packages/c1/1c/1140db75e7e375cda3c68792a33826c4fd40b5b98c3259d93c75f6c8368f/tree_sitter_c-0.24.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:97bc80a224d48215d4e6e6376bf30d114f4c317b8145ff1b02afe785d4ba7bdd", size = 86213 }, - { url = "https://files.pythonhosted.org/packages/e9/8c/0dfb88d726f8821d1c4c36042f092be974a800afd734307a595b8604190c/tree_sitter_c-0.24.2-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5041ef67eb68ce6bc8bb0b1f8ef3a5585ce523dae0c7eec109ab0627dd75aede", size = 94264 }, - { url = "https://files.pythonhosted.org/packages/87/78/47dc570e7aee6b0a1ecc2520b30639cc2b06003154c9ab0672d86bf720d5/tree_sitter_c-0.24.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c098bedcd5ac86ff93fa734d51d1dd86aed40fd5ed7d634c7af11380a0469969", size = 94560 }, - { url = "https://files.pythonhosted.org/packages/29/37/75d59d3f74f4cfc00f04472917e933d8a9c9fdc6eff980ef9552e010e6aa/tree_sitter_c-0.24.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:82842c5a5f2acd93f4de10038c33ac179c8979defc39376f990348d6289e933b", size = 94023 }, - { url = "https://files.pythonhosted.org/packages/64/57/8fc655d5a446a70a637e92b98bd2fdaab88bf5bb5b36076ac4add544808d/tree_sitter_c-0.24.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2b42e8e22202c251f8629306f9321233542e07a6e01611b5fe83489272143eb", size = 94160 }, - { url = "https://files.pythonhosted.org/packages/c1/f7/72a1d6b42dd31fd37e03ff67e7dc5ee572301499e6b216002b8dd42a1714/tree_sitter_c-0.24.2-cp310-abi3-win_amd64.whl", hash = "sha256:abb549225091f7b25df2dd3a0143ece6e208f7055d8bcb4700b41ee79b9ef1e1", size = 84669 }, - { url = "https://files.pythonhosted.org/packages/e2/9d/7475d9ae8ef679aa36c7dfe6c903ab78e573651c68b6ef9862d6a3f994db/tree_sitter_c-0.24.2-cp310-abi3-win_arm64.whl", hash = "sha256:4a2f4371cd816cc3153458f69062135ebb2ea5f275ddd90494e5c823d778204a", size = 82956 }, + { url = "https://files.pythonhosted.org/packages/28/c1/26ed17730ec2c17bedc1b673349e5e0a466c578e3eb0327c3b73cf52bf97/tree_sitter_c-0.24.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:4d4579a8b54f0a442f903d88d3304cab77cd5c2031d4015baa4f2f8e15d6dcb7", size = 81016, upload-time = "2026-04-22T08:06:07.208Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1c/1140db75e7e375cda3c68792a33826c4fd40b5b98c3259d93c75f6c8368f/tree_sitter_c-0.24.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:97bc80a224d48215d4e6e6376bf30d114f4c317b8145ff1b02afe785d4ba7bdd", size = 86213, upload-time = "2026-04-22T08:06:08.136Z" }, + { url = "https://files.pythonhosted.org/packages/e9/8c/0dfb88d726f8821d1c4c36042f092be974a800afd734307a595b8604190c/tree_sitter_c-0.24.2-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5041ef67eb68ce6bc8bb0b1f8ef3a5585ce523dae0c7eec109ab0627dd75aede", size = 94264, upload-time = "2026-04-22T08:06:08.918Z" }, + { url = "https://files.pythonhosted.org/packages/87/78/47dc570e7aee6b0a1ecc2520b30639cc2b06003154c9ab0672d86bf720d5/tree_sitter_c-0.24.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c098bedcd5ac86ff93fa734d51d1dd86aed40fd5ed7d634c7af11380a0469969", size = 94560, upload-time = "2026-04-22T08:06:09.852Z" }, + { url = "https://files.pythonhosted.org/packages/29/37/75d59d3f74f4cfc00f04472917e933d8a9c9fdc6eff980ef9552e010e6aa/tree_sitter_c-0.24.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:82842c5a5f2acd93f4de10038c33ac179c8979defc39376f990348d6289e933b", size = 94023, upload-time = "2026-04-22T08:06:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/64/57/8fc655d5a446a70a637e92b98bd2fdaab88bf5bb5b36076ac4add544808d/tree_sitter_c-0.24.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2b42e8e22202c251f8629306f9321233542e07a6e01611b5fe83489272143eb", size = 94160, upload-time = "2026-04-22T08:06:11.497Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f7/72a1d6b42dd31fd37e03ff67e7dc5ee572301499e6b216002b8dd42a1714/tree_sitter_c-0.24.2-cp310-abi3-win_amd64.whl", hash = "sha256:abb549225091f7b25df2dd3a0143ece6e208f7055d8bcb4700b41ee79b9ef1e1", size = 84669, upload-time = "2026-04-22T08:06:12.347Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9d/7475d9ae8ef679aa36c7dfe6c903ab78e573651c68b6ef9862d6a3f994db/tree_sitter_c-0.24.2-cp310-abi3-win_arm64.whl", hash = "sha256:4a2f4371cd816cc3153458f69062135ebb2ea5f275ddd90494e5c823d778204a", size = 82956, upload-time = "2026-04-22T08:06:13.364Z" }, ] [[package]] name = "tree-sitter-cpp" version = "0.23.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/2c/4dd63d705a8933543cad9b92ff31be849b164fec91a6eb63475ebc9ce668/tree_sitter_cpp-0.23.4.tar.gz", hash = "sha256:6a59c4cebb1ad1dc2e8d586cf8a72b39d21b8108b7b139d089719e81a339e41d", size = 940358 } +sdist = { url = "https://files.pythonhosted.org/packages/20/2c/4dd63d705a8933543cad9b92ff31be849b164fec91a6eb63475ebc9ce668/tree_sitter_cpp-0.23.4.tar.gz", hash = "sha256:6a59c4cebb1ad1dc2e8d586cf8a72b39d21b8108b7b139d089719e81a339e41d", size = 940358, upload-time = "2024-11-11T06:59:24.934Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/ac/11d56670f7b048362db872ca866fd00ba2002a322ab179f047b7c0fb2910/tree_sitter_cpp-0.23.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aacb1759f0efd9dbc25bd8ee88184a340483018869f75412d9c3bc32c039a520", size = 287861 }, - { url = "https://files.pythonhosted.org/packages/12/1c/0337c016bdc00a77a3326d12f10ee836401dd28f27db6fd5b7734bfb21ed/tree_sitter_cpp-0.23.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc3c404d9f0cbd87951213a85440afbf4c31e718f8d907fa9ee12bea4b8d276f", size = 315513 }, - { url = "https://files.pythonhosted.org/packages/b3/7b/dd38c049b10ed7fda118b903a1d28a8b55a36b98c30606ef90e8f374c6de/tree_sitter_cpp-0.23.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc43ddf1279d5d5a4ef190373f4cb16522801bec4492bcd4754edf2aeba2b7b", size = 334813 }, - { url = "https://files.pythonhosted.org/packages/6a/4d/23e390234d2acd351f5563b1079c515d7c1fe13ddb7392cee543be74dda3/tree_sitter_cpp-0.23.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:773d2cafc08bbc0f998687fa33f42f378c1a371cdb582870c4d13abb06092706", size = 316110 }, - { url = "https://files.pythonhosted.org/packages/32/c7/b94a7e0e803af9d3bd4608fb4f0cfb2e9e233abaf0a38c928bfb0b1a025d/tree_sitter_cpp-0.23.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:247d127f0eb6574b0f6b30c0151e0bd0774e2e7acf9c558bdf9fbb8adc2e80c0", size = 308242 }, - { url = "https://files.pythonhosted.org/packages/37/7e/909e52b3dec09c475140b0e175511e275d0d00ba2dbd7c68102d377ae0f6/tree_sitter_cpp-0.23.4-cp39-abi3-win_amd64.whl", hash = "sha256:68606a45bea92669d155399e1239f771a7767d8683cd8f8e30e7d813107030ca", size = 290997 }, - { url = "https://files.pythonhosted.org/packages/d4/6a/65435d4d1f4c735be7ffe52d7c2e7b8a7f7c2790343a2719c60c548611c8/tree_sitter_cpp-0.23.4-cp39-abi3-win_arm64.whl", hash = "sha256:712f84f18be94cbe2a148fa4fdf40fcf4a8c25a8f7670efb9f8a47ddec2fc281", size = 288203 }, + { url = "https://files.pythonhosted.org/packages/b6/ac/11d56670f7b048362db872ca866fd00ba2002a322ab179f047b7c0fb2910/tree_sitter_cpp-0.23.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aacb1759f0efd9dbc25bd8ee88184a340483018869f75412d9c3bc32c039a520", size = 287861, upload-time = "2024-11-11T06:59:15.005Z" }, + { url = "https://files.pythonhosted.org/packages/12/1c/0337c016bdc00a77a3326d12f10ee836401dd28f27db6fd5b7734bfb21ed/tree_sitter_cpp-0.23.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc3c404d9f0cbd87951213a85440afbf4c31e718f8d907fa9ee12bea4b8d276f", size = 315513, upload-time = "2024-11-11T06:59:16.679Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7b/dd38c049b10ed7fda118b903a1d28a8b55a36b98c30606ef90e8f374c6de/tree_sitter_cpp-0.23.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc43ddf1279d5d5a4ef190373f4cb16522801bec4492bcd4754edf2aeba2b7b", size = 334813, upload-time = "2024-11-11T06:59:18.253Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4d/23e390234d2acd351f5563b1079c515d7c1fe13ddb7392cee543be74dda3/tree_sitter_cpp-0.23.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:773d2cafc08bbc0f998687fa33f42f378c1a371cdb582870c4d13abb06092706", size = 316110, upload-time = "2024-11-11T06:59:19.823Z" }, + { url = "https://files.pythonhosted.org/packages/32/c7/b94a7e0e803af9d3bd4608fb4f0cfb2e9e233abaf0a38c928bfb0b1a025d/tree_sitter_cpp-0.23.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:247d127f0eb6574b0f6b30c0151e0bd0774e2e7acf9c558bdf9fbb8adc2e80c0", size = 308242, upload-time = "2024-11-11T06:59:21.466Z" }, + { url = "https://files.pythonhosted.org/packages/37/7e/909e52b3dec09c475140b0e175511e275d0d00ba2dbd7c68102d377ae0f6/tree_sitter_cpp-0.23.4-cp39-abi3-win_amd64.whl", hash = "sha256:68606a45bea92669d155399e1239f771a7767d8683cd8f8e30e7d813107030ca", size = 290997, upload-time = "2024-11-11T06:59:22.432Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6a/65435d4d1f4c735be7ffe52d7c2e7b8a7f7c2790343a2719c60c548611c8/tree_sitter_cpp-0.23.4-cp39-abi3-win_arm64.whl", hash = "sha256:712f84f18be94cbe2a148fa4fdf40fcf4a8c25a8f7670efb9f8a47ddec2fc281", size = 288203, upload-time = "2024-11-11T06:59:23.404Z" }, ] [[package]] name = "tree-sitter-go" version = "0.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/05/727308adbbc79bcb1c92fc0ea10556a735f9d0f0a5435a18f59d40f7fd77/tree_sitter_go-0.25.0.tar.gz", hash = "sha256:a7466e9b8d94dda94cae8d91629f26edb2d26166fd454d4831c3bf6dfa2e8d68", size = 93890 } +sdist = { url = "https://files.pythonhosted.org/packages/01/05/727308adbbc79bcb1c92fc0ea10556a735f9d0f0a5435a18f59d40f7fd77/tree_sitter_go-0.25.0.tar.gz", hash = "sha256:a7466e9b8d94dda94cae8d91629f26edb2d26166fd454d4831c3bf6dfa2e8d68", size = 93890, upload-time = "2025-08-29T06:20:25.044Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/aa/0984707acc2b9bb461fe4a41e7e0fc5b2b1e245c32820f0c83b3c602957c/tree_sitter_go-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b852993063a3429a443e7bd0aa376dd7dd329d595819fabf56ac4cf9d7257b54", size = 47117 }, - { url = "https://files.pythonhosted.org/packages/32/16/dd4cb124b35e99239ab3624225da07d4cb8da4d8564ed81d03fcb3a6ba9f/tree_sitter_go-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:503b81a2b4c31e302869a1de3a352ad0912ccab3df9ac9950197b0a9ceeabd8f", size = 48674 }, - { url = "https://files.pythonhosted.org/packages/86/fb/b30d63a08044115d8b8bd196c6c2ab4325fb8db5757249a4ef0563966e2e/tree_sitter_go-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04b3b3cb4aff18e74e28d49b716c6f24cb71ddfdd66768987e26e4d0fa812f74", size = 66418 }, - { url = "https://files.pythonhosted.org/packages/26/21/d3d88a30ad007419b2c97b3baeeef7431407faf9f686195b6f1cad0aedf9/tree_sitter_go-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:148255aca2f54b90d48c48a9dbb4c7faad6cad310a980b2c5a5a9822057ed145", size = 72006 }, - { url = "https://files.pythonhosted.org/packages/cd/d0/0dd6442353ced8a88bbda9e546f4ea29e381b59b5a40b122e5abb586bb6c/tree_sitter_go-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4d338116cdf8a6c6ff990d2441929b41323ef17c710407abe0993c13417d6aad", size = 70603 }, - { url = "https://files.pythonhosted.org/packages/01/e2/ee5e09f63504fc286539535d374d2eaa0e7d489b80f8f744bb3962aff22a/tree_sitter_go-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5608e089d2a29fa8d2b327abeb2ad1cdb8e223c440a6b0ceab0d3fa80bdeebae", size = 66088 }, - { url = "https://files.pythonhosted.org/packages/6e/b6/d9142583374720e79aca9ccb394b3795149a54c012e1dfd80738df2d984e/tree_sitter_go-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:30d4ada57a223dfc2c32d942f44d284d40f3d1215ddcf108f96807fd36d53022", size = 48152 }, - { url = "https://files.pythonhosted.org/packages/9e/00/9a2638e7339236f5b01622952a4d71c1474dd3783d1982a89555fc1f03b1/tree_sitter_go-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:d5d62362059bf79997340773d47cc7e7e002883b527a05cca829c46e40b70ded", size = 46752 }, + { url = "https://files.pythonhosted.org/packages/ca/aa/0984707acc2b9bb461fe4a41e7e0fc5b2b1e245c32820f0c83b3c602957c/tree_sitter_go-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b852993063a3429a443e7bd0aa376dd7dd329d595819fabf56ac4cf9d7257b54", size = 47117, upload-time = "2025-08-29T06:20:14.286Z" }, + { url = "https://files.pythonhosted.org/packages/32/16/dd4cb124b35e99239ab3624225da07d4cb8da4d8564ed81d03fcb3a6ba9f/tree_sitter_go-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:503b81a2b4c31e302869a1de3a352ad0912ccab3df9ac9950197b0a9ceeabd8f", size = 48674, upload-time = "2025-08-29T06:20:17.557Z" }, + { url = "https://files.pythonhosted.org/packages/86/fb/b30d63a08044115d8b8bd196c6c2ab4325fb8db5757249a4ef0563966e2e/tree_sitter_go-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04b3b3cb4aff18e74e28d49b716c6f24cb71ddfdd66768987e26e4d0fa812f74", size = 66418, upload-time = "2025-08-29T06:20:18.345Z" }, + { url = "https://files.pythonhosted.org/packages/26/21/d3d88a30ad007419b2c97b3baeeef7431407faf9f686195b6f1cad0aedf9/tree_sitter_go-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:148255aca2f54b90d48c48a9dbb4c7faad6cad310a980b2c5a5a9822057ed145", size = 72006, upload-time = "2025-08-29T06:20:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d0/0dd6442353ced8a88bbda9e546f4ea29e381b59b5a40b122e5abb586bb6c/tree_sitter_go-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4d338116cdf8a6c6ff990d2441929b41323ef17c710407abe0993c13417d6aad", size = 70603, upload-time = "2025-08-29T06:20:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/01/e2/ee5e09f63504fc286539535d374d2eaa0e7d489b80f8f744bb3962aff22a/tree_sitter_go-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5608e089d2a29fa8d2b327abeb2ad1cdb8e223c440a6b0ceab0d3fa80bdeebae", size = 66088, upload-time = "2025-08-29T06:20:22.336Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b6/d9142583374720e79aca9ccb394b3795149a54c012e1dfd80738df2d984e/tree_sitter_go-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:30d4ada57a223dfc2c32d942f44d284d40f3d1215ddcf108f96807fd36d53022", size = 48152, upload-time = "2025-08-29T06:20:23.089Z" }, + { url = "https://files.pythonhosted.org/packages/9e/00/9a2638e7339236f5b01622952a4d71c1474dd3783d1982a89555fc1f03b1/tree_sitter_go-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:d5d62362059bf79997340773d47cc7e7e002883b527a05cca829c46e40b70ded", size = 46752, upload-time = "2025-08-29T06:20:24.235Z" }, ] [[package]] name = "tree-sitter-javascript" version = "0.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/59/e0/e63103c72a9d3dfd89a31e02e660263ad84b7438e5f44ee82e443e65bbde/tree_sitter_javascript-0.25.0.tar.gz", hash = "sha256:329b5414874f0588a98f1c291f1b28138286617aa907746ffe55adfdcf963f38", size = 132338 } +sdist = { url = "https://files.pythonhosted.org/packages/59/e0/e63103c72a9d3dfd89a31e02e660263ad84b7438e5f44ee82e443e65bbde/tree_sitter_javascript-0.25.0.tar.gz", hash = "sha256:329b5414874f0588a98f1c291f1b28138286617aa907746ffe55adfdcf963f38", size = 132338, upload-time = "2025-09-01T07:13:44.792Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/df/5106ac250cd03661ebc3cc75da6b3d9f6800a3606393a0122eca58038104/tree_sitter_javascript-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b70f887fb269d6e58c349d683f59fa647140c410cfe2bee44a883b20ec92e3dc", size = 64052 }, - { url = "https://files.pythonhosted.org/packages/b1/8f/6b4b2bc90d8ab3955856ce852cc9d1e82c81d7ab9646385f0e75ffd5b5d3/tree_sitter_javascript-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8264a996b8845cfce06965152a013b5d9cbb7d199bc3503e12b5682e62bb1de1", size = 66440 }, - { url = "https://files.pythonhosted.org/packages/5f/c4/7da74ecdcd8a398f88bd003a87c65403b5fe0e958cdd43fbd5fd4a398fcf/tree_sitter_javascript-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dc04ba91fc8583344e57c1f1ed5b2c97ecaaf47480011b92fbeab8dda96db75", size = 99728 }, - { url = "https://files.pythonhosted.org/packages/96/c8/97da3af4796495e46421e9344738addb3602fa6426ea695be3fcbadbee37/tree_sitter_javascript-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:199d09985190852e0912da2b8d26c932159be314bc04952cf917ed0e4c633e6b", size = 106072 }, - { url = "https://files.pythonhosted.org/packages/13/be/c964e8130be08cc9bd6627d845f0e4460945b158429d39510953bbcb8fcc/tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfcf789064c58dc13c0a4edb550acacfc6f0f280577f1e7a00de3e89fc7f8ddc", size = 104388 }, - { url = "https://files.pythonhosted.org/packages/ee/89/9b773dee0f8961d1bb8d7baf0a204ab587618df19897c1ef260916f318ec/tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b852d3aee8a36186dbcc32c798b11b4869f9b5041743b63b65c2ef793db7a54", size = 98377 }, - { url = "https://files.pythonhosted.org/packages/3b/dc/d90cb1790f8cec9b4878d278ad9faf7c8f893189ce0f855304fd704fc274/tree_sitter_javascript-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:e5ed840f5bd4a3f0272e441d19429b26eedc257abe5574c8546da6b556865e3c", size = 62975 }, - { url = "https://files.pythonhosted.org/packages/2e/1f/f9eba1038b7d4394410f3c0a6ec2122b590cd7acb03f196e52fa57ebbe72/tree_sitter_javascript-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:622a69d677aa7f6ee2931d8c77c981a33f0ebb6d275aa9d43d3397c879a9bb0b", size = 61668 }, + { url = "https://files.pythonhosted.org/packages/2c/df/5106ac250cd03661ebc3cc75da6b3d9f6800a3606393a0122eca58038104/tree_sitter_javascript-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b70f887fb269d6e58c349d683f59fa647140c410cfe2bee44a883b20ec92e3dc", size = 64052, upload-time = "2025-09-01T07:13:36.865Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/6b4b2bc90d8ab3955856ce852cc9d1e82c81d7ab9646385f0e75ffd5b5d3/tree_sitter_javascript-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8264a996b8845cfce06965152a013b5d9cbb7d199bc3503e12b5682e62bb1de1", size = 66440, upload-time = "2025-09-01T07:13:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c4/7da74ecdcd8a398f88bd003a87c65403b5fe0e958cdd43fbd5fd4a398fcf/tree_sitter_javascript-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dc04ba91fc8583344e57c1f1ed5b2c97ecaaf47480011b92fbeab8dda96db75", size = 99728, upload-time = "2025-09-01T07:13:38.755Z" }, + { url = "https://files.pythonhosted.org/packages/96/c8/97da3af4796495e46421e9344738addb3602fa6426ea695be3fcbadbee37/tree_sitter_javascript-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:199d09985190852e0912da2b8d26c932159be314bc04952cf917ed0e4c633e6b", size = 106072, upload-time = "2025-09-01T07:13:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/13/be/c964e8130be08cc9bd6627d845f0e4460945b158429d39510953bbcb8fcc/tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfcf789064c58dc13c0a4edb550acacfc6f0f280577f1e7a00de3e89fc7f8ddc", size = 104388, upload-time = "2025-09-01T07:13:40.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/89/9b773dee0f8961d1bb8d7baf0a204ab587618df19897c1ef260916f318ec/tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b852d3aee8a36186dbcc32c798b11b4869f9b5041743b63b65c2ef793db7a54", size = 98377, upload-time = "2025-09-01T07:13:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/d90cb1790f8cec9b4878d278ad9faf7c8f893189ce0f855304fd704fc274/tree_sitter_javascript-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:e5ed840f5bd4a3f0272e441d19429b26eedc257abe5574c8546da6b556865e3c", size = 62975, upload-time = "2025-09-01T07:13:42.828Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1f/f9eba1038b7d4394410f3c0a6ec2122b590cd7acb03f196e52fa57ebbe72/tree_sitter_javascript-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:622a69d677aa7f6ee2931d8c77c981a33f0ebb6d275aa9d43d3397c879a9bb0b", size = 61668, upload-time = "2025-09-01T07:13:43.803Z" }, ] [[package]] name = "tree-sitter-json" version = "0.24.8" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/29/e92df6dca3a6b2ab1c179978be398059817e1173fbacd47e832aaff3446b/tree_sitter_json-0.24.8.tar.gz", hash = "sha256:ca8486e52e2d261819311d35cf98656123d59008c3b7dcf91e61d2c0c6f3120e", size = 8155 } +sdist = { url = "https://files.pythonhosted.org/packages/d7/29/e92df6dca3a6b2ab1c179978be398059817e1173fbacd47e832aaff3446b/tree_sitter_json-0.24.8.tar.gz", hash = "sha256:ca8486e52e2d261819311d35cf98656123d59008c3b7dcf91e61d2c0c6f3120e", size = 8155, upload-time = "2024-11-11T06:05:00.667Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/41/84866232980fb3cf0cff46f5af2dbb9bfa3324b32614c6a9af3d08926b72/tree_sitter_json-0.24.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:59ac06c6db1877d0e2076bce54a5fddcdd2fc38ca778905662e80fa9ffcea2ab", size = 8718 }, - { url = "https://files.pythonhosted.org/packages/5c/31/102c15948d97b135611d6a995c97a3933c0e9745f25737723977f58e142c/tree_sitter_json-0.24.8-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:62b4c45b561db31436a81a3f037f71ec29049f4fc9bf5269b6ec3ebaaa35a1cd", size = 9163 }, - { url = "https://files.pythonhosted.org/packages/28/64/aa44ea2f3d2e76ec086ce83902eb26b2ed0a92d3fd5e2714c9cb007e90d1/tree_sitter_json-0.24.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8627f7d375fda9fc193ebee368c453f374f65c2f25c58b6fea4e6b49a7fccbc", size = 17726 }, - { url = "https://files.pythonhosted.org/packages/77/08/10001992526670e0d6f24c571b179f0ece90e5e014a4b98a3ce076884f32/tree_sitter_json-0.24.8-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85cca779872f7278f3a74eb38533d34b9c4de4fd548615e3361fa64fe350ad0a", size = 17236 }, - { url = "https://files.pythonhosted.org/packages/92/64/908e9e0bd84fe3c81c564115d3bbe0e49b0e152784bbaf153d749d00bbe6/tree_sitter_json-0.24.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:deeb45850dcc52990fbb52c80196492a099e3fa3512d928a390a91cf061068cc", size = 16071 }, - { url = "https://files.pythonhosted.org/packages/53/df/31daab1eedb445bef208a04fc35428de3afe2b37075fec84d7737e1c69de/tree_sitter_json-0.24.8-cp39-abi3-win_amd64.whl", hash = "sha256:e4849a03cd7197267b2688a4506a90a13568a8e0e8588080bd0212fcb38974e3", size = 11457 }, - { url = "https://files.pythonhosted.org/packages/6c/3d/902d2f3125b6b90cebf404b63ca775bc6d82071ccc76c0d10fabfeb2febe/tree_sitter_json-0.24.8-cp39-abi3-win_arm64.whl", hash = "sha256:591e0096c882d12668b88f30d3ca6f85b9db3406910eaaab6afb6b17d65367dd", size = 10174 }, + { url = "https://files.pythonhosted.org/packages/42/41/84866232980fb3cf0cff46f5af2dbb9bfa3324b32614c6a9af3d08926b72/tree_sitter_json-0.24.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:59ac06c6db1877d0e2076bce54a5fddcdd2fc38ca778905662e80fa9ffcea2ab", size = 8718, upload-time = "2024-11-11T06:04:49.779Z" }, + { url = "https://files.pythonhosted.org/packages/5c/31/102c15948d97b135611d6a995c97a3933c0e9745f25737723977f58e142c/tree_sitter_json-0.24.8-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:62b4c45b561db31436a81a3f037f71ec29049f4fc9bf5269b6ec3ebaaa35a1cd", size = 9163, upload-time = "2024-11-11T06:04:51.275Z" }, + { url = "https://files.pythonhosted.org/packages/28/64/aa44ea2f3d2e76ec086ce83902eb26b2ed0a92d3fd5e2714c9cb007e90d1/tree_sitter_json-0.24.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8627f7d375fda9fc193ebee368c453f374f65c2f25c58b6fea4e6b49a7fccbc", size = 17726, upload-time = "2024-11-11T06:04:52.732Z" }, + { url = "https://files.pythonhosted.org/packages/77/08/10001992526670e0d6f24c571b179f0ece90e5e014a4b98a3ce076884f32/tree_sitter_json-0.24.8-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85cca779872f7278f3a74eb38533d34b9c4de4fd548615e3361fa64fe350ad0a", size = 17236, upload-time = "2024-11-11T06:04:54.189Z" }, + { url = "https://files.pythonhosted.org/packages/92/64/908e9e0bd84fe3c81c564115d3bbe0e49b0e152784bbaf153d749d00bbe6/tree_sitter_json-0.24.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:deeb45850dcc52990fbb52c80196492a099e3fa3512d928a390a91cf061068cc", size = 16071, upload-time = "2024-11-11T06:04:55.628Z" }, + { url = "https://files.pythonhosted.org/packages/53/df/31daab1eedb445bef208a04fc35428de3afe2b37075fec84d7737e1c69de/tree_sitter_json-0.24.8-cp39-abi3-win_amd64.whl", hash = "sha256:e4849a03cd7197267b2688a4506a90a13568a8e0e8588080bd0212fcb38974e3", size = 11457, upload-time = "2024-11-11T06:04:57.698Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3d/902d2f3125b6b90cebf404b63ca775bc6d82071ccc76c0d10fabfeb2febe/tree_sitter_json-0.24.8-cp39-abi3-win_arm64.whl", hash = "sha256:591e0096c882d12668b88f30d3ca6f85b9db3406910eaaab6afb6b17d65367dd", size = 10174, upload-time = "2024-11-11T06:04:59.309Z" }, ] [[package]] @@ -4942,111 +4942,111 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tree-sitter" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/9e/01be7a94fc69204350dc99707730aa4f32906cdb8b0b5856ff43fd0c60d5/tree_sitter_language_pack-1.13.3.tar.gz", hash = "sha256:e0e0287bf5a2f3d8a382136b1565206988417ece502b796f4b6f7fefb832963c", size = 82100 } +sdist = { url = "https://files.pythonhosted.org/packages/cb/9e/01be7a94fc69204350dc99707730aa4f32906cdb8b0b5856ff43fd0c60d5/tree_sitter_language_pack-1.13.3.tar.gz", hash = "sha256:e0e0287bf5a2f3d8a382136b1565206988417ece502b796f4b6f7fefb832963c", size = 82100, upload-time = "2026-07-21T14:10:20.469Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a0/69ac382a8e610a2e521fd1c661a59127b5387fad8789355c9fa60b27765a/tree_sitter_language_pack-1.13.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9229a14185583a0acfd9946154d491908078cc7a54150bf03268aed5a67cf3c3", size = 2141847 }, - { url = "https://files.pythonhosted.org/packages/bb/57/24ea7493d5704fcce545707732c652d7fd1cbe82c6f824cdc26554124069/tree_sitter_language_pack-1.13.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8435aabc1e5537e97e0a5893b6bf8d501ee7e55f93ee813e98711c5787fdbf78", size = 2033141 }, - { url = "https://files.pythonhosted.org/packages/4e/02/b476c812681922ac2763100aeb98040b880e6fae9bf5960068fb5652356d/tree_sitter_language_pack-1.13.3-cp310-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3096595f83c3ad1c8cdace1db737e074f4ef3c798534deb2fe758acda0d7e211", size = 2190490 }, - { url = "https://files.pythonhosted.org/packages/6e/f8/bfb863ffd53eb888308ffebeb9f73ed24bda0eac10393b9ec1b6774abe06/tree_sitter_language_pack-1.13.3-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ebcd8fa9435ff956bd82eeea21f492fb2ccfefd9be909fe5cdebabb892c4b034", size = 2301379 }, - { url = "https://files.pythonhosted.org/packages/5b/32/5a395972eee620b763493f6abf527dd76dea0cef91010ab348137cb51a2d/tree_sitter_language_pack-1.13.3-cp310-abi3-win_amd64.whl", hash = "sha256:c67c322370c30fa802e01532933709d0c29de8337d911eb693692a996993e55d", size = 2076066 }, - { url = "https://files.pythonhosted.org/packages/ff/56/b2cbaaaadbcadc90355f3bc7ecf9bb9f842ffd328ee9fb6b33518c93c360/tree_sitter_language_pack-1.13.3-cp310-abi3-win_arm64.whl", hash = "sha256:49eb4d6f532556076a885b36aab51919ae6ab396401144e4f73e4a0c9ead0219", size = 1987435 }, + { url = "https://files.pythonhosted.org/packages/75/a0/69ac382a8e610a2e521fd1c661a59127b5387fad8789355c9fa60b27765a/tree_sitter_language_pack-1.13.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9229a14185583a0acfd9946154d491908078cc7a54150bf03268aed5a67cf3c3", size = 2141847, upload-time = "2026-07-21T14:10:11.626Z" }, + { url = "https://files.pythonhosted.org/packages/bb/57/24ea7493d5704fcce545707732c652d7fd1cbe82c6f824cdc26554124069/tree_sitter_language_pack-1.13.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8435aabc1e5537e97e0a5893b6bf8d501ee7e55f93ee813e98711c5787fdbf78", size = 2033141, upload-time = "2026-07-21T14:10:13.131Z" }, + { url = "https://files.pythonhosted.org/packages/4e/02/b476c812681922ac2763100aeb98040b880e6fae9bf5960068fb5652356d/tree_sitter_language_pack-1.13.3-cp310-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3096595f83c3ad1c8cdace1db737e074f4ef3c798534deb2fe758acda0d7e211", size = 2190490, upload-time = "2026-07-21T14:10:14.66Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f8/bfb863ffd53eb888308ffebeb9f73ed24bda0eac10393b9ec1b6774abe06/tree_sitter_language_pack-1.13.3-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ebcd8fa9435ff956bd82eeea21f492fb2ccfefd9be909fe5cdebabb892c4b034", size = 2301379, upload-time = "2026-07-21T14:10:16.147Z" }, + { url = "https://files.pythonhosted.org/packages/5b/32/5a395972eee620b763493f6abf527dd76dea0cef91010ab348137cb51a2d/tree_sitter_language_pack-1.13.3-cp310-abi3-win_amd64.whl", hash = "sha256:c67c322370c30fa802e01532933709d0c29de8337d911eb693692a996993e55d", size = 2076066, upload-time = "2026-07-21T14:10:17.614Z" }, + { url = "https://files.pythonhosted.org/packages/ff/56/b2cbaaaadbcadc90355f3bc7ecf9bb9f842ffd328ee9fb6b33518c93c360/tree_sitter_language_pack-1.13.3-cp310-abi3-win_arm64.whl", hash = "sha256:49eb4d6f532556076a885b36aab51919ae6ab396401144e4f73e4a0c9ead0219", size = 1987435, upload-time = "2026-07-21T14:10:19.367Z" }, ] [[package]] name = "tree-sitter-python" version = "0.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b8/8b/c992ff0e768cb6768d5c96234579bf8842b3a633db641455d86dd30d5dac/tree_sitter_python-0.25.0.tar.gz", hash = "sha256:b13e090f725f5b9c86aa455a268553c65cadf325471ad5b65cd29cac8a1a68ac", size = 159845 } +sdist = { url = "https://files.pythonhosted.org/packages/b8/8b/c992ff0e768cb6768d5c96234579bf8842b3a633db641455d86dd30d5dac/tree_sitter_python-0.25.0.tar.gz", hash = "sha256:b13e090f725f5b9c86aa455a268553c65cadf325471ad5b65cd29cac8a1a68ac", size = 159845, upload-time = "2025-09-11T06:47:58.159Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/64/a4e503c78a4eb3ac46d8e72a29c1b1237fa85238d8e972b063e0751f5a94/tree_sitter_python-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:14a79a47ddef72f987d5a2c122d148a812169d7484ff5c75a3db9609d419f361", size = 73790 }, - { url = "https://files.pythonhosted.org/packages/e6/1d/60d8c2a0cc63d6ec4ba4e99ce61b802d2e39ef9db799bdf2a8f932a6cd4b/tree_sitter_python-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:480c21dbd995b7fe44813e741d71fed10ba695e7caab627fb034e3828469d762", size = 76691 }, - { url = "https://files.pythonhosted.org/packages/aa/cb/d9b0b67d037922d60cbe0359e0c86457c2da721bc714381a63e2c8e35eba/tree_sitter_python-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:86f118e5eecad616ecdb81d171a36dde9bef5a0b21ed71ea9c3e390813c3baf5", size = 108133 }, - { url = "https://files.pythonhosted.org/packages/40/bd/bf4787f57e6b2860f3f1c8c62f045b39fb32d6bac4b53d7a9e66de968440/tree_sitter_python-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be71650ca2b93b6e9649e5d65c6811aad87a7614c8c1003246b303f6b150f61b", size = 110603 }, - { url = "https://files.pythonhosted.org/packages/5d/25/feff09f5c2f32484fbce15db8b49455c7572346ce61a699a41972dea7318/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6d5b5799628cc0f24691ab2a172a8e676f668fe90dc60468bee14084a35c16d", size = 108998 }, - { url = "https://files.pythonhosted.org/packages/75/69/4946da3d6c0df316ccb938316ce007fb565d08f89d02d854f2d308f0309f/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:71959832fc5d9642e52c11f2f7d79ae520b461e63334927e93ca46cd61cd9683", size = 107268 }, - { url = "https://files.pythonhosted.org/packages/ed/a2/996fc2dfa1076dc460d3e2f3c75974ea4b8f02f6bc925383aaae519920e8/tree_sitter_python-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:9bcde33f18792de54ee579b00e1b4fe186b7926825444766f849bf7181793a76", size = 76073 }, - { url = "https://files.pythonhosted.org/packages/07/19/4b5569d9b1ebebb5907d11554a96ef3fa09364a30fcfabeff587495b512f/tree_sitter_python-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:0fbf6a3774ad7e89ee891851204c2e2c47e12b63a5edbe2e9156997731c128bb", size = 74169 }, + { url = "https://files.pythonhosted.org/packages/cf/64/a4e503c78a4eb3ac46d8e72a29c1b1237fa85238d8e972b063e0751f5a94/tree_sitter_python-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:14a79a47ddef72f987d5a2c122d148a812169d7484ff5c75a3db9609d419f361", size = 73790, upload-time = "2025-09-11T06:47:47.652Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/60d8c2a0cc63d6ec4ba4e99ce61b802d2e39ef9db799bdf2a8f932a6cd4b/tree_sitter_python-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:480c21dbd995b7fe44813e741d71fed10ba695e7caab627fb034e3828469d762", size = 76691, upload-time = "2025-09-11T06:47:49.038Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/d9b0b67d037922d60cbe0359e0c86457c2da721bc714381a63e2c8e35eba/tree_sitter_python-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:86f118e5eecad616ecdb81d171a36dde9bef5a0b21ed71ea9c3e390813c3baf5", size = 108133, upload-time = "2025-09-11T06:47:50.499Z" }, + { url = "https://files.pythonhosted.org/packages/40/bd/bf4787f57e6b2860f3f1c8c62f045b39fb32d6bac4b53d7a9e66de968440/tree_sitter_python-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be71650ca2b93b6e9649e5d65c6811aad87a7614c8c1003246b303f6b150f61b", size = 110603, upload-time = "2025-09-11T06:47:51.985Z" }, + { url = "https://files.pythonhosted.org/packages/5d/25/feff09f5c2f32484fbce15db8b49455c7572346ce61a699a41972dea7318/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6d5b5799628cc0f24691ab2a172a8e676f668fe90dc60468bee14084a35c16d", size = 108998, upload-time = "2025-09-11T06:47:53.046Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/4946da3d6c0df316ccb938316ce007fb565d08f89d02d854f2d308f0309f/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:71959832fc5d9642e52c11f2f7d79ae520b461e63334927e93ca46cd61cd9683", size = 107268, upload-time = "2025-09-11T06:47:54.388Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a2/996fc2dfa1076dc460d3e2f3c75974ea4b8f02f6bc925383aaae519920e8/tree_sitter_python-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:9bcde33f18792de54ee579b00e1b4fe186b7926825444766f849bf7181793a76", size = 76073, upload-time = "2025-09-11T06:47:55.773Z" }, + { url = "https://files.pythonhosted.org/packages/07/19/4b5569d9b1ebebb5907d11554a96ef3fa09364a30fcfabeff587495b512f/tree_sitter_python-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:0fbf6a3774ad7e89ee891851204c2e2c47e12b63a5edbe2e9156997731c128bb", size = 74169, upload-time = "2025-09-11T06:47:56.747Z" }, ] [[package]] name = "tree-sitter-rust" version = "0.24.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/87/75cbd22b927267d310f76cca1ab3c1d9d41035dfa3eb9cc95f96ee199440/tree_sitter_rust-0.24.2.tar.gz", hash = "sha256:54fb02a5911e345308b405174465112479f56dc39e3f1e7744d7568595f00db9", size = 339341 } +sdist = { url = "https://files.pythonhosted.org/packages/b7/87/75cbd22b927267d310f76cca1ab3c1d9d41035dfa3eb9cc95f96ee199440/tree_sitter_rust-0.24.2.tar.gz", hash = "sha256:54fb02a5911e345308b405174465112479f56dc39e3f1e7744d7568595f00db9", size = 339341, upload-time = "2026-03-27T21:08:55.629Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/24/2b2d33af5e27c84a4fde4e8cd2594bb4ab1e1cf48756a9f40dadc84956cc/tree_sitter_rust-0.24.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3620cfd12340efa43082d45df76349ff511893a9c361da2f8d6d51e307020a59", size = 129507 }, - { url = "https://files.pythonhosted.org/packages/78/2a/cf39f881a545360b5a86bb1accba1f4acc713daab01fb9edd35b6e84f473/tree_sitter_rust-0.24.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:01a46622735498493f29f3e628a90de95c96a07bfbeb88996243eb986b1cee36", size = 136812 }, - { url = "https://files.pythonhosted.org/packages/ca/45/a051bbd3045a61182dde25b93ae9a33d2677c935b16952283e12eaf46051/tree_sitter_rust-0.24.2-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e033c5a93b57c88e0a835880de39fc802909ff69f57aaff6000211c196ea5190", size = 164706 }, - { url = "https://files.pythonhosted.org/packages/b5/f6/a5a146df5c0a5daea3ffcd5d7245775fe7f084357770d5a313dd6245ae78/tree_sitter_rust-0.24.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d76d1208c3638b871236090759dfc13d478921320653a6c9da5336e7c58f65a", size = 170310 }, - { url = "https://files.pythonhosted.org/packages/95/a8/f85b1ca75e01361ca5f92d226593ca4857cea49551b9f6c8fa6fc08ea917/tree_sitter_rust-0.24.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87930163a462408c49ab62c667e74029bc26b4cc7123dd1bdc7352215786c64a", size = 168668 }, - { url = "https://files.pythonhosted.org/packages/a2/e1/3519f866a4679ca36acd9f5a06a779ecb8a92b18887c5546458d521df557/tree_sitter_rust-0.24.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:da2b86099028fd42c6cd32878b7b16b01f8aac0f7b0e98742b7fa6bc3cf09b89", size = 162403 }, - { url = "https://files.pythonhosted.org/packages/34/71/7ef609894dbfe5699eb16f7471f9b8af1d958d8ba3e29c238d7607e8cb47/tree_sitter_rust-0.24.2-cp39-abi3-win_amd64.whl", hash = "sha256:4529c125d928882ddfb879fdc6bc0704913261ecc078b6fa7902559e0daf200d", size = 129422 }, - { url = "https://files.pythonhosted.org/packages/b9/d8/050a781172745bc345f98abb7c56e72022ea0790f8e793de981c83c2ef15/tree_sitter_rust-0.24.2-cp39-abi3-win_arm64.whl", hash = "sha256:66ba90f61bd54f4c4f5d30434957daf64507c16b0313df76becb37d63f70a227", size = 128245 }, + { url = "https://files.pythonhosted.org/packages/d0/24/2b2d33af5e27c84a4fde4e8cd2594bb4ab1e1cf48756a9f40dadc84956cc/tree_sitter_rust-0.24.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3620cfd12340efa43082d45df76349ff511893a9c361da2f8d6d51e307020a59", size = 129507, upload-time = "2026-03-27T21:08:47.585Z" }, + { url = "https://files.pythonhosted.org/packages/78/2a/cf39f881a545360b5a86bb1accba1f4acc713daab01fb9edd35b6e84f473/tree_sitter_rust-0.24.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:01a46622735498493f29f3e628a90de95c96a07bfbeb88996243eb986b1cee36", size = 136812, upload-time = "2026-03-27T21:08:48.761Z" }, + { url = "https://files.pythonhosted.org/packages/ca/45/a051bbd3045a61182dde25b93ae9a33d2677c935b16952283e12eaf46051/tree_sitter_rust-0.24.2-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e033c5a93b57c88e0a835880de39fc802909ff69f57aaff6000211c196ea5190", size = 164706, upload-time = "2026-03-27T21:08:49.605Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f6/a5a146df5c0a5daea3ffcd5d7245775fe7f084357770d5a313dd6245ae78/tree_sitter_rust-0.24.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d76d1208c3638b871236090759dfc13d478921320653a6c9da5336e7c58f65a", size = 170310, upload-time = "2026-03-27T21:08:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/95/a8/f85b1ca75e01361ca5f92d226593ca4857cea49551b9f6c8fa6fc08ea917/tree_sitter_rust-0.24.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:87930163a462408c49ab62c667e74029bc26b4cc7123dd1bdc7352215786c64a", size = 168668, upload-time = "2026-03-27T21:08:51.404Z" }, + { url = "https://files.pythonhosted.org/packages/a2/e1/3519f866a4679ca36acd9f5a06a779ecb8a92b18887c5546458d521df557/tree_sitter_rust-0.24.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:da2b86099028fd42c6cd32878b7b16b01f8aac0f7b0e98742b7fa6bc3cf09b89", size = 162403, upload-time = "2026-03-27T21:08:52.588Z" }, + { url = "https://files.pythonhosted.org/packages/34/71/7ef609894dbfe5699eb16f7471f9b8af1d958d8ba3e29c238d7607e8cb47/tree_sitter_rust-0.24.2-cp39-abi3-win_amd64.whl", hash = "sha256:4529c125d928882ddfb879fdc6bc0704913261ecc078b6fa7902559e0daf200d", size = 129422, upload-time = "2026-03-27T21:08:54.031Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d8/050a781172745bc345f98abb7c56e72022ea0790f8e793de981c83c2ef15/tree_sitter_rust-0.24.2-cp39-abi3-win_arm64.whl", hash = "sha256:66ba90f61bd54f4c4f5d30434957daf64507c16b0313df76becb37d63f70a227", size = 128245, upload-time = "2026-03-27T21:08:54.803Z" }, ] [[package]] name = "tree-sitter-typescript" version = "0.23.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/fc/bb52958f7e399250aee093751e9373a6311cadbe76b6e0d109b853757f35/tree_sitter_typescript-0.23.2.tar.gz", hash = "sha256:7b167b5827c882261cb7a50dfa0fb567975f9b315e87ed87ad0a0a3aedb3834d", size = 773053 } +sdist = { url = "https://files.pythonhosted.org/packages/1e/fc/bb52958f7e399250aee093751e9373a6311cadbe76b6e0d109b853757f35/tree_sitter_typescript-0.23.2.tar.gz", hash = "sha256:7b167b5827c882261cb7a50dfa0fb567975f9b315e87ed87ad0a0a3aedb3834d", size = 773053, upload-time = "2024-11-11T02:36:11.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/28/95/4c00680866280e008e81dd621fd4d3f54aa3dad1b76b857a19da1b2cc426/tree_sitter_typescript-0.23.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3cd752d70d8e5371fdac6a9a4df9d8924b63b6998d268586f7d374c9fba2a478", size = 286677 }, - { url = "https://files.pythonhosted.org/packages/8f/2f/1f36fda564518d84593f2740d5905ac127d590baf5c5753cef2a88a89c15/tree_sitter_typescript-0.23.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c7cc1b0ff5d91bac863b0e38b1578d5505e718156c9db577c8baea2557f66de8", size = 302008 }, - { url = "https://files.pythonhosted.org/packages/96/2d/975c2dad292aa9994f982eb0b69cc6fda0223e4b6c4ea714550477d8ec3a/tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b1eed5b0b3a8134e86126b00b743d667ec27c63fc9de1b7bb23168803879e31", size = 351987 }, - { url = "https://files.pythonhosted.org/packages/49/d1/a71c36da6e2b8a4ed5e2970819b86ef13ba77ac40d9e333cb17df6a2c5db/tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e96d36b85bcacdeb8ff5c2618d75593ef12ebaf1b4eace3477e2bdb2abb1752c", size = 344960 }, - { url = "https://files.pythonhosted.org/packages/7f/cb/f57b149d7beed1a85b8266d0c60ebe4c46e79c9ba56bc17b898e17daf88e/tree_sitter_typescript-0.23.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8d4f0f9bcb61ad7b7509d49a1565ff2cc363863644a234e1e0fe10960e55aea0", size = 340245 }, - { url = "https://files.pythonhosted.org/packages/8b/ab/dd84f0e2337296a5f09749f7b5483215d75c8fa9e33738522e5ed81f7254/tree_sitter_typescript-0.23.2-cp39-abi3-win_amd64.whl", hash = "sha256:3f730b66396bc3e11811e4465c41ee45d9e9edd6de355a58bbbc49fa770da8f9", size = 278015 }, - { url = "https://files.pythonhosted.org/packages/9f/e4/81f9a935789233cf412a0ed5fe04c883841d2c8fb0b7e075958a35c65032/tree_sitter_typescript-0.23.2-cp39-abi3-win_arm64.whl", hash = "sha256:05db58f70b95ef0ea126db5560f3775692f609589ed6f8dd0af84b7f19f1cbb7", size = 274052 }, + { url = "https://files.pythonhosted.org/packages/28/95/4c00680866280e008e81dd621fd4d3f54aa3dad1b76b857a19da1b2cc426/tree_sitter_typescript-0.23.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3cd752d70d8e5371fdac6a9a4df9d8924b63b6998d268586f7d374c9fba2a478", size = 286677, upload-time = "2024-11-11T02:35:58.839Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/1f36fda564518d84593f2740d5905ac127d590baf5c5753cef2a88a89c15/tree_sitter_typescript-0.23.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c7cc1b0ff5d91bac863b0e38b1578d5505e718156c9db577c8baea2557f66de8", size = 302008, upload-time = "2024-11-11T02:36:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/96/2d/975c2dad292aa9994f982eb0b69cc6fda0223e4b6c4ea714550477d8ec3a/tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b1eed5b0b3a8134e86126b00b743d667ec27c63fc9de1b7bb23168803879e31", size = 351987, upload-time = "2024-11-11T02:36:02.669Z" }, + { url = "https://files.pythonhosted.org/packages/49/d1/a71c36da6e2b8a4ed5e2970819b86ef13ba77ac40d9e333cb17df6a2c5db/tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e96d36b85bcacdeb8ff5c2618d75593ef12ebaf1b4eace3477e2bdb2abb1752c", size = 344960, upload-time = "2024-11-11T02:36:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/7f/cb/f57b149d7beed1a85b8266d0c60ebe4c46e79c9ba56bc17b898e17daf88e/tree_sitter_typescript-0.23.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8d4f0f9bcb61ad7b7509d49a1565ff2cc363863644a234e1e0fe10960e55aea0", size = 340245, upload-time = "2024-11-11T02:36:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ab/dd84f0e2337296a5f09749f7b5483215d75c8fa9e33738522e5ed81f7254/tree_sitter_typescript-0.23.2-cp39-abi3-win_amd64.whl", hash = "sha256:3f730b66396bc3e11811e4465c41ee45d9e9edd6de355a58bbbc49fa770da8f9", size = 278015, upload-time = "2024-11-11T02:36:07.631Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e4/81f9a935789233cf412a0ed5fe04c883841d2c8fb0b7e075958a35c65032/tree_sitter_typescript-0.23.2-cp39-abi3-win_arm64.whl", hash = "sha256:05db58f70b95ef0ea126db5560f3775692f609589ed6f8dd0af84b7f19f1cbb7", size = 274052, upload-time = "2024-11-11T02:36:09.514Z" }, ] [[package]] name = "tree-sitter-yaml" version = "0.7.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/b6/941d356ac70c90b9d2927375259e3a4204f38f7499ec6e7e8a95b9664689/tree_sitter_yaml-0.7.2.tar.gz", hash = "sha256:756db4c09c9d9e97c81699e8f941cb8ce4e51104927f6090eefe638ee567d32c", size = 84882 } +sdist = { url = "https://files.pythonhosted.org/packages/57/b6/941d356ac70c90b9d2927375259e3a4204f38f7499ec6e7e8a95b9664689/tree_sitter_yaml-0.7.2.tar.gz", hash = "sha256:756db4c09c9d9e97c81699e8f941cb8ce4e51104927f6090eefe638ee567d32c", size = 84882, upload-time = "2025-10-07T14:40:36.071Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/29/c0b8dbff302c49ff4284666ffb6f2f21145006843bb4c3a9a85d0ec0b7ae/tree_sitter_yaml-0.7.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7e269ddcfcab8edb14fbb1f1d34eed1e1e26888f78f94eedfe7cc98c60f8bc9f", size = 43898 }, - { url = "https://files.pythonhosted.org/packages/18/0d/15a5add06b3932b5e4ce5f5e8e179197097decfe82a0ef000952c8b98216/tree_sitter_yaml-0.7.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0807b7966e23ddf7dddc4545216e28b5a58cdadedcecca86b8d8c74271a07870", size = 44691 }, - { url = "https://files.pythonhosted.org/packages/72/92/c4b896c90d08deb8308fadbad2210fdcc4c66c44ab4292eac4e80acb4b61/tree_sitter_yaml-0.7.2-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1a5c60c98b6c4c037aae023569f020d0c489fad8dc26fdfd5510363c9c29a41", size = 91430 }, - { url = "https://files.pythonhosted.org/packages/89/59/61f1fed31eb6d46ff080b8c0d53658cf29e10263f41ef5fe34768908037a/tree_sitter_yaml-0.7.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88636d19d0654fd24f4f242eaaafa90f6f5ebdba8a62e4b32d251ed156c51a2a", size = 92428 }, - { url = "https://files.pythonhosted.org/packages/e3/62/a33a04d19b7f9a0ded780b9c9fcc6279e37c5d00b89b00425bb807a22cc2/tree_sitter_yaml-0.7.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1d2e8f0bb14aa4537320952d0f9607eef3021d5aada8383c34ebeece17db1e06", size = 90580 }, - { url = "https://files.pythonhosted.org/packages/6c/e7/9525defa7b30792623f56b1fba9bbba361752348875b165b8975b87398fd/tree_sitter_yaml-0.7.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:74ca712c50fc9d7dbc68cb36b4a7811d6e67a5466b5a789f19bf8dd6084ef752", size = 90455 }, - { url = "https://files.pythonhosted.org/packages/4a/d6/8d1e1ace03db3b02e64e91daf21d1347941d1bbecc606a5473a1a605250d/tree_sitter_yaml-0.7.2-cp310-abi3-win_amd64.whl", hash = "sha256:7587b5ca00fc4f9a548eff649697a3b395370b2304b399ceefa2087d8a6c9186", size = 45514 }, - { url = "https://files.pythonhosted.org/packages/d8/c7/dcf3ea1c4f5da9b10353b9af4455d756c92d728a8f58f03c480d3ef0ead5/tree_sitter_yaml-0.7.2-cp310-abi3-win_arm64.whl", hash = "sha256:f63c227b18e7ce7587bce124578f0bbf1f890ac63d3e3cd027417574273642c4", size = 44065 }, + { url = "https://files.pythonhosted.org/packages/38/29/c0b8dbff302c49ff4284666ffb6f2f21145006843bb4c3a9a85d0ec0b7ae/tree_sitter_yaml-0.7.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:7e269ddcfcab8edb14fbb1f1d34eed1e1e26888f78f94eedfe7cc98c60f8bc9f", size = 43898, upload-time = "2025-10-07T14:40:29.486Z" }, + { url = "https://files.pythonhosted.org/packages/18/0d/15a5add06b3932b5e4ce5f5e8e179197097decfe82a0ef000952c8b98216/tree_sitter_yaml-0.7.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:0807b7966e23ddf7dddc4545216e28b5a58cdadedcecca86b8d8c74271a07870", size = 44691, upload-time = "2025-10-07T14:40:30.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/92/c4b896c90d08deb8308fadbad2210fdcc4c66c44ab4292eac4e80acb4b61/tree_sitter_yaml-0.7.2-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1a5c60c98b6c4c037aae023569f020d0c489fad8dc26fdfd5510363c9c29a41", size = 91430, upload-time = "2025-10-07T14:40:31.16Z" }, + { url = "https://files.pythonhosted.org/packages/89/59/61f1fed31eb6d46ff080b8c0d53658cf29e10263f41ef5fe34768908037a/tree_sitter_yaml-0.7.2-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88636d19d0654fd24f4f242eaaafa90f6f5ebdba8a62e4b32d251ed156c51a2a", size = 92428, upload-time = "2025-10-07T14:40:31.954Z" }, + { url = "https://files.pythonhosted.org/packages/e3/62/a33a04d19b7f9a0ded780b9c9fcc6279e37c5d00b89b00425bb807a22cc2/tree_sitter_yaml-0.7.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1d2e8f0bb14aa4537320952d0f9607eef3021d5aada8383c34ebeece17db1e06", size = 90580, upload-time = "2025-10-07T14:40:33.037Z" }, + { url = "https://files.pythonhosted.org/packages/6c/e7/9525defa7b30792623f56b1fba9bbba361752348875b165b8975b87398fd/tree_sitter_yaml-0.7.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:74ca712c50fc9d7dbc68cb36b4a7811d6e67a5466b5a789f19bf8dd6084ef752", size = 90455, upload-time = "2025-10-07T14:40:33.778Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d6/8d1e1ace03db3b02e64e91daf21d1347941d1bbecc606a5473a1a605250d/tree_sitter_yaml-0.7.2-cp310-abi3-win_amd64.whl", hash = "sha256:7587b5ca00fc4f9a548eff649697a3b395370b2304b399ceefa2087d8a6c9186", size = 45514, upload-time = "2025-10-07T14:40:34.562Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c7/dcf3ea1c4f5da9b10353b9af4455d756c92d728a8f58f03c480d3ef0ead5/tree_sitter_yaml-0.7.2-cp310-abi3-win_arm64.whl", hash = "sha256:f63c227b18e7ce7587bce124578f0bbf1f890ac63d3e3cd027417574273642c4", size = 44065, upload-time = "2025-10-07T14:40:35.337Z" }, ] [[package]] name = "truststore" version = "0.10.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169 } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660 }, + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, ] [[package]] name = "ty" version = "0.0.63" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/ce/cbeaa5c7576fec643609dfbf200d59493523b1cc0481d4e7a5effcbf0630/ty-0.0.63.tar.gz", hash = "sha256:c2f66439393b3acac69306c117d4ae44638ce5fffa4a20c21046e85bd473359f", size = 6280695 } +sdist = { url = "https://files.pythonhosted.org/packages/ba/ce/cbeaa5c7576fec643609dfbf200d59493523b1cc0481d4e7a5effcbf0630/ty-0.0.63.tar.gz", hash = "sha256:c2f66439393b3acac69306c117d4ae44638ce5fffa4a20c21046e85bd473359f", size = 6280695, upload-time = "2026-07-23T11:41:39.845Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/e4/d17a8e113ab15c692fe6fb9c422112d4bee7e829d80ced35826b45d98d98/ty-0.0.63-py3-none-linux_armv6l.whl", hash = "sha256:9a4ef7782e3af314fb63d006bec5ac3025bd70fee774b4dcfb5e44e8564f1994", size = 12056302 }, - { url = "https://files.pythonhosted.org/packages/be/0b/357234c815dc4bfcc88c3f860aa0983fe4228c8aa2a5bb16c35ee08a94af/ty-0.0.63-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:01eab0ab70d51ad10298aa2d4b058b387a1fe93e5ee52d2a1ee23e9c69ba8354", size = 11737674 }, - { url = "https://files.pythonhosted.org/packages/1c/13/193d9aeeb6774690351cff9fafabd3ae9b54cc225d125e07fa004ce23bdc/ty-0.0.63-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a671b61eaad16178389e05b9c108c9cb75ae8d84968fe55906484f55d6268338", size = 11264191 }, - { url = "https://files.pythonhosted.org/packages/4f/b7/c5843e16759a2b25fc8a7197473474038e585ac9fdaa6fa16aeb6384bf6a/ty-0.0.63-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b255cc83d95c51bb9ee4931303fdbece8cb1d6d7c655eb77a3f2c8f349fb64d6", size = 11818890 }, - { url = "https://files.pythonhosted.org/packages/06/20/adf83ae1fc570d9bb449605e1eeeaa523ad2329ca838a636698b5c135d11/ty-0.0.63-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0da1e8183aa4f893421a173904478021498f542ee41273660538da6649a9631c", size = 11853141 }, - { url = "https://files.pythonhosted.org/packages/ea/90/f8effd846e3ee13486ea08257c13094d58b9f188c5641bf609d6a7d5c09f/ty-0.0.63-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:128f38eb5a67199e3811426386f7ec96f41251ddf45e04ddc0bcbb29d4853245", size = 12545184 }, - { url = "https://files.pythonhosted.org/packages/b8/76/aac3a30d40431eb1d329acea016b248f5b6255e30ef3d28e19447e344be2/ty-0.0.63-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bedf34aff8b0557f2a7119b314a68a9c9e58c1d21554d0e2748f2c5fe6a1f638", size = 13062375 }, - { url = "https://files.pythonhosted.org/packages/62/3d/0158733932893e17f6008dadd74c8d7aed422fefc66e47fe77888014fe8c/ty-0.0.63-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7328d63c34587606dce02935a25404f54cd0161bfe413b8f7fc21d174aead612", size = 12619461 }, - { url = "https://files.pythonhosted.org/packages/f5/be/e280ad095b050778f16f493d49a073fa5f6d8f301d3e2e59be6a672ba05c/ty-0.0.63-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504c4457f3a62afe836c1f26a2c9a12549299095f9cc4146778558df51a7515c", size = 12376402 }, - { url = "https://files.pythonhosted.org/packages/57/57/619788bf335cb86b090470b557ae1eed33613dc24e12142505d32b462e58/ty-0.0.63-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7394ed39424b027c89d5f0c818871c791e33958b88f5bb13e14bd4a12a3ca631", size = 12671377 }, - { url = "https://files.pythonhosted.org/packages/07/a2/0aff2cdd3c98e2c4729337542b8da0ce1980790e1600e0c4a717a1f46293/ty-0.0.63-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:94c3f155230490f8fb505911655e940c630c25cc0c35a76690cb413254fb4437", size = 11768090 }, - { url = "https://files.pythonhosted.org/packages/43/4a/cdb5f1d26154144dfee08e1bd671daf827238170f7cee9dad43990bb2016/ty-0.0.63-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c16e1d8b4f0ae99106d5f13a894034a202baf6cdab61e2bb1a239de82904f839", size = 11867747 }, - { url = "https://files.pythonhosted.org/packages/7e/26/ecc09ecb70bc9fbfdf42fa57ff29568f173e8200df575a0d72dc2b8486f9/ty-0.0.63-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b66293dad89eaed4b9fbf3b661614dbeb85b59ba037178a3f9a0adedf264da5c", size = 12120000 }, - { url = "https://files.pythonhosted.org/packages/14/07/862822f9c2c397785b69b25cf79b4dfc3c0d55684b9adf11ac194450f8e1/ty-0.0.63-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8b29cc832e2ec73502c97dd7325d6ae0e085b34a33529a0f785192317d9862fc", size = 12477862 }, - { url = "https://files.pythonhosted.org/packages/d0/c2/50b08b641578d6f48e8aa28a91d0de32c91aa24dd926ed199e8afd2d37ea/ty-0.0.63-py3-none-win32.whl", hash = "sha256:f0a8fbfd1f990c0c5d85cce018d438969ac79e49247e2192c9745394bb7d17ab", size = 11433155 }, - { url = "https://files.pythonhosted.org/packages/92/2d/d422a5568f0d1f317186be22241288dc6e08b71dc24aca223f22038ac2d2/ty-0.0.63-py3-none-win_amd64.whl", hash = "sha256:2aa2370bdd6f42e9f37518c812379bef70b9162f9e5aad511495229b0b71cb93", size = 12450036 }, - { url = "https://files.pythonhosted.org/packages/35/97/2c9748e28ead0650c7ad3e5f74f178832ceabd7cb5c272a882f29eb32ee4/ty-0.0.63-py3-none-win_arm64.whl", hash = "sha256:95ac1a62162c3c7ac204731e95ebf766d62a46a7bfa238a6acbe923fcb772cb1", size = 11826243 }, + { url = "https://files.pythonhosted.org/packages/1a/e4/d17a8e113ab15c692fe6fb9c422112d4bee7e829d80ced35826b45d98d98/ty-0.0.63-py3-none-linux_armv6l.whl", hash = "sha256:9a4ef7782e3af314fb63d006bec5ac3025bd70fee774b4dcfb5e44e8564f1994", size = 12056302, upload-time = "2026-07-23T11:41:03.5Z" }, + { url = "https://files.pythonhosted.org/packages/be/0b/357234c815dc4bfcc88c3f860aa0983fe4228c8aa2a5bb16c35ee08a94af/ty-0.0.63-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:01eab0ab70d51ad10298aa2d4b058b387a1fe93e5ee52d2a1ee23e9c69ba8354", size = 11737674, upload-time = "2026-07-23T11:41:05.862Z" }, + { url = "https://files.pythonhosted.org/packages/1c/13/193d9aeeb6774690351cff9fafabd3ae9b54cc225d125e07fa004ce23bdc/ty-0.0.63-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a671b61eaad16178389e05b9c108c9cb75ae8d84968fe55906484f55d6268338", size = 11264191, upload-time = "2026-07-23T11:41:07.963Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/c5843e16759a2b25fc8a7197473474038e585ac9fdaa6fa16aeb6384bf6a/ty-0.0.63-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b255cc83d95c51bb9ee4931303fdbece8cb1d6d7c655eb77a3f2c8f349fb64d6", size = 11818890, upload-time = "2026-07-23T11:41:09.885Z" }, + { url = "https://files.pythonhosted.org/packages/06/20/adf83ae1fc570d9bb449605e1eeeaa523ad2329ca838a636698b5c135d11/ty-0.0.63-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0da1e8183aa4f893421a173904478021498f542ee41273660538da6649a9631c", size = 11853141, upload-time = "2026-07-23T11:41:12.151Z" }, + { url = "https://files.pythonhosted.org/packages/ea/90/f8effd846e3ee13486ea08257c13094d58b9f188c5641bf609d6a7d5c09f/ty-0.0.63-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:128f38eb5a67199e3811426386f7ec96f41251ddf45e04ddc0bcbb29d4853245", size = 12545184, upload-time = "2026-07-23T11:41:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/aac3a30d40431eb1d329acea016b248f5b6255e30ef3d28e19447e344be2/ty-0.0.63-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bedf34aff8b0557f2a7119b314a68a9c9e58c1d21554d0e2748f2c5fe6a1f638", size = 13062375, upload-time = "2026-07-23T11:41:16.441Z" }, + { url = "https://files.pythonhosted.org/packages/62/3d/0158733932893e17f6008dadd74c8d7aed422fefc66e47fe77888014fe8c/ty-0.0.63-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7328d63c34587606dce02935a25404f54cd0161bfe413b8f7fc21d174aead612", size = 12619461, upload-time = "2026-07-23T11:41:18.538Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/e280ad095b050778f16f493d49a073fa5f6d8f301d3e2e59be6a672ba05c/ty-0.0.63-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:504c4457f3a62afe836c1f26a2c9a12549299095f9cc4146778558df51a7515c", size = 12376402, upload-time = "2026-07-23T11:41:20.482Z" }, + { url = "https://files.pythonhosted.org/packages/57/57/619788bf335cb86b090470b557ae1eed33613dc24e12142505d32b462e58/ty-0.0.63-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7394ed39424b027c89d5f0c818871c791e33958b88f5bb13e14bd4a12a3ca631", size = 12671377, upload-time = "2026-07-23T11:41:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/07/a2/0aff2cdd3c98e2c4729337542b8da0ce1980790e1600e0c4a717a1f46293/ty-0.0.63-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:94c3f155230490f8fb505911655e940c630c25cc0c35a76690cb413254fb4437", size = 11768090, upload-time = "2026-07-23T11:41:24.558Z" }, + { url = "https://files.pythonhosted.org/packages/43/4a/cdb5f1d26154144dfee08e1bd671daf827238170f7cee9dad43990bb2016/ty-0.0.63-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c16e1d8b4f0ae99106d5f13a894034a202baf6cdab61e2bb1a239de82904f839", size = 11867747, upload-time = "2026-07-23T11:41:26.794Z" }, + { url = "https://files.pythonhosted.org/packages/7e/26/ecc09ecb70bc9fbfdf42fa57ff29568f173e8200df575a0d72dc2b8486f9/ty-0.0.63-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b66293dad89eaed4b9fbf3b661614dbeb85b59ba037178a3f9a0adedf264da5c", size = 12120000, upload-time = "2026-07-23T11:41:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/14/07/862822f9c2c397785b69b25cf79b4dfc3c0d55684b9adf11ac194450f8e1/ty-0.0.63-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8b29cc832e2ec73502c97dd7325d6ae0e085b34a33529a0f785192317d9862fc", size = 12477862, upload-time = "2026-07-23T11:41:30.847Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c2/50b08b641578d6f48e8aa28a91d0de32c91aa24dd926ed199e8afd2d37ea/ty-0.0.63-py3-none-win32.whl", hash = "sha256:f0a8fbfd1f990c0c5d85cce018d438969ac79e49247e2192c9745394bb7d17ab", size = 11433155, upload-time = "2026-07-23T11:41:33.28Z" }, + { url = "https://files.pythonhosted.org/packages/92/2d/d422a5568f0d1f317186be22241288dc6e08b71dc24aca223f22038ac2d2/ty-0.0.63-py3-none-win_amd64.whl", hash = "sha256:2aa2370bdd6f42e9f37518c812379bef70b9162f9e5aad511495229b0b71cb93", size = 12450036, upload-time = "2026-07-23T11:41:35.559Z" }, + { url = "https://files.pythonhosted.org/packages/35/97/2c9748e28ead0650c7ad3e5f74f178832ceabd7cb5c272a882f29eb32ee4/ty-0.0.63-py3-none-win_arm64.whl", hash = "sha256:95ac1a62162c3c7ac204731e95ebf766d62a46a7bfa238a6acbe923fcb772cb1", size = 11826243, upload-time = "2026-07-23T11:41:37.749Z" }, ] [[package]] @@ -5056,9 +5056,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240 } +sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240, upload-time = "2026-05-14T12:59:40.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl", hash = "sha256:fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf", size = 36748 }, + { url = "https://files.pythonhosted.org/packages/5b/29/74eeb4d3f3ae61ca096b018ad486b3b3c74b17bec09ab4edab721cbefec3/typeguard-4.5.2-py3-none-any.whl", hash = "sha256:fcf9de18bd945cdb4c7b996e12b4c51ce83f92f191314a6d7cf1739586ec98cf", size = 36748, upload-time = "2026-05-14T12:59:39.473Z" }, ] [[package]] @@ -5071,27 +5071,27 @@ dependencies = [ { name = "rich" }, { name = "shellingham" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430 } +sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716 }, + { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, ] [[package]] name = "types-croniter" version = "6.2.4.20260711" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/e1/ef8900515a103efa41b29ce81e59efb18e72bc9642139c5a3a17f6022641/types_croniter-6.2.4.20260711.tar.gz", hash = "sha256:807793e109f4e0d51ec18f6277a1a5f1d133e425ac6bee3a7c3254ab1793e0ea", size = 12164 } +sdist = { url = "https://files.pythonhosted.org/packages/c0/e1/ef8900515a103efa41b29ce81e59efb18e72bc9642139c5a3a17f6022641/types_croniter-6.2.4.20260711.tar.gz", hash = "sha256:807793e109f4e0d51ec18f6277a1a5f1d133e425ac6bee3a7c3254ab1793e0ea", size = 12164, upload-time = "2026-07-11T04:51:18.624Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/3f/896a98180a55f855bd068b97e8c99fd4ce1885a4b43f68fd7b29641084f6/types_croniter-6.2.4.20260711-py3-none-any.whl", hash = "sha256:601b68139a47fe1b2177f433132cc81fd91155f3059a49c5cb63404c389dff01", size = 9746 }, + { url = "https://files.pythonhosted.org/packages/c8/3f/896a98180a55f855bd068b97e8c99fd4ce1885a4b43f68fd7b29641084f6/types_croniter-6.2.4.20260711-py3-none-any.whl", hash = "sha256:601b68139a47fe1b2177f433132cc81fd91155f3059a49c5cb63404c389dff01", size = 9746, upload-time = "2026-07-11T04:51:17.662Z" }, ] [[package]] name = "types-docutils" version = "0.22.3.20260724" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/8f/30e02b59a9aad81eacd3d02fab5365257e89fde56e7acf44a4128f5f80aa/types_docutils-0.22.3.20260724.tar.gz", hash = "sha256:0223ce87f9b8331a5a3a7c7832e828033d289da6b878a0dcfbc74c30ec58850e", size = 57883 } +sdist = { url = "https://files.pythonhosted.org/packages/e5/8f/30e02b59a9aad81eacd3d02fab5365257e89fde56e7acf44a4128f5f80aa/types_docutils-0.22.3.20260724.tar.gz", hash = "sha256:0223ce87f9b8331a5a3a7c7832e828033d289da6b878a0dcfbc74c30ec58850e", size = 57883, upload-time = "2026-07-24T04:58:36.388Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/1a/898fbb98680dbd5eb7ac8e9cbaf39cf234d329a45d89d04faaf33d7d8008/types_docutils-0.22.3.20260724-py3-none-any.whl", hash = "sha256:45e2fe584608671aa648784c4f805d08a36cd69eb2bd542e60522140c46dc89c", size = 91986 }, + { url = "https://files.pythonhosted.org/packages/13/1a/898fbb98680dbd5eb7ac8e9cbaf39cf234d329a45d89d04faaf33d7d8008/types_docutils-0.22.3.20260724-py3-none-any.whl", hash = "sha256:45e2fe584608671aa648784c4f805d08a36cd69eb2bd542e60522140c46dc89c", size = 91986, upload-time = "2026-07-24T04:58:35.368Z" }, ] [[package]] @@ -5101,9 +5101,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dd/46/73b6a5d61a61015c4248030a8cb07e5bdddb4041430fae9e585a68692578/types_jsonschema-4.26.0.20260518.tar.gz", hash = "sha256:e1dd53dc97a64f5eccdd6fa9839666e09bb500a8ebba2db6fdaf1789faea81a6", size = 16638 } +sdist = { url = "https://files.pythonhosted.org/packages/dd/46/73b6a5d61a61015c4248030a8cb07e5bdddb4041430fae9e585a68692578/types_jsonschema-4.26.0.20260518.tar.gz", hash = "sha256:e1dd53dc97a64f5eccdd6fa9839666e09bb500a8ebba2db6fdaf1789faea81a6", size = 16638, upload-time = "2026-05-18T06:06:44.106Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/07/d5/134f8a147dcecda10db7f60cfc6af0578a25a5c53c87b3907a64385e0184/types_jsonschema-4.26.0.20260518-py3-none-any.whl", hash = "sha256:30b30a518c7fe335df85c919fcbcc631b69c03d4a4b5b632fa916bea03065307", size = 16072 }, + { url = "https://files.pythonhosted.org/packages/07/d5/134f8a147dcecda10db7f60cfc6af0578a25a5c53c87b3907a64385e0184/types_jsonschema-4.26.0.20260518-py3-none-any.whl", hash = "sha256:30b30a518c7fe335df85c919fcbcc631b69c03d4a4b5b632fa916bea03065307", size = 16072, upload-time = "2026-05-18T06:06:43.264Z" }, ] [[package]] @@ -5113,27 +5113,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-docutils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/66/3e27e8dbe72947d51355d1fcba222a55bf5f0770ee660e9cc9df0d1ce5d7/types_pygments-2.20.0.20260518.tar.gz", hash = "sha256:bcab233d0389cb0a91146eb860e7bdcbceaa0f30bee73cefc7b367129cc4a330", size = 21152 } +sdist = { url = "https://files.pythonhosted.org/packages/db/66/3e27e8dbe72947d51355d1fcba222a55bf5f0770ee660e9cc9df0d1ce5d7/types_pygments-2.20.0.20260518.tar.gz", hash = "sha256:bcab233d0389cb0a91146eb860e7bdcbceaa0f30bee73cefc7b367129cc4a330", size = 21152, upload-time = "2026-05-18T06:07:26.927Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/66/d6/5f19f5b633af6a7666c6096fdd06b70f0d4a8f585af5e18a3ef58ad47ec1/types_pygments-2.20.0.20260518-py3-none-any.whl", hash = "sha256:e40728efd00c9da5936366648d5b3c55e72ed52bdd80495a96577b4d717c4fcb", size = 29002 }, + { url = "https://files.pythonhosted.org/packages/66/d6/5f19f5b633af6a7666c6096fdd06b70f0d4a8f585af5e18a3ef58ad47ec1/types_pygments-2.20.0.20260518-py3-none-any.whl", hash = "sha256:e40728efd00c9da5936366648d5b3c55e72ed52bdd80495a96577b4d717c4fcb", size = 29002, upload-time = "2026-05-18T06:07:26.054Z" }, ] [[package]] name = "types-pyyaml" version = "6.0.12.20260724" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893 } +sdist = { url = "https://files.pythonhosted.org/packages/3f/6f/a28f44bcd56bebed42b028a2894c79853e2f5e6b5279e633cb3f287a05e7/types_pyyaml-6.0.12.20260724.tar.gz", hash = "sha256:3c1ce1bb73cd5ec02e90390c2b1f00e810d241d8825fd73ff359696839271b6b", size = 17893, upload-time = "2026-07-24T04:58:43.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312 }, + { url = "https://files.pythonhosted.org/packages/8b/42/0337fefc615e20ee55d1c8f71b774a9b2b734a04669139c20753b27a2a3a/types_pyyaml-6.0.12.20260724-py3-none-any.whl", hash = "sha256:d57db930a4b2efbc57cf430ec8882765d246929432fa253092f383902329a453", size = 20312, upload-time = "2026-07-24T04:58:42.486Z" }, ] [[package]] name = "typing-extensions" version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555 } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -5144,9 +5144,9 @@ dependencies = [ { name = "mypy-extensions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825 } +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827 }, + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, ] [[package]] @@ -5156,27 +5156,27 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] name = "tzdata" version = "2026.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674 } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168 }, + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] [[package]] name = "uncalled-for" version = "0.3.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032 } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444 }, + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, ] [[package]] @@ -5187,9 +5187,9 @@ dependencies = [ { name = "fsspec" }, { name = "pathlib-abc" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/6e/d997a70ee8f4c61f9a7e2f4f8af721cf072a3326848fc881b05187e52558/universal_pathlib-0.3.10.tar.gz", hash = "sha256:4487cbc90730a48cfb64f811d99e14b6faed6d738420cd5f93f59f48e6930bfb", size = 261110 } +sdist = { url = "https://files.pythonhosted.org/packages/3d/6e/d997a70ee8f4c61f9a7e2f4f8af721cf072a3326848fc881b05187e52558/universal_pathlib-0.3.10.tar.gz", hash = "sha256:4487cbc90730a48cfb64f811d99e14b6faed6d738420cd5f93f59f48e6930bfb", size = 261110, upload-time = "2026-02-22T14:40:58.87Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dd/1a/5d9a402b39ec892d856bbdd9db502ff73ce28cdf4aff72eb1ce1d6843506/universal_pathlib-0.3.10-py3-none-any.whl", hash = "sha256:dfaf2fb35683d2eb1287a3ed7b215e4d6016aa6eaf339c607023d22f90821c66", size = 83528 }, + { url = "https://files.pythonhosted.org/packages/dd/1a/5d9a402b39ec892d856bbdd9db502ff73ce28cdf4aff72eb1ce1d6843506/universal_pathlib-0.3.10-py3-none-any.whl", hash = "sha256:dfaf2fb35683d2eb1287a3ed7b215e4d6016aa6eaf339c607023d22f90821c66", size = 83528, upload-time = "2026-02-22T14:40:57.316Z" }, ] [[package]] @@ -5203,9 +5203,9 @@ dependencies = [ { name = "ripgrep-rs" }, { name = "universal-pathlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d4/36/dec97bc75c092f493492e023260d1994785365fdfe3a6705eb103fbc81ed/upathtools-1.20.4.tar.gz", hash = "sha256:d29173ea690299e4e3955d6900d666ef6226ead4df91849bcbd4c5a05fe8e179", size = 207060 } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/dec97bc75c092f493492e023260d1994785365fdfe3a6705eb103fbc81ed/upathtools-1.20.4.tar.gz", hash = "sha256:d29173ea690299e4e3955d6900d666ef6226ead4df91849bcbd4c5a05fe8e179", size = 207060, upload-time = "2026-04-24T22:03:30.677Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/72/70943b53a6dad3a5a8c3ea4ed7c498344b6f8eafe5a36e7982d686c4bc7e/upathtools-1.20.4-py3-none-any.whl", hash = "sha256:45bf470cf529c2bd4c492427fac24c4a75863a603bbf061c91e1d526371926c5", size = 274153 }, + { url = "https://files.pythonhosted.org/packages/81/72/70943b53a6dad3a5a8c3ea4ed7c498344b6f8eafe5a36e7982d686c4bc7e/upathtools-1.20.4-py3-none-any.whl", hash = "sha256:45bf470cf529c2bd4c492427fac24c4a75863a603bbf061c91e1d526371926c5", size = 274153, upload-time = "2026-04-24T22:03:29.101Z" }, ] [package.optional-dependencies] @@ -5219,9 +5219,9 @@ httpx = [ name = "urllib3" version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602 } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087 }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -5232,9 +5232,9 @@ dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412 } +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219 }, + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, ] [package.optional-dependencies] @@ -5251,14 +5251,14 @@ standard = [ name = "uvloop" version = "0.22.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250 } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611 }, - { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811 }, - { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562 }, - { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890 }, - { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472 }, - { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051 }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, ] [[package]] @@ -5269,30 +5269,30 @@ dependencies = [ { name = "pyyaml" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/d5/8a1f8eb603e2d35fbb0ecd1e309d0c5c18a0ecfc8c0a8f04088bbc8f833b/vcrpy-8.3.0.tar.gz", hash = "sha256:46d64e77e8d95e5c76c7d9a94ff05d8b38b2ae4e1d4869eb0235024b6fcb5212", size = 96117 } +sdist = { url = "https://files.pythonhosted.org/packages/39/d5/8a1f8eb603e2d35fbb0ecd1e309d0c5c18a0ecfc8c0a8f04088bbc8f833b/vcrpy-8.3.0.tar.gz", hash = "sha256:46d64e77e8d95e5c76c7d9a94ff05d8b38b2ae4e1d4869eb0235024b6fcb5212", size = 96117, upload-time = "2026-07-04T14:27:01.608Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/77/cb4219be91508399cbcb6143bad89462cfb16f6c638458f454a5d46ac95a/vcrpy-8.3.0-py3-none-any.whl", hash = "sha256:bd66e6143746778157f00e2a922527a8d96b2fdc350be8988a45a29c843815b9", size = 46530 }, + { url = "https://files.pythonhosted.org/packages/34/77/cb4219be91508399cbcb6143bad89462cfb16f6c638458f454a5d46ac95a/vcrpy-8.3.0-py3-none-any.whl", hash = "sha256:bd66e6143746778157f00e2a922527a8d96b2fdc350be8988a45a29c843815b9", size = 46530, upload-time = "2026-07-04T14:27:00.546Z" }, ] [[package]] name = "watchdog" version = "6.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] [[package]] @@ -5302,80 +5302,80 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730 }, - { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842 }, - { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989 }, - { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978 }, - { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248 }, - { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847 }, - { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974 }, - { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782 }, - { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182 }, - { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841 }, - { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028 }, - { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183 }, - { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059 }, - { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186 }, - { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031 }, - { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205 }, - { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892 }, - { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867 }, - { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217 }, - { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458 }, - { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707 }, - { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663 }, - { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537 }, - { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194 }, - { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194 }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, ] [[package]] name = "wcwidth" version = "0.8.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253 } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166 }, + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ] [[package]] name = "websocket-client" version = "1.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576 } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616 }, + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] [[package]] name = "websockets" version = "15.0.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016 } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440 }, - { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098 }, - { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329 }, - { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111 }, - { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054 }, - { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496 }, - { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829 }, - { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217 }, - { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 }, - { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 }, - { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 }, - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] [[package]] name = "win32-setctime" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 }, + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] [[package]] @@ -5587,7 +5587,7 @@ requires-dist = [ { name = "markitdown", marker = "python_full_version < '3.14' and extra == 'markitdown'" }, { name = "mcp", specifier = ">=1.2.0" }, { name = "mcpx-py", marker = "extra == 'mcp-run'", specifier = ">=0.7.0" }, - { name = "openviking-sdk", marker = "extra == 'viking'", specifier = ">=0.1.6" }, + { name = "openviking-sdk", marker = "extra == 'viking'", specifier = ">=0.1.8" }, { name = "pillow", specifier = ">=11.3.0" }, { name = "platformdirs" }, { name = "promptantic", specifier = ">=0.4.5" }, @@ -5684,19 +5684,19 @@ lint = [ name = "wrapt" version = "1.17.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547 } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003 }, - { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025 }, - { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108 }, - { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072 }, - { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214 }, - { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105 }, - { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766 }, - { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711 }, - { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885 }, - { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896 }, - { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591 }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] [[package]] @@ -5712,9 +5712,9 @@ dependencies = [ { name = "universal-pathlib" }, { name = "upathtools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/77/16d795d8c89ab018f8aeac0b075adb0491dbe2f3e95410f89ac93ff46803/yamling-2.1.7.tar.gz", hash = "sha256:b75e5b37dc3d2a874286604ec7179a3a149541c3ebfeb7f50ee7e66e1bacabf9", size = 24561 } +sdist = { url = "https://files.pythonhosted.org/packages/f0/77/16d795d8c89ab018f8aeac0b075adb0491dbe2f3e95410f89ac93ff46803/yamling-2.1.7.tar.gz", hash = "sha256:b75e5b37dc3d2a874286604ec7179a3a149541c3ebfeb7f50ee7e66e1bacabf9", size = 24561, upload-time = "2025-12-10T10:47:50.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/3f/d981b71fd7332a3f70a17e4111e01f36ec38b7333eda8ae508cc2959b7de/yamling-2.1.7-py3-none-any.whl", hash = "sha256:730c4be2c5efe44660e1b77db82a87c305cdfcc669052742925b026c1078081a", size = 30465 }, + { url = "https://files.pythonhosted.org/packages/2a/3f/d981b71fd7332a3f70a17e4111e01f36ec38b7333eda8ae508cc2959b7de/yamling-2.1.7-py3-none-any.whl", hash = "sha256:730c4be2c5efe44660e1b77db82a87c305cdfcc669052742925b026c1078081a", size = 30465, upload-time = "2025-12-10T10:47:47.611Z" }, ] [[package]] @@ -5726,26 +5726,26 @@ dependencies = [ { name = "multidict" }, { name = "propcache" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075 }, - { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225 }, - { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751 }, - { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960 }, - { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500 }, - { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780 }, - { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308 }, - { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574 }, - { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914 }, - { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712 }, - { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251 }, - { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319 }, - { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163 }, - { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435 }, - { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691 }, - { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454 }, - { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813 }, - { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612 }, +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, ] [[package]] @@ -5762,52 +5762,52 @@ dependencies = [ { name = "pyyaml" }, { name = "tomli" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b8/f7/d07ffb268ca86afb26b7f32dbabe25dec03d3aa63ba4d876720c84681d33/zensical-0.0.51.tar.gz", hash = "sha256:de25de067bedfa18f916d7f366fd64a7fbf09bfcc615b44d1ddbe3b5fe02ab49", size = 3979640 } +sdist = { url = "https://files.pythonhosted.org/packages/b8/f7/d07ffb268ca86afb26b7f32dbabe25dec03d3aa63ba4d876720c84681d33/zensical-0.0.51.tar.gz", hash = "sha256:de25de067bedfa18f916d7f366fd64a7fbf09bfcc615b44d1ddbe3b5fe02ab49", size = 3979640, upload-time = "2026-07-17T18:08:03.445Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/21/02db3e1fb3904016bfac310037c95b9f1eaaf0ffe7b4a84f14263a7d95df/zensical-0.0.51-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:134d776afa526098e05e34713e2f577c075e57a232e01b97842bb0206716afce", size = 12791154 }, - { url = "https://files.pythonhosted.org/packages/a2/35/b0d96f58253514cb3d08f5779020ab01ee5472334fb984b92e3fc9e9c9ac/zensical-0.0.51-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e97ab39668ae3b452c550634e921a0336443743aae5e1fe031c7bb57d049e535", size = 12692190 }, - { url = "https://files.pythonhosted.org/packages/2e/90/7a60e126a10c37c6b789938ff17e73fe76bba707fa029cb40ac659aeaa82/zensical-0.0.51-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c9579809f88608e7aa2cff516fff9d267d74a843cf6088a5f4227de2f092bb5", size = 13139337 }, - { url = "https://files.pythonhosted.org/packages/ae/c3/9101c97b90d4713ef2816db03366a45ae4762efebffd296737a2dd2df325/zensical-0.0.51-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296dc7a14aa28b81a58eb57df2d5c9c9a4b0de7e90c11d99c943354287952925", size = 13069851 }, - { url = "https://files.pythonhosted.org/packages/d2/79/0474df9e15a2c18f6281a786e10177c1b6e16feac1c568e7f36ad39b339c/zensical-0.0.51-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f779d2d87b4bf228cf2e279bc0ae6bcf3b36a9335ff283a317d01f7c15ae46b2", size = 13451083 }, - { url = "https://files.pythonhosted.org/packages/fe/6f/91bbf78f704d5fd4c0c9be27d6bce3b6e4c2c339e4dcd6e7cf19ecda643c/zensical-0.0.51-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f813a1514a90890ca86248a8d54b81b2164bcbff11a6bcf11b01e1c01a1454", size = 13110446 }, - { url = "https://files.pythonhosted.org/packages/d9/89/aa9a95f81771614c37bdc52b8ab21fcdef4c8de7c9cedf34e9bf62674281/zensical-0.0.51-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:186ef37e0eee0e969e2cfae47b1b97775e3164e2cba95c71faa4dd6ef47ed009", size = 13315871 }, - { url = "https://files.pythonhosted.org/packages/08/11/1bf6e9ded29d376f8c12644cc4de04676b010fee8caa17f682606b1f16d5/zensical-0.0.51-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5d91ce246ed930224603083cef02ae8947132fc7c52901d72015ea03526fa58", size = 13344382 }, - { url = "https://files.pythonhosted.org/packages/d6/ec/663f16ff82d08b212e7c3236a88bd332f73331f94ddac1c91aaf882bbd1e/zensical-0.0.51-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b1108eae82c6e8ffc33026f60b485c1512647a5333be4f547166b7c8877b98af", size = 13499628 }, - { url = "https://files.pythonhosted.org/packages/60/b4/7f1b6c3cf06d9f6ff5216523168a5d6ccc693444d5ceeb911eca97b30d98/zensical-0.0.51-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6fa0ecaf14f56841bfc595fa141396350c72aafbec73a016ebe3c824ed21ac72", size = 13451420 }, - { url = "https://files.pythonhosted.org/packages/0a/44/be4bc09ec8f69e7be1b07b875887961c4e0e478b03a10d2cc624ef28fbe6/zensical-0.0.51-cp310-abi3-win32.whl", hash = "sha256:fb7ff4946b72168759c6af0a29cf5de4c38aebe633a83292e8cd4145b5213cc2", size = 12375639 }, - { url = "https://files.pythonhosted.org/packages/1f/85/aa827c244ed4f404e99a91c3ecf5e5adb62eca806a9e9c8e3333bbad8660/zensical-0.0.51-cp310-abi3-win_amd64.whl", hash = "sha256:12529d3d3991b63820952111dc1d1edc29b2c9b3a3abb16c243bcb649631ebf2", size = 12628965 }, + { url = "https://files.pythonhosted.org/packages/48/21/02db3e1fb3904016bfac310037c95b9f1eaaf0ffe7b4a84f14263a7d95df/zensical-0.0.51-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:134d776afa526098e05e34713e2f577c075e57a232e01b97842bb0206716afce", size = 12791154, upload-time = "2026-07-17T18:07:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/35/b0d96f58253514cb3d08f5779020ab01ee5472334fb984b92e3fc9e9c9ac/zensical-0.0.51-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e97ab39668ae3b452c550634e921a0336443743aae5e1fe031c7bb57d049e535", size = 12692190, upload-time = "2026-07-17T18:07:24.553Z" }, + { url = "https://files.pythonhosted.org/packages/2e/90/7a60e126a10c37c6b789938ff17e73fe76bba707fa029cb40ac659aeaa82/zensical-0.0.51-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c9579809f88608e7aa2cff516fff9d267d74a843cf6088a5f4227de2f092bb5", size = 13139337, upload-time = "2026-07-17T18:07:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c3/9101c97b90d4713ef2816db03366a45ae4762efebffd296737a2dd2df325/zensical-0.0.51-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296dc7a14aa28b81a58eb57df2d5c9c9a4b0de7e90c11d99c943354287952925", size = 13069851, upload-time = "2026-07-17T18:07:31.814Z" }, + { url = "https://files.pythonhosted.org/packages/d2/79/0474df9e15a2c18f6281a786e10177c1b6e16feac1c568e7f36ad39b339c/zensical-0.0.51-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f779d2d87b4bf228cf2e279bc0ae6bcf3b36a9335ff283a317d01f7c15ae46b2", size = 13451083, upload-time = "2026-07-17T18:07:35.543Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/91bbf78f704d5fd4c0c9be27d6bce3b6e4c2c339e4dcd6e7cf19ecda643c/zensical-0.0.51-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f813a1514a90890ca86248a8d54b81b2164bcbff11a6bcf11b01e1c01a1454", size = 13110446, upload-time = "2026-07-17T18:07:38.783Z" }, + { url = "https://files.pythonhosted.org/packages/d9/89/aa9a95f81771614c37bdc52b8ab21fcdef4c8de7c9cedf34e9bf62674281/zensical-0.0.51-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:186ef37e0eee0e969e2cfae47b1b97775e3164e2cba95c71faa4dd6ef47ed009", size = 13315871, upload-time = "2026-07-17T18:07:42.43Z" }, + { url = "https://files.pythonhosted.org/packages/08/11/1bf6e9ded29d376f8c12644cc4de04676b010fee8caa17f682606b1f16d5/zensical-0.0.51-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5d91ce246ed930224603083cef02ae8947132fc7c52901d72015ea03526fa58", size = 13344382, upload-time = "2026-07-17T18:07:46.066Z" }, + { url = "https://files.pythonhosted.org/packages/d6/ec/663f16ff82d08b212e7c3236a88bd332f73331f94ddac1c91aaf882bbd1e/zensical-0.0.51-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b1108eae82c6e8ffc33026f60b485c1512647a5333be4f547166b7c8877b98af", size = 13499628, upload-time = "2026-07-17T18:07:49.196Z" }, + { url = "https://files.pythonhosted.org/packages/60/b4/7f1b6c3cf06d9f6ff5216523168a5d6ccc693444d5ceeb911eca97b30d98/zensical-0.0.51-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6fa0ecaf14f56841bfc595fa141396350c72aafbec73a016ebe3c824ed21ac72", size = 13451420, upload-time = "2026-07-17T18:07:52.563Z" }, + { url = "https://files.pythonhosted.org/packages/0a/44/be4bc09ec8f69e7be1b07b875887961c4e0e478b03a10d2cc624ef28fbe6/zensical-0.0.51-cp310-abi3-win32.whl", hash = "sha256:fb7ff4946b72168759c6af0a29cf5de4c38aebe633a83292e8cd4145b5213cc2", size = 12375639, upload-time = "2026-07-17T18:07:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/1f/85/aa827c244ed4f404e99a91c3ecf5e5adb62eca806a9e9c8e3333bbad8660/zensical-0.0.51-cp310-abi3-win_amd64.whl", hash = "sha256:12529d3d3991b63820952111dc1d1edc29b2c9b3a3abb16c243bcb649631ebf2", size = 12628965, upload-time = "2026-07-17T18:07:59.741Z" }, ] [[package]] name = "zipp" version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214 } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238 }, + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ] [[package]] name = "zstandard" version = "0.25.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735 }, - { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440 }, - { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070 }, - { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001 }, - { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120 }, - { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230 }, - { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173 }, - { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736 }, - { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368 }, - { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022 }, - { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889 }, - { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952 }, - { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054 }, - { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113 }, - { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936 }, - { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232 }, - { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671 }, +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, ]