clm-kernel 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clm/__init__.py +230 -0
- clm/__main__.py +25 -0
- clm/cross_cutting/__init__.py +50 -0
- clm/cross_cutting/_test_runner.py +36 -0
- clm/cross_cutting/async_utils.py +25 -0
- clm/cross_cutting/cel_builder.py +123 -0
- clm/cross_cutting/cel_converter.py +697 -0
- clm/cross_cutting/cel_engine.py +252 -0
- clm/cross_cutting/cel_ops.py +72 -0
- clm/cross_cutting/cel_ops_db.py +157 -0
- clm/cross_cutting/cel_ops_files.py +99 -0
- clm/cross_cutting/cel_ops_state.py +71 -0
- clm/cross_cutting/cel_ops_strings.py +64 -0
- clm/cross_cutting/cel_resolver.py +63 -0
- clm/cross_cutting/cel_slices.py +56 -0
- clm/cross_cutting/cel_store.py +330 -0
- clm/cross_cutting/clm_logger.py +140 -0
- clm/cross_cutting/config/__init__.py +155 -0
- clm/cross_cutting/config/config_constants.py +392 -0
- clm/cross_cutting/config/env_parameters.py +90 -0
- clm/cross_cutting/config/logging.py +378 -0
- clm/cross_cutting/config/settings.py +504 -0
- clm/cross_cutting/context.py +5 -0
- clm/cross_cutting/domain_models.py +55 -0
- clm/cross_cutting/domain_types.py +61 -0
- clm/cross_cutting/effects.py +67 -0
- clm/cross_cutting/errors.py +153 -0
- clm/cross_cutting/governance.py +331 -0
- clm/cross_cutting/guards.py +41 -0
- clm/cross_cutting/mcard.py +321 -0
- clm/cross_cutting/native.py +185 -0
- clm/cross_cutting/observability.py +289 -0
- clm/cross_cutting/pcard.py +434 -0
- clm/cross_cutting/registry.py +71 -0
- clm/cross_cutting/resources.py +84 -0
- clm/cross_cutting/secrets.py +44 -0
- clm/cross_cutting/subprocess_utils.py +118 -0
- clm/cross_cutting/telemetry.py +164 -0
- clm/cross_cutting/testing/__init__.py +8 -0
- clm/cross_cutting/testing/comparator.py +187 -0
- clm/cross_cutting/testing/datasets.py +87 -0
- clm/cross_cutting/timeutil.py +20 -0
- clm/cross_cutting/types/new_type_check.py +0 -0
- clm/cross_cutting/utils/__init__.py +1 -0
- clm/cross_cutting/utils/url_safety.py +50 -0
- clm/cross_cutting/vcard.py +560 -0
- clm/cross_cutting/writer.py +118 -0
- clm/layer0/README.md +50 -0
- clm/layer0/__init__.py +223 -0
- clm/layer0/algebra.py +60 -0
- clm/layer0/algebra_baldwin.py +133 -0
- clm/layer0/algebra_baldwin_generators.py +36 -0
- clm/layer0/algebra_equivalence.py +262 -0
- clm/layer0/algebra_ingest.py +306 -0
- clm/layer0/algebra_manifest_io.py +156 -0
- clm/layer0/assembler.py +481 -0
- clm/layer0/cel/__init__.py +23 -0
- clm/layer0/cel_eval.py +491 -0
- clm/layer0/codec.py +106 -0
- clm/layer0/context.py +122 -0
- clm/layer0/db.py +160 -0
- clm/layer0/exceptions.py +73 -0
- clm/layer0/fibration.py +106 -0
- clm/layer0/gates.py +50 -0
- clm/layer0/hash.py +80 -0
- clm/layer0/loader.py +826 -0
- clm/layer0/math_utils.py +163 -0
- clm/layer0/mcard.py +96 -0
- clm/layer0/model/__init__.py +0 -0
- clm/layer0/model/action.py +223 -0
- clm/layer0/model/card.py +280 -0
- clm/layer0/model/card_triad.py +427 -0
- clm/layer0/model/compatibility.py +60 -0
- clm/layer0/model/dictionary.py +46 -0
- clm/layer0/model/dots.py +331 -0
- clm/layer0/model/event_producer.py +88 -0
- clm/layer0/model/g_time.py +89 -0
- clm/layer0/model/handle.py +163 -0
- clm/layer0/model/hash/__init__.py +6 -0
- clm/layer0/model/hash/algorithms/__init__.py +3 -0
- clm/layer0/model/hash/algorithms/custom_hash.py +7 -0
- clm/layer0/model/hash/algorithms/local_sha256.py +178 -0
- clm/layer0/model/hash/constants.py +53 -0
- clm/layer0/model/hash/enums.py +60 -0
- clm/layer0/model/hash/validator.py +307 -0
- clm/layer0/model/interpreter.py +275 -0
- clm/layer0/model/pagination.py +45 -0
- clm/layer0/model/pcard.py +716 -0
- clm/layer0/model/schema.py +44 -0
- clm/layer0/model/utils/__init__.py +1 -0
- clm/layer0/model/utils/content_analyzer.py +152 -0
- clm/layer0/model/validators/__init__.py +1 -0
- clm/layer0/model/validators/base_validator.py +44 -0
- clm/layer0/model/validators/binary_validator.py +66 -0
- clm/layer0/model/validators/text_validator.py +82 -0
- clm/layer0/model/validators/validation_registry.py +49 -0
- clm/layer0/model/vcard.py +823 -0
- clm/layer0/model/vcard_ext/__init__.py +39 -0
- clm/layer0/model/vcard_ext/core.py +60 -0
- clm/layer0/model/vcard_ext/network.py +23 -0
- clm/layer0/model/vcard_ext/observability.py +63 -0
- clm/layer0/model/vcard_ext/storage.py +60 -0
- clm/layer0/model/vcard_ext/vendors.py +211 -0
- clm/layer0/model/vcard_sandwich.py +119 -0
- clm/layer0/model/vcard_vocabulary.py +463 -0
- clm/layer0/model/workflow.py +274 -0
- clm/layer0/narrative.py +431 -0
- clm/layer0/ontology/collection.py +125 -0
- clm/layer0/parser.py +429 -0
- clm/layer0/relations.py +73 -0
- clm/layer0/rule_evaluator.py +251 -0
- clm/layer0/schema/__init__.py +38 -0
- clm/layer0/schema/ddl.py +97 -0
- clm/layer0/templates/abstract.yaml +99 -0
- clm/layer0/templates/balanced.yaml +253 -0
- clm/layer0/templates/concrete.yaml +172 -0
- clm/layer0/type_registry.py +223 -0
- clm/layer0/types.py +204 -0
- clm/layer0/utils.py +886 -0
- clm/layer0/verifier_core.py +262 -0
- clm/layer1/__init__.py +31 -0
- clm/layer1/action_dispatcher.py +275 -0
- clm/layer1/adapter_registry.py +233 -0
- clm/layer1/algebra_baldwin_generators.py +192 -0
- clm/layer1/arrow_compiler.py +525 -0
- clm/layer1/batch_processor.py +152 -0
- clm/layer1/cel/__init__.py +27 -0
- clm/layer1/cel/builder.py +173 -0
- clm/layer1/engine.py +759 -0
- clm/layer1/engine_async.py +89 -0
- clm/layer1/eval_callback.py +49 -0
- clm/layer1/gate_composer.py +229 -0
- clm/layer1/hoare_sandwich.py +298 -0
- clm/layer1/identity_guard.py +348 -0
- clm/layer1/matrix.py +138 -0
- clm/layer1/neural_runtime.py +221 -0
- clm/layer1/operations/__init__.py +123 -0
- clm/layer1/operations/augment.py +156 -0
- clm/layer1/operations/base.py +109 -0
- clm/layer1/operations/builtins.py +338 -0
- clm/layer1/operations/dispatch.py +144 -0
- clm/layer1/operations/exclude.py +107 -0
- clm/layer1/operations/handle.py +197 -0
- clm/layer1/operations/identity.py +139 -0
- clm/layer1/operations/invert.py +66 -0
- clm/layer1/operations/loader.py +116 -0
- clm/layer1/operations/manipulators/__init__.py +23 -0
- clm/layer1/operations/manipulators/document.py +211 -0
- clm/layer1/operations/manipulators/media.py +134 -0
- clm/layer1/operations/manipulators/structured.py +159 -0
- clm/layer1/operations/manipulators/tabular.py +334 -0
- clm/layer1/operations/port.py +65 -0
- clm/layer1/operations/services.py +529 -0
- clm/layer1/operations/split.py +117 -0
- clm/layer1/operations/substitute.py +75 -0
- clm/layer1/petri.py +1052 -0
- clm/layer1/pocketflow_orchestrator.py +408 -0
- clm/layer1/pocketflow_scheduler.py +519 -0
- clm/layer1/protocols.py +197 -0
- clm/layer1/runtime_adapter.py +718 -0
- clm/layer1/runtimes/__init__.py +71 -0
- clm/layer1/runtimes/_generated_assertions.py +125 -0
- clm/layer1/runtimes/_generated_types.py +42 -0
- clm/layer1/runtimes/base.py +265 -0
- clm/layer1/runtimes/binary.py +101 -0
- clm/layer1/runtimes/factory.py +203 -0
- clm/layer1/runtimes/javascript.py +321 -0
- clm/layer1/runtimes/javascript_runtime.js +33 -0
- clm/layer1/runtimes/js_scripts/isomorphism.mjs +22 -0
- clm/layer1/runtimes/js_scripts/loader.mjs +52 -0
- clm/layer1/runtimes/lambda_calc.py +126 -0
- clm/layer1/runtimes/python.py +288 -0
- clm/layer1/runtimes/python_runtime.py +41 -0
- clm/layer1/runtimes/script.py +114 -0
- clm/layer1/sandbox.py +334 -0
- clm/layer1/savepoint_guard.py +136 -0
- clm/layer1/sparse_net.py +122 -0
- clm/layer1/spec_tester.py +165 -0
- clm/layer1/store.py +295 -0
- clm/layer1/test_runner.py +498 -0
- clm/layer1/type_inspector.py +166 -0
- clm/layer1/verifier.py +772 -0
- clm/layer1/vm.py +91 -0
- clm/layer2/__init__.py +78 -0
- clm/layer2/card_collection.py +678 -0
- clm/layer2/classifier.py +479 -0
- clm/layer2/engine/__init__.py +1 -0
- clm/layer2/engine/abstract_sql_engine.py +320 -0
- clm/layer2/engine/base.py +193 -0
- clm/layer2/engine/duckdb_engine.py +576 -0
- clm/layer2/engine/sqlite_engine.py +518 -0
- clm/layer2/fiber/__init__.py +31 -0
- clm/layer2/fiber/context.py +89 -0
- clm/layer2/fiber/disposable.py +163 -0
- clm/layer2/fiber/fiber.py +125 -0
- clm/layer2/fiber/hoare.py +191 -0
- clm/layer2/file_io.py +376 -0
- clm/layer2/file_registrar.py +77 -0
- clm/layer2/ingest.py +202 -0
- clm/layer2/lineage.py +322 -0
- clm/layer2/mcard_fs.py +262 -0
- clm/layer2/merkle.py +11 -0
- clm/layer2/mime.py +244 -0
- clm/layer2/mime_detector.py +245 -0
- clm/layer2/private_collection.py +118 -0
- clm/layer2/shared_store.py +11 -0
- clm/layer2/storage/__init__.py +190 -0
- clm/layer2/storage/db_bitemporal.py +141 -0
- clm/layer2/storage/db_driver.py +331 -0
- clm/layer2/storage/db_hyperlinks.py +214 -0
- clm/layer2/storage/db_pool.py +139 -0
- clm/layer2/storage/db_repository.py +229 -0
- clm/layer2/storage/savepoint.py +78 -0
- clm/layer2/storage/sqlite_wal.py +74 -0
- clm/layer2/tridb.py +245 -0
- clm/layer2/vcard_receipt.py +71 -0
- clm/layer2/vfs.py +17 -0
- clm/layer3/__init__.py +141 -0
- clm/layer3/agency/__init__.py +19 -0
- clm/layer3/agency/config.py +220 -0
- clm/layer3/agency/providers/__init__.py +15 -0
- clm/layer3/agency/providers/base.py +91 -0
- clm/layer3/agency/providers/mlc_llm.py +169 -0
- clm/layer3/agency/providers/ollama.py +203 -0
- clm/layer3/agency/router.py +71 -0
- clm/layer3/agency/runtime.py +524 -0
- clm/layer3/gateway.py +353 -0
- clm/layer3/open_interpreter_adapter.py +159 -0
- clm/layer3/rag/__init__.py +70 -0
- clm/layer3/rag/cli.py +228 -0
- clm/layer3/rag/config.py +153 -0
- clm/layer3/rag/embeddings/__init__.py +16 -0
- clm/layer3/rag/embeddings/base.py +79 -0
- clm/layer3/rag/embeddings/ollama.py +211 -0
- clm/layer3/rag/embeddings/vision.py +290 -0
- clm/layer3/rag/engine.py +276 -0
- clm/layer3/rag/graph/__init__.py +22 -0
- clm/layer3/rag/graph/community.py +198 -0
- clm/layer3/rag/graph/engine.py +433 -0
- clm/layer3/rag/graph/extractor.py +255 -0
- clm/layer3/rag/graph/schema.py +115 -0
- clm/layer3/rag/graph/store.py +641 -0
- clm/layer3/rag/indexer.py +333 -0
- clm/layer3/rag/llm_providers.json +52 -0
- clm/layer3/rag/semantic_versioning.py +313 -0
- clm/layer3/rag/vector/__init__.py +28 -0
- clm/layer3/rag/vector/handle_vector_store.py +749 -0
- clm/layer3/rag/vector/schema.py +151 -0
- clm/layer3/rag/vector/store.py +632 -0
- clm/layer3/reticulum/__init__.py +55 -0
- clm/layer3/reticulum/bridge.py +74 -0
- clm/layer3/reticulum/daemon.py +88 -0
- clm/layer3/reticulum/discovery.py +164 -0
- clm/layer3/reticulum/identity.py +107 -0
- clm/layer3/reticulum/media.py +148 -0
- clm/layer3/reticulum/transport.py +133 -0
- clm/layer3/satori/__init__.py +209 -0
- clm/layer3/satori/alpha_conversion.py +168 -0
- clm/layer3/satori/beta_reduction.py +359 -0
- clm/layer3/satori/continuation.py +38 -0
- clm/layer3/satori/eta_conversion.py +205 -0
- clm/layer3/satori/free_variables.py +163 -0
- clm/layer3/satori/io_effects.py +370 -0
- clm/layer3/satori/lambda_runtime.py +726 -0
- clm/layer3/satori/lambda_term.py +230 -0
- clm/layer3/satori/sheaf_audit.py +261 -0
- clm/layer3/satori/speech_act.py +279 -0
- clm/layer4/README.md +9 -0
- clm/layer4/__init__.py +67 -0
- clm/layer4/_sovereign_executor.py +580 -0
- clm/layer4/action_commands.py +719 -0
- clm/layer4/api.py +236 -0
- clm/layer4/bootstrap/__init__.py +13 -0
- clm/layer4/bootstrap/bootstrapper.py +118 -0
- clm/layer4/bootstrap/cleanup.py +44 -0
- clm/layer4/bootstrap/clm_hooks.py +49 -0
- clm/layer4/bootstrap/genesis.py +77 -0
- clm/layer4/bootstrap/injection.py +125 -0
- clm/layer4/cli.py +549 -0
- clm/layer4/collection_manager.py +553 -0
- clm/layer4/commands/__init__.py +81 -0
- clm/layer4/commands/action_cmd.py +42 -0
- clm/layer4/commands/check_cmd.py +82 -0
- clm/layer4/commands/envelope.py +73 -0
- clm/layer4/commands/evaluate_cmd.py +224 -0
- clm/layer4/commands/mcard_cmd.py +24 -0
- clm/layer4/commands/petri_cmd.py +92 -0
- clm/layer4/commands/tridb_cmd.py +319 -0
- clm/layer4/commands/version_cmd.py +27 -0
- clm/layer4/diff_commands.py +167 -0
- clm/layer4/eoa_sponsor.py +232 -0
- clm/layer4/events.py +69 -0
- clm/layer4/flux_gateway.py +133 -0
- clm/layer4/gateway.py +91 -0
- clm/layer4/loader.py +307 -0
- clm/layer4/mcard_commands.py +166 -0
- clm/layer4/membrane.py +273 -0
- clm/layer4/navigation.py +50 -0
- clm/layer4/protocol/protocol_entry_python.py +108 -0
- clm/layer4/protocol/python_sync.py +126 -0
- clm/layer4/runner.py +150 -0
- clm/layer4/satori.py +262 -0
- clm/layer4/server/python_sync_client.py +150 -0
- clm/layer4/server/python_sync_server.py +139 -0
- clm/layer4/server/service_api.py +315 -0
- clm/layer4/server/start_servers.py +107 -0
- clm/layer4/server/stop_servers.py +24 -0
- clm/layer4/simulated_optimization_fix.py +0 -0
- clm/layer4/storage.py +463 -0
- clm/layer4/tridb_admin.py +268 -0
- clm/layer4/websocket/test_ws_client.py +104 -0
- clm/layer4/websocket/test_ws_server.py +138 -0
- clm/layer4/websocket/ws_server_local.py +160 -0
- clm/layer5/__init__.py +51 -0
- clm/layer5/async_batch_writer.py +133 -0
- clm/layer5/learning.py +276 -0
- clm/layer5/telemetry_hook.py +107 -0
- clm/layer5/type_lattice.py +238 -0
- clm/py.typed +0 -0
- clm/schemas/__init__.py +652 -0
- clm/schemas/abstract_face_schema.yaml +77 -0
- clm/schemas/cel_schema.yaml +416 -0
- clm/schemas/lifecycle/__init__.py +45 -0
- clm/schemas/lifecycle/models.py +123 -0
- clm/schemas/lifecycle/service.py +364 -0
- clm/schemas/mcard_schema.sql +192 -0
- clm/schemas/mcard_vector_schema.sql +283 -0
- clm/schemas/mlp-context-v1.json +91 -0
- clm_kernel-0.0.0.dist-info/METADATA +121 -0
- clm_kernel-0.0.0.dist-info/RECORD +333 -0
- clm_kernel-0.0.0.dist-info/WHEEL +4 -0
- clm_kernel-0.0.0.dist-info/entry_points.txt +3 -0
- clm_kernel-0.0.0.dist-info/licenses/LICENSE +21 -0
clm/layer2/file_io.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File I/O operations for MCard.
|
|
3
|
+
|
|
4
|
+
This module handles safe file reading, streaming, and directory traversal.
|
|
5
|
+
It is designed to be robust against large files, binary content, and pathological text.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import logging
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from clm.cross_cutting.config.settings import settings
|
|
16
|
+
from clm.layer0.model.interpreter import ContentTypeInterpreter
|
|
17
|
+
from clm.layer0.model.utils.content_analyzer import (
|
|
18
|
+
_PATHOLOGICAL_NO_BREAK_MIN as READ_CHUNK_SIZE,
|
|
19
|
+
)
|
|
20
|
+
from clm.layer0.model.utils.content_analyzer import (
|
|
21
|
+
_PROBLEMATIC_MAX_KNOWN_TYPE as KNOWN_TYPE_SIZE_LIMIT,
|
|
22
|
+
)
|
|
23
|
+
from clm.layer0.model.utils.content_analyzer import (
|
|
24
|
+
_PROBLEMATIC_MIN_MULTI_POSITION as MULTI_POSITION_THRESHOLD,
|
|
25
|
+
)
|
|
26
|
+
from clm.layer0.model.utils.content_analyzer import (
|
|
27
|
+
_PROBLEMATIC_SAMPLE_SIZE as BINARY_CHECK_SAMPLE_SIZE,
|
|
28
|
+
)
|
|
29
|
+
from clm.layer0.model.utils.content_analyzer import ContentAnalyzer
|
|
30
|
+
|
|
31
|
+
# File I/O specific constants
|
|
32
|
+
MAX_FILE_SIZE = settings.file_processing.max_file_size # Configurable via environment
|
|
33
|
+
LARGE_FILE_THRESHOLD = 50 * 1024 # 50KB — above this, read in chunks
|
|
34
|
+
CONTENT_DETECTION_SAMPLE_SIZE = 1024 * 1024 # 1MB cap for content-type detection
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def soft_wrap_long_lines(text: str, max_line_length: int = 1000) -> str:
|
|
40
|
+
"""Insert newlines into very long single lines to prevent pathological processing.
|
|
41
|
+
This is a safe text normalization step for minified or long-line content.
|
|
42
|
+
"""
|
|
43
|
+
if max_line_length <= 0:
|
|
44
|
+
return text
|
|
45
|
+
out_lines: list[str] = []
|
|
46
|
+
for line in text.splitlines() or [text]:
|
|
47
|
+
if len(line) <= max_line_length:
|
|
48
|
+
out_lines.append(line)
|
|
49
|
+
continue
|
|
50
|
+
# Chunk the long line
|
|
51
|
+
for i in range(0, len(line), max_line_length):
|
|
52
|
+
out_lines.append(line[i : i + max_line_length])
|
|
53
|
+
# Preserve trailing newline if present in input
|
|
54
|
+
result = "\n".join(out_lines)
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def stream_read_normalized_text(
|
|
59
|
+
file_path: Path, *, byte_cap: int, wrap_width: int
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""Stream-read bytes up to byte_cap, decode with replacement, and insert soft wraps on the fly.
|
|
62
|
+
Returns dict with keys: text, original_size, original_sha256_prefix.
|
|
63
|
+
"""
|
|
64
|
+
sha = hashlib.sha256()
|
|
65
|
+
total_size = 0
|
|
66
|
+
produced_chars: list[str] = []
|
|
67
|
+
current_len = 0
|
|
68
|
+
with open(file_path, "rb") as f:
|
|
69
|
+
remaining = byte_cap
|
|
70
|
+
while remaining > 0:
|
|
71
|
+
chunk = f.read(min(READ_CHUNK_SIZE, remaining))
|
|
72
|
+
if not chunk:
|
|
73
|
+
break
|
|
74
|
+
sha.update(chunk)
|
|
75
|
+
total_size += len(chunk)
|
|
76
|
+
remaining -= len(chunk)
|
|
77
|
+
try:
|
|
78
|
+
s = chunk.decode("utf-8", errors="replace")
|
|
79
|
+
except UnicodeDecodeError:
|
|
80
|
+
s = chunk.decode("latin-1", errors="replace")
|
|
81
|
+
for ch in s:
|
|
82
|
+
if ch == "\r":
|
|
83
|
+
# Normalize CR to nothing; let CRLF become just LF via the subsequent '\n'
|
|
84
|
+
continue
|
|
85
|
+
produced_chars.append(ch)
|
|
86
|
+
if ch == "\n":
|
|
87
|
+
current_len = 0
|
|
88
|
+
else:
|
|
89
|
+
current_len += 1
|
|
90
|
+
if wrap_width > 0 and current_len >= wrap_width:
|
|
91
|
+
produced_chars.append("\n")
|
|
92
|
+
current_len = 0
|
|
93
|
+
return {
|
|
94
|
+
"text": "".join(produced_chars),
|
|
95
|
+
"original_size": total_size,
|
|
96
|
+
"original_sha256_prefix": sha.hexdigest()[:16],
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_file_safely(
|
|
101
|
+
file_path: str | Path,
|
|
102
|
+
allow_pathological: bool = False,
|
|
103
|
+
max_bytes: int | None = None,
|
|
104
|
+
) -> bytes:
|
|
105
|
+
"""
|
|
106
|
+
Read file content with timeout protection and size limits.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
file_path: Path to the file to read
|
|
110
|
+
allow_pathological: If True, bypass long-line/pathological content checks
|
|
111
|
+
max_bytes: If set, cap the number of bytes read to this value
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
File content as bytes
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
TimeoutError: If reading takes too long
|
|
118
|
+
IOError: If file cannot be read
|
|
119
|
+
"""
|
|
120
|
+
file_path = Path(file_path)
|
|
121
|
+
|
|
122
|
+
# Check file size first
|
|
123
|
+
try:
|
|
124
|
+
file_size = file_path.stat().st_size
|
|
125
|
+
except OSError as e:
|
|
126
|
+
raise OSError(f"Cannot access file {file_path}: {e}") from e
|
|
127
|
+
|
|
128
|
+
if file_size > MAX_FILE_SIZE:
|
|
129
|
+
raise OSError(f"File too large: {file_size} bytes (max {MAX_FILE_SIZE})")
|
|
130
|
+
|
|
131
|
+
# Timeout configurable with a small bias for larger files
|
|
132
|
+
base_timeout = settings.file_processing.read_timeout_secs
|
|
133
|
+
timeout_seconds = (
|
|
134
|
+
5.0 if file_size > LARGE_FILE_THRESHOLD and base_timeout >= 5 else base_timeout
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
result = [None]
|
|
138
|
+
exception = [None]
|
|
139
|
+
|
|
140
|
+
def read_with_timeout():
|
|
141
|
+
try:
|
|
142
|
+
with open(file_path, "rb") as file:
|
|
143
|
+
# For potentially problematic files, read in chunks with progress checking
|
|
144
|
+
if (
|
|
145
|
+
file_size > LARGE_FILE_THRESHOLD or max_bytes is not None
|
|
146
|
+
): # Files > 50KB or when capping
|
|
147
|
+
content = bytearray()
|
|
148
|
+
chunk_size = READ_CHUNK_SIZE
|
|
149
|
+
bytes_read = 0
|
|
150
|
+
start_time = time.time()
|
|
151
|
+
|
|
152
|
+
to_read = max_bytes if max_bytes is not None else file_size
|
|
153
|
+
|
|
154
|
+
while bytes_read < to_read:
|
|
155
|
+
if time.time() - start_time > timeout_seconds:
|
|
156
|
+
raise TimeoutError(
|
|
157
|
+
f"Reading timeout exceeded for {file_path}"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
remaining = to_read - bytes_read
|
|
161
|
+
chunk = file.read(min(chunk_size, remaining))
|
|
162
|
+
if not chunk:
|
|
163
|
+
break
|
|
164
|
+
content.extend(chunk)
|
|
165
|
+
bytes_read += len(chunk)
|
|
166
|
+
|
|
167
|
+
# Check for pathological content patterns during reading unless allowed
|
|
168
|
+
if (
|
|
169
|
+
(not allow_pathological)
|
|
170
|
+
and len(content) > 32768
|
|
171
|
+
and b"\n" not in content
|
|
172
|
+
and b"\r" not in content
|
|
173
|
+
):
|
|
174
|
+
raise OSError(
|
|
175
|
+
f"File appears to be a single massive line: {file_path}"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
result[0] = bytes(content)
|
|
179
|
+
else:
|
|
180
|
+
result[0] = file.read()
|
|
181
|
+
except Exception as e:
|
|
182
|
+
exception[0] = e
|
|
183
|
+
|
|
184
|
+
# Use threading for timeout since signal doesn't work well on all platforms
|
|
185
|
+
thread = threading.Thread(target=read_with_timeout)
|
|
186
|
+
thread.daemon = True
|
|
187
|
+
thread.start()
|
|
188
|
+
thread.join(timeout=timeout_seconds)
|
|
189
|
+
|
|
190
|
+
if thread.is_alive():
|
|
191
|
+
# Thread is still running, reading is taking too long
|
|
192
|
+
logger.warning(f"File reading timed out after {timeout_seconds}s: {file_path}")
|
|
193
|
+
raise TimeoutError(f"File reading timed out for {file_path}")
|
|
194
|
+
|
|
195
|
+
if exception[0]:
|
|
196
|
+
logger.error(f"Error reading file {file_path}: {exception[0]}")
|
|
197
|
+
raise exception[0]
|
|
198
|
+
|
|
199
|
+
if result[0] is None:
|
|
200
|
+
raise OSError(f"Failed to read file: {file_path}")
|
|
201
|
+
|
|
202
|
+
return result[0]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def is_problematic_file(file_path: Path) -> bool:
|
|
206
|
+
"""
|
|
207
|
+
Check if a file is likely to cause processing issues.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
file_size = file_path.stat().st_size
|
|
211
|
+
|
|
212
|
+
# Skip empty files
|
|
213
|
+
if file_size == 0:
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
# If it's a known type that can have long lines, be more permissive
|
|
217
|
+
file_extension = file_path.suffix.lower()
|
|
218
|
+
is_known_type = ContentAnalyzer.is_known_long_line_extension(file_extension)
|
|
219
|
+
|
|
220
|
+
# For known types, still have size limits to prevent pathological cases
|
|
221
|
+
if (
|
|
222
|
+
is_known_type and file_size > KNOWN_TYPE_SIZE_LIMIT
|
|
223
|
+
): # 1MB limit for known types
|
|
224
|
+
logger.warning(
|
|
225
|
+
f"Skipping large file of known type: {file_path} ({file_size} bytes)"
|
|
226
|
+
)
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
with open(file_path, "rb") as f:
|
|
230
|
+
# Sample from beginning, middle, and end for large files
|
|
231
|
+
sample_size = min(BINARY_CHECK_SAMPLE_SIZE, file_size) # 32KB sample
|
|
232
|
+
samples = []
|
|
233
|
+
|
|
234
|
+
if (
|
|
235
|
+
file_size > MULTI_POSITION_THRESHOLD
|
|
236
|
+
): # For files > 100KB, check multiple positions
|
|
237
|
+
positions = [0, file_size // 2, max(0, file_size - sample_size)]
|
|
238
|
+
else:
|
|
239
|
+
positions = [0]
|
|
240
|
+
|
|
241
|
+
for pos in positions:
|
|
242
|
+
f.seek(pos)
|
|
243
|
+
sample = f.read(sample_size)
|
|
244
|
+
if sample:
|
|
245
|
+
samples.append(sample)
|
|
246
|
+
|
|
247
|
+
for sample in samples:
|
|
248
|
+
# Check for binary content that's not structured
|
|
249
|
+
if ContentAnalyzer.is_unstructured_binary(sample):
|
|
250
|
+
logger.warning(f"Skipping unstructured binary file: {file_path}")
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
# Check for pathological line patterns
|
|
254
|
+
if ContentAnalyzer.has_pathological_lines(sample, is_known_type):
|
|
255
|
+
logger.warning(
|
|
256
|
+
f"Skipping file with pathological line structure: {file_path}"
|
|
257
|
+
)
|
|
258
|
+
return True
|
|
259
|
+
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
except OSError as e:
|
|
263
|
+
logger.warning(f"Error checking file {file_path}: {e}")
|
|
264
|
+
return True # Skip files we can't check
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def list_files(directory: str | Path, recursive: bool = False) -> list[Path]:
|
|
268
|
+
"""
|
|
269
|
+
Load all files from the specified directory.
|
|
270
|
+
Explicitly skips hidden files and directories (starting with .).
|
|
271
|
+
"""
|
|
272
|
+
import os
|
|
273
|
+
|
|
274
|
+
dir_path = Path(directory) if isinstance(directory, str) else directory
|
|
275
|
+
if not dir_path.exists():
|
|
276
|
+
raise FileNotFoundError(f"Directory '{dir_path}' does not exist.")
|
|
277
|
+
|
|
278
|
+
safe_files = []
|
|
279
|
+
|
|
280
|
+
if recursive:
|
|
281
|
+
for root, dirs, filenames in os.walk(str(dir_path)):
|
|
282
|
+
# Modify dirs in-place to skip hidden directories and common massive dependencies
|
|
283
|
+
dirs[:] = [
|
|
284
|
+
d
|
|
285
|
+
for d in dirs
|
|
286
|
+
if not d.startswith(".")
|
|
287
|
+
and d
|
|
288
|
+
not in {
|
|
289
|
+
"node_modules",
|
|
290
|
+
"__pycache__",
|
|
291
|
+
"venv",
|
|
292
|
+
"env",
|
|
293
|
+
"build",
|
|
294
|
+
"dist",
|
|
295
|
+
"target",
|
|
296
|
+
"mcard-studio-worktree",
|
|
297
|
+
}
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
for filename in filenames:
|
|
301
|
+
if filename.startswith("."):
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
file_path = Path(root) / filename
|
|
305
|
+
if not is_problematic_file(file_path):
|
|
306
|
+
safe_files.append(file_path)
|
|
307
|
+
else:
|
|
308
|
+
# Non-recursive: just list top level
|
|
309
|
+
# os.walk matches this if we break, but simpler usually:
|
|
310
|
+
for item in dir_path.iterdir():
|
|
311
|
+
if item.name.startswith("."):
|
|
312
|
+
continue
|
|
313
|
+
if item.is_file():
|
|
314
|
+
if not is_problematic_file(item):
|
|
315
|
+
safe_files.append(item)
|
|
316
|
+
|
|
317
|
+
logger.info(f"Found {len(safe_files)} safe files in {dir_path}")
|
|
318
|
+
return safe_files
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def process_file_content(
|
|
322
|
+
file_path: str | Path,
|
|
323
|
+
*,
|
|
324
|
+
force_binary: bool = False,
|
|
325
|
+
allow_pathological: bool = False,
|
|
326
|
+
max_bytes: int | None = None,
|
|
327
|
+
) -> dict[str, Any]:
|
|
328
|
+
"""
|
|
329
|
+
Process a file and return its metadata and content.
|
|
330
|
+
"""
|
|
331
|
+
# Read file content as bytes
|
|
332
|
+
content = read_file_safely(
|
|
333
|
+
file_path, allow_pathological=allow_pathological, max_bytes=max_bytes
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Analyze the content to get MIME type and other metadata
|
|
337
|
+
# For very large files or files with extremely long lines, use a sample
|
|
338
|
+
content_sample = content
|
|
339
|
+
if (
|
|
340
|
+
len(content) > CONTENT_DETECTION_SAMPLE_SIZE
|
|
341
|
+
): # 1MB limit for content type detection
|
|
342
|
+
content_sample = content[:CONTENT_DETECTION_SAMPLE_SIZE]
|
|
343
|
+
logger.info(f"Using content sample for large file: {file_path}")
|
|
344
|
+
|
|
345
|
+
mime_type, extension = ContentTypeInterpreter.detect_content_type(content_sample)
|
|
346
|
+
is_binary = ContentTypeInterpreter.is_binary_content(content_sample)
|
|
347
|
+
|
|
348
|
+
# Force binary mode if requested
|
|
349
|
+
if force_binary:
|
|
350
|
+
is_binary = True
|
|
351
|
+
# If detection thought it's text, override to a safe default
|
|
352
|
+
if not mime_type or mime_type.startswith("text/"):
|
|
353
|
+
mime_type = "application/octet-stream"
|
|
354
|
+
|
|
355
|
+
# For text files, try to decode the content
|
|
356
|
+
if not is_binary and mime_type.startswith("text/"):
|
|
357
|
+
try:
|
|
358
|
+
# Decode the content as UTF-8 for text files
|
|
359
|
+
content = content.decode("utf-8")
|
|
360
|
+
except UnicodeDecodeError:
|
|
361
|
+
# If UTF-8 fails, try with error replacement
|
|
362
|
+
try:
|
|
363
|
+
content = content.decode("utf-8", errors="replace")
|
|
364
|
+
except UnicodeDecodeError as e:
|
|
365
|
+
logger.warning(
|
|
366
|
+
f"Failed to decode content for {file_path} as UTF-8: {e}"
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
"content": content,
|
|
371
|
+
"filename": Path(file_path).name,
|
|
372
|
+
"mime_type": mime_type,
|
|
373
|
+
"extension": extension,
|
|
374
|
+
"is_binary": is_binary,
|
|
375
|
+
"size": len(content), # Use original content size, not sample
|
|
376
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""CLM File Registrar — File-to-MCard registration in the Tri-Database.
|
|
2
|
+
|
|
3
|
+
Registers CLM manifest files as MCards, including AST normalization,
|
|
4
|
+
classification, and dialect metadata handles.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from clm.cross_cutting.clm_logger import get_logger
|
|
11
|
+
from clm.layer0.utils import clm_hash_string, resolve_clm_path
|
|
12
|
+
from clm.layer1.verifier import get_load_manifest
|
|
13
|
+
from clm.layer2.storage import CLMDB, CLMRepository
|
|
14
|
+
|
|
15
|
+
from .classifier import classify_clm, detect_dialect
|
|
16
|
+
|
|
17
|
+
_logger = get_logger("clm.layer2.file_registrar")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register_file_mcard_py(clm_root: str, clm_file: str) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Registers a CLM manifest file as an MCard in the Tri-Database.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
clm_root (str): The root directory of the CLM workspace.
|
|
26
|
+
clm_file (str): The CLM file to register.
|
|
27
|
+
"""
|
|
28
|
+
load_normalized_manifest_fn = get_load_manifest()
|
|
29
|
+
clm_file = resolve_clm_path(clm_root, clm_file)
|
|
30
|
+
db = CLMDB(clm_root)
|
|
31
|
+
repo = CLMRepository(db, clm_root)
|
|
32
|
+
rel_path = os.path.relpath(clm_file, clm_root)
|
|
33
|
+
rel_path = rel_path.replace('\\', '/')
|
|
34
|
+
handle_name = f"file:{rel_path}"
|
|
35
|
+
|
|
36
|
+
with open(clm_file, encoding="utf-8") as f:
|
|
37
|
+
content = f.read()
|
|
38
|
+
norm_content = content.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n")
|
|
39
|
+
content_hash = clm_hash_string(norm_content)
|
|
40
|
+
|
|
41
|
+
repo.register_handle(handle_name, norm_content)
|
|
42
|
+
|
|
43
|
+
json_ast = ""
|
|
44
|
+
try:
|
|
45
|
+
parsed = load_normalized_manifest_fn(clm_root, clm_file)
|
|
46
|
+
json_ast = json.dumps(parsed)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
_logger.warning(f"[register_file_mcard_py] Warning: manifest normalization failed: {e}")
|
|
49
|
+
|
|
50
|
+
if json_ast:
|
|
51
|
+
classification = "Number"
|
|
52
|
+
try:
|
|
53
|
+
parsed_data = json.loads(json_ast)
|
|
54
|
+
classification = classify_clm(parsed_data)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
_logger.warning(f"[register_file_mcard_py] Warning: classification failed: {e}")
|
|
57
|
+
|
|
58
|
+
dialect = "UNKNOWN"
|
|
59
|
+
if classification == "Function":
|
|
60
|
+
try:
|
|
61
|
+
parsed_data = json.loads(json_ast)
|
|
62
|
+
dialect = detect_dialect(parsed_data, classification)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
_logger.warning(f"[register_file_mcard_py] Warning: dialect detection failed: {e}")
|
|
65
|
+
|
|
66
|
+
for key, val in [("ast", json_ast), ("classification", classification), ("dialect", dialect)]:
|
|
67
|
+
h_name = f"{key}:{content_hash}"
|
|
68
|
+
repo.register_handle(h_name, val)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Register file registrar hook with Layer 1 protocols (Dependency Inversion)
|
|
72
|
+
try:
|
|
73
|
+
from clm.layer1.protocols import register_file_registrar_hook
|
|
74
|
+
|
|
75
|
+
register_file_registrar_hook(register_file_mcard_py)
|
|
76
|
+
except ImportError:
|
|
77
|
+
pass
|
clm/layer2/ingest.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Universal MCard Ingestion Membrane (Sprint 392 / DEL-392-02).
|
|
2
|
+
|
|
3
|
+
Provides universal ingestion of local files and raw byte streams into the
|
|
4
|
+
content-addressed MCard virtual filesystem (mcard://), with automatic MIME detection,
|
|
5
|
+
structural metadata extraction, and Zero-FS in-memory storage support.
|
|
6
|
+
|
|
7
|
+
Acceptance Criteria:
|
|
8
|
+
- AC-392-02-01: Ingesting any supported file returns valid MCard with deterministic mcard://<blake3_hash>.
|
|
9
|
+
- AC-392-02-02: Automatic MIME detection without relying solely on file extensions.
|
|
10
|
+
- AC-392-02-03: Structural metadata stored in card metadata.
|
|
11
|
+
- AC-392-02-04: Zero-FS support (in-memory SQLite storage when CLM_ZERO_FS=true).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import os
|
|
18
|
+
import sqlite3
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from clm.cross_cutting.domain_models import MCard
|
|
22
|
+
from clm.layer2.mcard_fs import MCardFileSystem
|
|
23
|
+
from clm.layer2.mime_detector import detect_mime_and_format, extract_structural_metadata
|
|
24
|
+
|
|
25
|
+
_SHARED_MEMORY_CONN: sqlite3.Connection | None = None
|
|
26
|
+
_DEFAULT_FS: MCardFileSystem | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_default_fs() -> MCardFileSystem:
|
|
30
|
+
"""Obtain the default MCardFileSystem instance for the current process.
|
|
31
|
+
|
|
32
|
+
Under CLM_ZERO_FS=true or default test mode, uses an in-memory SQLite instance.
|
|
33
|
+
"""
|
|
34
|
+
global _SHARED_MEMORY_CONN, _DEFAULT_FS
|
|
35
|
+
if _DEFAULT_FS is not None:
|
|
36
|
+
return _DEFAULT_FS
|
|
37
|
+
|
|
38
|
+
# Check Zero-FS flag
|
|
39
|
+
zero_fs = os.environ.get("CLM_ZERO_FS", "true").lower() in ("true", "1", "yes")
|
|
40
|
+
|
|
41
|
+
if zero_fs:
|
|
42
|
+
if _SHARED_MEMORY_CONN is None:
|
|
43
|
+
_SHARED_MEMORY_CONN = sqlite3.connect(":memory:", check_same_thread=False)
|
|
44
|
+
_DEFAULT_FS = MCardFileSystem(conn=_SHARED_MEMORY_CONN)
|
|
45
|
+
else:
|
|
46
|
+
clm_root = os.environ.get("CLM_ROOT") or os.getcwd()
|
|
47
|
+
db_path = Path(clm_root) / "computable_knowledge" / "content_memory.db"
|
|
48
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
50
|
+
_DEFAULT_FS = MCardFileSystem(conn=conn)
|
|
51
|
+
|
|
52
|
+
return _DEFAULT_FS
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reset_default_fs() -> None:
|
|
56
|
+
"""Reset shared default filesystem (primarily for test isolation)."""
|
|
57
|
+
global _SHARED_MEMORY_CONN, _DEFAULT_FS
|
|
58
|
+
if _SHARED_MEMORY_CONN is not None:
|
|
59
|
+
try:
|
|
60
|
+
_SHARED_MEMORY_CONN.close()
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
_SHARED_MEMORY_CONN = None
|
|
64
|
+
_DEFAULT_FS = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ingest_bytes(
|
|
68
|
+
data: bytes,
|
|
69
|
+
format: str | None = None,
|
|
70
|
+
handle: str | None = None,
|
|
71
|
+
author: str = "did:clm:user",
|
|
72
|
+
fs: MCardFileSystem | None = None,
|
|
73
|
+
filename: str | None = None,
|
|
74
|
+
) -> MCard:
|
|
75
|
+
"""Ingest raw bytes into the MCard Virtual File System.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
data: Raw byte payload.
|
|
79
|
+
format: Optional explicit format override. If None, sniffed automatically.
|
|
80
|
+
handle: Optional human-readable handle (e.g. 'customers.csv' or 'mcard://my_dataset').
|
|
81
|
+
author: DID string of the authoring entity.
|
|
82
|
+
fs: Optional MCardFileSystem instance. If None, uses default fs.
|
|
83
|
+
filename: Optional source filename to assist MIME sniffing.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
MCard instance registered in CAS.
|
|
87
|
+
"""
|
|
88
|
+
target_fs = fs or get_default_fs()
|
|
89
|
+
|
|
90
|
+
# 1. Detect MIME and format
|
|
91
|
+
sniffed_mime, sniffed_format = detect_mime_and_format(data, filename=filename)
|
|
92
|
+
chosen_format = format or sniffed_format
|
|
93
|
+
|
|
94
|
+
# 2. Extract structural metadata
|
|
95
|
+
metadata = extract_structural_metadata(data, sniffed_mime, chosen_format)
|
|
96
|
+
if filename:
|
|
97
|
+
metadata["source_filename"] = os.path.basename(filename)
|
|
98
|
+
|
|
99
|
+
# 3. Format payload for canonical storage
|
|
100
|
+
is_textual = chosen_format in ("csv", "json", "yaml", "markdown", "text")
|
|
101
|
+
if is_textual:
|
|
102
|
+
try:
|
|
103
|
+
payload = data.decode("utf-8")
|
|
104
|
+
metadata["encoding"] = "utf-8"
|
|
105
|
+
except UnicodeDecodeError:
|
|
106
|
+
payload = base64.b64encode(data).decode("ascii")
|
|
107
|
+
metadata["encoding"] = "base64"
|
|
108
|
+
else:
|
|
109
|
+
payload = base64.b64encode(data).decode("ascii")
|
|
110
|
+
metadata["encoding"] = "base64"
|
|
111
|
+
|
|
112
|
+
# 4. Compute deterministic BLAKE3 hash of content
|
|
113
|
+
from clm.layer0.model.card_triad import compute_canonical_hash
|
|
114
|
+
digest = compute_canonical_hash(payload)
|
|
115
|
+
|
|
116
|
+
# 5. Resolve canonical URI
|
|
117
|
+
if handle:
|
|
118
|
+
clean_handle = handle.removeprefix("mcard://").removeprefix("mcard:").strip("/")
|
|
119
|
+
uri = f"mcard:{clean_handle}"
|
|
120
|
+
else:
|
|
121
|
+
uri = f"mcard:{digest}"
|
|
122
|
+
|
|
123
|
+
# 6. Store in filesystem
|
|
124
|
+
mcard = target_fs.write_file(
|
|
125
|
+
uri=uri,
|
|
126
|
+
payload=payload,
|
|
127
|
+
author=author,
|
|
128
|
+
metadata=metadata,
|
|
129
|
+
)
|
|
130
|
+
return mcard
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def ingest_file(
|
|
134
|
+
path: str | Path,
|
|
135
|
+
handle: str | None = None,
|
|
136
|
+
author: str = "did:clm:user",
|
|
137
|
+
format: str | None = None,
|
|
138
|
+
fs: MCardFileSystem | None = None,
|
|
139
|
+
) -> MCard:
|
|
140
|
+
"""Ingest a local filesystem file into the MCard Virtual File System.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
path: Path to the local file.
|
|
144
|
+
handle: Optional handle name. If None, defaults to file's basename or hash.
|
|
145
|
+
author: DID string of the ingesting agent.
|
|
146
|
+
format: Optional format override.
|
|
147
|
+
fs: Optional MCardFileSystem instance.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
MCard instance registered in CAS.
|
|
151
|
+
"""
|
|
152
|
+
file_path = Path(path).resolve()
|
|
153
|
+
if not file_path.is_file():
|
|
154
|
+
raise FileNotFoundError(f"File not found for ingestion: {path}")
|
|
155
|
+
|
|
156
|
+
data = file_path.read_bytes()
|
|
157
|
+
default_handle = handle or file_path.name
|
|
158
|
+
|
|
159
|
+
return ingest_bytes(
|
|
160
|
+
data=data,
|
|
161
|
+
format=format,
|
|
162
|
+
handle=default_handle,
|
|
163
|
+
author=author,
|
|
164
|
+
fs=fs,
|
|
165
|
+
filename=str(file_path),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def export_mcard(
|
|
170
|
+
uri: str,
|
|
171
|
+
output_path: str | Path,
|
|
172
|
+
fs: MCardFileSystem | None = None,
|
|
173
|
+
) -> Path:
|
|
174
|
+
"""Export an MCard from CAS back to the local host filesystem.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
uri: Handle or URI of the MCard (e.g. 'mcard://<hash>' or 'mcard:<handle>').
|
|
178
|
+
output_path: Local filesystem destination path.
|
|
179
|
+
fs: Optional MCardFileSystem instance.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Path of written file.
|
|
183
|
+
"""
|
|
184
|
+
target_fs = fs or get_default_fs()
|
|
185
|
+
mcard = target_fs.read_file(uri)
|
|
186
|
+
|
|
187
|
+
out_p = Path(output_path).resolve()
|
|
188
|
+
out_p.parent.mkdir(parents=True, exist_ok=True)
|
|
189
|
+
|
|
190
|
+
encoding = mcard.metadata.get("encoding", "utf-8")
|
|
191
|
+
if encoding == "base64" and isinstance(mcard.payload, str):
|
|
192
|
+
raw_bytes = base64.b64decode(mcard.payload)
|
|
193
|
+
out_p.write_bytes(raw_bytes)
|
|
194
|
+
elif isinstance(mcard.payload, str):
|
|
195
|
+
out_p.write_text(mcard.payload, encoding="utf-8")
|
|
196
|
+
elif isinstance(mcard.payload, bytes):
|
|
197
|
+
out_p.write_bytes(mcard.payload)
|
|
198
|
+
else:
|
|
199
|
+
import json
|
|
200
|
+
out_p.write_text(json.dumps(mcard.payload, indent=2), encoding="utf-8")
|
|
201
|
+
|
|
202
|
+
return out_p
|