From d17fd166df0b6eb145c678bb93397a2de8872baf Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Fri, 23 Oct 2026 18:24:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(local-files):=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E7=8B=AC=E7=AB=8B=20Python=20=E6=AD=A3=E6=96=87=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E5=88=86=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 读取本地文件元数据与已收入正文,不依赖原 Peer 在线 - 保留原件失效与上次成功修订语义,仅注册 Resolver --- docs/30-unit-tdd/README.md | 1 + docs/30-unit-tdd/local-files-extension.md | 5 ++ .../local_files/.changes/local-files.added.md | 1 + extensions/local_files/CHANGELOG.md | 1 + extensions/local_files/LICENSE | 21 ++++++ extensions/local_files/__init__.py | 17 +++++ extensions/local_files/pyproject.toml | 29 ++++++++ extensions/local_files/resolver.py | 53 +++++++++++++++ extensions/local_files/schema.py | 66 +++++++++++++++++++ tests/extensions/local_files/test_reader.py | 66 +++++++++++++++++++ 10 files changed, 260 insertions(+) create mode 100644 docs/30-unit-tdd/local-files-extension.md create mode 100644 extensions/local_files/.changes/local-files.added.md create mode 100644 extensions/local_files/CHANGELOG.md create mode 100644 extensions/local_files/LICENSE create mode 100644 extensions/local_files/__init__.py create mode 100644 extensions/local_files/pyproject.toml create mode 100644 extensions/local_files/resolver.py create mode 100644 extensions/local_files/schema.py create mode 100644 tests/extensions/local_files/test_reader.py diff --git a/docs/30-unit-tdd/README.md b/docs/30-unit-tdd/README.md index a688b95..f889824 100644 --- a/docs/30-unit-tdd/README.md +++ b/docs/30-unit-tdd/README.md @@ -13,6 +13,7 @@ This directory owns expensive internal design truth for logical units delivered | [mail-extension.md](mail-extension.md) | Mail identity, MIME materialization, collection, graph, and failure boundaries | | [memos-extension.md](memos-extension.md) | Memos adapter identity, graph grammar, persistence, and failure boundaries | | [capture-extension.md](capture-extension.md) | Capture submission identity, original materials, shared graph, and atomic recovery | +| [local-files-extension.md](local-files-extension.md) | 本地文件资料、原件状态与正文的读取分发 | | [rss-extension.md](rss-extension.md) | RSS adapter identity, collection lifecycle, reconciliation, and materialization | | [twitter-extension.md](twitter-extension.md) | Twitter 共同身份、Bookmark 过渡、事务合并和按需媒体读取 | | [security-model.md](security-model.md) | Core-py realization of the shared security boundary model | diff --git a/docs/30-unit-tdd/local-files-extension.md b/docs/30-unit-tdd/local-files-extension.md new file mode 100644 index 0000000..081071d --- /dev/null +++ b/docs/30-unit-tdd/local-files-extension.md @@ -0,0 +1,5 @@ +# 本地文件读取分发 + +`extensions/local_files` 交付 `inkcre/local-files` 的 Python 读取能力,解释 `extensions.local_files.file.v1` 与 `body` 关系。源合同在 Hub 的 `20-product-tdd/local-files-contract.md`。目录监视、文件提取及共享写入由具备文件权限的执行分发完成;Python 分发不提供收入 API,也不注册目录 Source 占位类。 + +`LocalFileResolver` 读取元数据并校验唯一的 `core.text.v1` 正文,允许首次失败时尚无正文。原件失效或当前提取失败仍返回最后成功正文,同时保留状态和提取范围。位置只作为文字解释,不访问本机路径,不尝试远端取文件,也不物化其他副本。此功能复用已有 Source/Job/图模型,没有新增迁移。 diff --git a/extensions/local_files/.changes/local-files.added.md b/extensions/local_files/.changes/local-files.added.md new file mode 100644 index 0000000..cfc650a --- /dev/null +++ b/extensions/local_files/.changes/local-files.added.md @@ -0,0 +1 @@ +读取本地文件资料的正文、原件位置及失效状态,不接管目录执行能力。 diff --git a/extensions/local_files/CHANGELOG.md b/extensions/local_files/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/extensions/local_files/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/extensions/local_files/LICENSE b/extensions/local_files/LICENSE new file mode 100644 index 0000000..94e962a --- /dev/null +++ b/extensions/local_files/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 InKCre contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/local_files/__init__.py b/extensions/local_files/__init__.py new file mode 100644 index 0000000..35d9126 --- /dev/null +++ b/extensions/local_files/__init__.py @@ -0,0 +1,17 @@ +"""Optional local-file graph interpretation for a Python Peer.""" + +import pydantic + +from app.business.extension import ExtensionBase + + +class LocalFilesConfig(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="forbid") + + +class Extension( + ExtensionBase[LocalFilesConfig], ext_id="local-files", config_cls=LocalFilesConfig +): + @classmethod + def _init_resolvers(cls) -> None: + from . import resolver as resolver_module # noqa: F401 diff --git a/extensions/local_files/pyproject.toml b/extensions/local_files/pyproject.toml new file mode 100644 index 0000000..eb29868 --- /dev/null +++ b/extensions/local_files/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=80,<81"] +build-backend = "setuptools.build_meta" + +[project] +name = "inkcre-ext-local-files" +version = "0.1.0" +description = "Read shared local file bodies and locations in Python peers" +requires-python = ">=3.12,<3.13" +license = "MIT" +license-files = ["LICENSE"] +dependencies = [ + "pydantic>=2.11.7,<3.0.0", +] + +[project.entry-points."inkcre.core.extensions"] +local_files = "extensions.local_files:Extension" + +[tool.inkcre-extension] +name = "inkcre/local-files" +nickname = "本地文件" +host-sdk = "core-py" +host-sdk-version = ">=0.3.3 <0.4.0" + +[tool.setuptools] +packages = ["extensions.local_files"] + +[tool.setuptools.package-dir] +"extensions.local_files" = "." diff --git a/extensions/local_files/resolver.py b/extensions/local_files/resolver.py new file mode 100644 index 0000000..e946734 --- /dev/null +++ b/extensions/local_files/resolver.py @@ -0,0 +1,53 @@ +"""Read persisted text without accessing the original Peer or executing a Source.""" + +from pathlib import PurePosixPath + +from app.business.info_base.resolver import Resolver, TextProjectionContext +from app.business.info_base.resolver.label import format_label +from app.business.info_base.services import BlockService + +from .schema import FILE_RESOLVER, FileMetadata, SolvedFile, file_location + + +class LocalFileResolver(Resolver[SolvedFile, str], rso_type=FILE_RESOLVER): + async def _get_solved_content( + self, *, refresh: bool = False, materialize_missing: bool = True + ) -> SolvedFile: + metadata = FileMetadata.model_validate_json(await self.get_raw_content(refresh=refresh)) + relations = await self.get_relations( + include_in=False, include_out=True, refresh=refresh + ) + bodies = [item for item in relations if item.content == "body"] + if len(bodies) > 1 or (metadata.revision is not None and not bodies): + raise ValueError("文件资料的正文关系无效") + if not bodies: + return SolvedFile(metadata=metadata, body_id=None, text=None) + block = await BlockService.get(bodies[0].to_) + if block is None or block.resolver != "core.text.v1" or block.storage is not None: + raise ValueError("文件正文不存在或类型不符") + return SolvedFile(metadata=metadata, body_id=block.id, text=block.content) + + async def get_text( + self, + *, + context: TextProjectionContext = "default", + refresh: bool = False, + materialize_missing: bool = True, + ) -> str: + solved = await self._get_solved_content(refresh=refresh, materialize_missing=False) + metadata = solved.metadata + parts = [f"原件位置:{metadata.location}"] + if not metadata.available: + parts.append("原件已失效;保留最后收入的正文。") + if metadata.issue: + parts.append(f"当前提取失败:{metadata.issue}。正文对应上次成功修订。") + if metadata.revision and metadata.revision.scope == "main_document": + parts.append("DOCX 主文档文字,不含脚注、批注。") + if solved.text is not None: + parts.append(solved.text) + return "\n\n".join(parts) + + async def get_label(self, *, refresh: bool = False) -> str: + metadata = FileMetadata.model_validate_json(await self.get_raw_content(refresh=refresh)) + _, path = file_location(metadata.location) + return format_label("本地文件", PurePosixPath(path).name) diff --git a/extensions/local_files/schema.py b/extensions/local_files/schema.py new file mode 100644 index 0000000..73f4261 --- /dev/null +++ b/extensions/local_files/schema.py @@ -0,0 +1,66 @@ +"""Local-files v1 graph metadata; original bytes remain on the owning Peer.""" + +from typing import Literal +from urllib.parse import unquote, urlsplit +from uuid import UUID + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator + +FILE_RESOLVER = "extensions.local_files.file.v1" + + +def file_location(value: str) -> tuple[UUID, str]: + location = urlsplit(value) + if ( + location.scheme != "inkcre-peer-file" + or location.username is not None + or location.password is not None + or location.port is not None + or "?" in value + or "#" in value + ): + raise ValueError("Invalid Peer file location") + peer = UUID(location.hostname or "") + path = unquote(location.path, errors="strict") + if not path.startswith("/") or "\0" in path: + raise ValueError("Peer file location requires an absolute path") + return peer, path + + +class MetadataModel(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + +class FileIdentity(MetadataModel): + volume: str + number: int = Field(ge=0) + created: AwareDatetime + + +class FileRevision(MetadataModel): + sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + extractor: str + extracted_at: AwareDatetime + scope: Literal["text", "text_layer", "main_document"] + + +class FileMetadata(MetadataModel): + location: str + format: Literal["pdf", "docx", "markdown", "org", "html", "text"] + identity: FileIdentity | None = None + available: bool + observed_at: AwareDatetime + revision: FileRevision | None = None + issue: str | None = None + + @field_validator("location") + @classmethod + def valid_location(cls, value: str) -> str: + file_location(value) + return value + + +class SolvedFile(MetadataModel): + metadata: FileMetadata + body_id: int | None + text: str | None diff --git a/tests/extensions/local_files/test_reader.py b/tests/extensions/local_files/test_reader.py new file mode 100644 index 0000000..44bc848 --- /dev/null +++ b/tests/extensions/local_files/test_reader.py @@ -0,0 +1,66 @@ +"""Readers consume stored bodies; the source Peer and original file are unnecessary.""" + +import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock +from uuid import UUID + +import pytest + +from extensions.local_files.schema import FileMetadata, file_location +from extensions.local_files.resolver import LocalFileResolver +from app.business.info_base.services import BlockService + + +def test_location_retains_machine_path_without_remote_file_access(): + peer, path = file_location( + "inkcre-peer-file://a143b185-f43d-47d1-8ef7-1d3857284b01/Users/a/%E4%B8%AD%E6%96%87%20%23%3F.md" + ) + assert peer == UUID("a143b185-f43d-47d1-8ef7-1d3857284b01") + assert path == "/Users/a/中文 #?.md" + for value in [ + "file:///etc/passwd", + "inkcre-peer-file://unknown/a", + "inkcre-peer-file://a143b185-f43d-47d1-8ef7-1d3857284b01/a#", + ]: + with pytest.raises(ValueError): + file_location(value) + + +def test_failed_current_file_keeps_successful_body_readable(monkeypatch, async_runner): + metadata = FileMetadata( + location="inkcre-peer-file://a143b185-f43d-47d1-8ef7-1d3857284b01/Users/a/note.docx", + format="docx", + available=False, + observed_at=datetime.datetime.now(datetime.UTC), + revision={ + "sha256": "a" * 64, + "extractor": "mac-native-text.v1", + "extracted_at": datetime.datetime.now(datetime.UTC), + "scope": "main_document", + }, + issue="当前原件损坏", + ) + reader = object.__new__(LocalFileResolver) + monkeypatch.setattr( + reader, "get_raw_content", AsyncMock(return_value=metadata.model_dump_json()) + ) + monkeypatch.setattr( + reader, + "get_relations", + AsyncMock(return_value=(SimpleNamespace(content="body", to_=20),)), + ) + monkeypatch.setattr( + BlockService, + "get", + AsyncMock( + return_value=SimpleNamespace( + id=20, resolver="core.text.v1", content="上次成功正文", storage=None + ) + ), + ) + text = async_runner.run(reader.get_text(materialize_missing=False)) + assert "上次成功正文" in text and "原件已失效" in text and "不含脚注" in text + monkeypatch.setattr(reader, "get_relations", AsyncMock(return_value=())) + with pytest.raises(ValueError, match="正文关系"): + async_runner.run(reader.get_text()) From fca9f8f250446ba324865d2911c738369e3b4713 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Fri, 23 Oct 2026 18:26:26 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(shared):=20=E5=AF=B9=E9=BD=90=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=96=87=E4=BB=B6=E5=80=99=E9=80=89=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 固定已推送的 Hub 合同提交 831e835 - 与分发实现提交分离,合并依赖 InKCre/docs#42 --- docs/_shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_shared b/docs/_shared index 7449135..831e835 160000 --- a/docs/_shared +++ b/docs/_shared @@ -1 +1 @@ -Subproject commit 7449135fefdd6056fecba387e59e3005aed5eafd +Subproject commit 831e8358a87894ecf29f25e18bff2c2ef3ce42bb