Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/30-unit-tdd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions docs/30-unit-tdd/local-files-extension.md
Original file line number Diff line number Diff line change
@@ -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/图模型,没有新增迁移。
1 change: 1 addition & 0 deletions extensions/local_files/.changes/local-files.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
读取本地文件资料的正文、原件位置及失效状态,不接管目录执行能力。
1 change: 1 addition & 0 deletions extensions/local_files/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
21 changes: 21 additions & 0 deletions extensions/local_files/LICENSE
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions extensions/local_files/__init__.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions extensions/local_files/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" = "."
53 changes: 53 additions & 0 deletions extensions/local_files/resolver.py
Original file line number Diff line number Diff line change
@@ -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)
66 changes: 66 additions & 0 deletions extensions/local_files/schema.py
Original file line number Diff line number Diff line change
@@ -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
66 changes: 66 additions & 0 deletions tests/extensions/local_files/test_reader.py
Original file line number Diff line number Diff line change
@@ -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())
Loading