rosetta-cli 3.0.5__tar.gz → 3.0.7__tar.gz
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.
- {rosetta_cli-3.0.5/rosetta_cli.egg-info → rosetta_cli-3.0.7}/PKG-INFO +1 -1
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/pyproject.toml +1 -1
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/cleanup_command.py +10 -1
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/parse_command.py +43 -44
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/rosetta_config.py +49 -15
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/rosetta_publisher.py +61 -24
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/services/document_data.py +10 -3
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7/rosetta_cli.egg-info}/PKG-INFO +1 -1
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/MANIFEST.in +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/README.md +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/env.template +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/__init__.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/__main__.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/cli.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/__init__.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/base_command.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/list_command.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/publish_command.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/verify_command.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/ragflow_client.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/rosetta_auth.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/rosetta_utils.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/services/__init__.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/services/auth_service.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/services/dataset_service.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/services/document_service.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/typing_utils.py +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli.egg-info/SOURCES.txt +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli.egg-info/dependency_links.txt +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli.egg-info/entry_points.txt +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli.egg-info/requires.txt +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli.egg-info/top_level.txt +0 -0
- {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/setup.cfg +0 -0
|
@@ -87,7 +87,16 @@ class CleanupCommand(BaseCommand):
|
|
|
87
87
|
dataset_service: DatasetService,
|
|
88
88
|
args: CommandArgs,
|
|
89
89
|
) -> list[DocumentLike]:
|
|
90
|
-
"""Get documents to delete based on filters.
|
|
90
|
+
"""Get documents to delete based on filters.
|
|
91
|
+
|
|
92
|
+
Accepted limitation: every branch below reads one page, bounded by
|
|
93
|
+
RAGFLOW_PAGE_SIZE (default 1000). On a dataset larger than that, this
|
|
94
|
+
command deletes the documents on the first page and reports success -
|
|
95
|
+
the remainder is left in place with no warning. The instruction set is
|
|
96
|
+
not expected to approach the cap, and this command serves the optional
|
|
97
|
+
MCP publishing pipeline rather than plugin delivery. Anyone pointing
|
|
98
|
+
`--dataset` at a larger dataset should know the delete is partial.
|
|
99
|
+
"""
|
|
91
100
|
document_service = DocumentService(self.client)
|
|
92
101
|
|
|
93
102
|
if args.tags:
|
|
@@ -11,7 +11,7 @@ from ..services.auth_service import AuthService
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
from .base_command import BaseCommand
|
|
14
|
-
from ..typing_utils import CommandArgs, DatasetLike, JsonDict
|
|
14
|
+
from ..typing_utils import CommandArgs, DatasetLike, DocumentLike, JsonDict
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class ParseCommand(BaseCommand):
|
|
@@ -102,58 +102,57 @@ class ParseCommand(BaseCommand):
|
|
|
102
102
|
traceback.print_exc()
|
|
103
103
|
return 1
|
|
104
104
|
|
|
105
|
+
# RAGFlow `run` values that mean a document still needs parsing.
|
|
106
|
+
PARSE_REQUIRED_STATUSES = ("FAIL", "UNSTART", "CANCEL")
|
|
107
|
+
|
|
105
108
|
def _get_documents_to_parse(self, dataset: DatasetLike, args: CommandArgs) -> tuple[list[JsonDict], dict[str, int]]:
|
|
106
109
|
"""Get documents that need parsing."""
|
|
107
|
-
document_service = DocumentService(self.client)
|
|
108
110
|
docs_to_parse: list[JsonDict] = []
|
|
109
111
|
status_counts = {'done': 0, 'running': 0}
|
|
110
112
|
|
|
111
113
|
print(f"Checking parsing status for documents...")
|
|
112
114
|
|
|
115
|
+
# One unfiltered list call serves both modes: force selects everything,
|
|
116
|
+
# default partitions the same list client-side on `run`. Default mode used
|
|
117
|
+
# to make two calls - a server-side run= filter, plus a full list purely to
|
|
118
|
+
# tally done/running for the summary.
|
|
119
|
+
#
|
|
120
|
+
# Deliberate tradeoff, not a no-op: the server-filtered call surfaced up to
|
|
121
|
+
# page_size documents *needing parsing*, whereas page 1 of an unfiltered
|
|
122
|
+
# list surfaces only the needing-parse documents that happen to fall inside
|
|
123
|
+
# it. Once a dataset exceeds RAGFLOW_PAGE_SIZE this can therefore select
|
|
124
|
+
# fewer documents than before.
|
|
125
|
+
#
|
|
126
|
+
# Accepted limitation, not a deferred fix: every list path in this command
|
|
127
|
+
# reads page 1 only, bounded by RAGFLOW_PAGE_SIZE (default 1000). The
|
|
128
|
+
# instruction set is not expected to approach that cap - it is roughly 470
|
|
129
|
+
# files - and this command serves the optional MCP publishing pipeline,
|
|
130
|
+
# which is secondary to plugin delivery. The page-1-only read is also
|
|
131
|
+
# pre-existing: it already applied to the force branch and to the
|
|
132
|
+
# done/running tally.
|
|
133
|
+
documents = dataset.list_documents(page_size=self.config.page_size)
|
|
134
|
+
|
|
135
|
+
selected: list[tuple[DocumentLike, str]] = []
|
|
136
|
+
for document in documents:
|
|
137
|
+
status = self._count_parse_status(status_counts, document)
|
|
138
|
+
if args.force or status in self.PARSE_REQUIRED_STATUSES:
|
|
139
|
+
selected.append((document, status))
|
|
140
|
+
|
|
113
141
|
if args.force:
|
|
114
|
-
|
|
115
|
-
documents = dataset.list_documents(page_size=self.config.page_size)
|
|
116
|
-
|
|
117
|
-
print(f"Found {len(documents)} document(s) (force mode - parsing all)\n")
|
|
118
|
-
|
|
119
|
-
for document in documents:
|
|
120
|
-
status = self._count_parse_status(status_counts, document)
|
|
121
|
-
doc_id = getattr(document, 'id', None)
|
|
122
|
-
if doc_id:
|
|
123
|
-
docs_to_parse.append({
|
|
124
|
-
"id": doc_id,
|
|
125
|
-
"name": getattr(document, 'name', 'Untitled'),
|
|
126
|
-
"dataset_id": dataset.id,
|
|
127
|
-
"folder": ".",
|
|
128
|
-
"status": status
|
|
129
|
-
})
|
|
142
|
+
print(f"Found {len(selected)} document(s) (force mode - parsing all)\n")
|
|
130
143
|
else:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
print(f"Found {len(documents_needing_parse)} document(s) needing parsing\n")
|
|
145
|
-
|
|
146
|
-
for document in documents_needing_parse:
|
|
147
|
-
status = self._count_parse_status(status_counts, document)
|
|
148
|
-
doc_id = getattr(document, 'id', None)
|
|
149
|
-
if doc_id:
|
|
150
|
-
docs_to_parse.append({
|
|
151
|
-
"id": doc_id,
|
|
152
|
-
"name": getattr(document, 'name', 'Untitled'),
|
|
153
|
-
"dataset_id": dataset.id,
|
|
154
|
-
"folder": ".",
|
|
155
|
-
"status": status
|
|
156
|
-
})
|
|
144
|
+
print(f"Found {len(selected)} document(s) needing parsing\n")
|
|
145
|
+
|
|
146
|
+
for document, status in selected:
|
|
147
|
+
doc_id = getattr(document, 'id', None)
|
|
148
|
+
if doc_id:
|
|
149
|
+
docs_to_parse.append({
|
|
150
|
+
"id": doc_id,
|
|
151
|
+
"name": getattr(document, 'name', 'Untitled'),
|
|
152
|
+
"dataset_id": dataset.id,
|
|
153
|
+
"folder": ".",
|
|
154
|
+
"status": status
|
|
155
|
+
})
|
|
157
156
|
|
|
158
157
|
return docs_to_parse, status_counts
|
|
159
158
|
|
|
@@ -9,7 +9,6 @@ import logging
|
|
|
9
9
|
import os
|
|
10
10
|
from dataclasses import dataclass
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from urllib.parse import urlparse
|
|
13
12
|
|
|
14
13
|
from dotenv import load_dotenv
|
|
15
14
|
|
|
@@ -69,14 +68,40 @@ def find_env_file(env_name: str | None = None) -> Path | None:
|
|
|
69
68
|
return None
|
|
70
69
|
|
|
71
70
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
71
|
+
# Environment keywords searched for in the RAGFlow URL, in scan order.
|
|
72
|
+
#
|
|
73
|
+
# ORDER IS LOAD-BEARING - do not sort or alphabetize this tuple. The scan takes the
|
|
74
|
+
# first keyword that appears anywhere in the URL, so a keyword that contains another
|
|
75
|
+
# keyword must be listed before the one it contains. "prod" is a substring of
|
|
76
|
+
# "preprod" - the only such pair in this set - so putting "prod" first would make
|
|
77
|
+
# preprod.example.com resolve to "prod". test_environment_keyword_order_is_safe in
|
|
78
|
+
# tests/test_rosetta_config_environment.py enforces this invariant.
|
|
79
|
+
ENVIRONMENT_KEYWORDS: tuple[str, ...] = (
|
|
80
|
+
"preprod", # must precede "prod", which it contains
|
|
81
|
+
"prod",
|
|
82
|
+
"stag",
|
|
83
|
+
"qa",
|
|
84
|
+
"dev",
|
|
85
|
+
"test",
|
|
86
|
+
"uat",
|
|
87
|
+
"sandbox",
|
|
88
|
+
"perf",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _environment_from_url(url: str) -> str | None:
|
|
93
|
+
"""Detect an environment name from keywords in a RAGFlow URL.
|
|
94
|
+
|
|
95
|
+
Case-insensitive substring scan over the URL exactly as given (scheme, host,
|
|
96
|
+
port and path all count). The first keyword in ENVIRONMENT_KEYWORDS to appear
|
|
97
|
+
wins, and the resolved value is the keyword itself, not the surrounding label:
|
|
98
|
+
'https://ims-dev.example.com/' -> 'dev'. Returns None when no keyword appears.
|
|
99
|
+
"""
|
|
100
|
+
lowered = (url or "").lower()
|
|
101
|
+
for keyword in ENVIRONMENT_KEYWORDS:
|
|
102
|
+
if keyword in lowered:
|
|
103
|
+
return keyword
|
|
104
|
+
return None
|
|
80
105
|
|
|
81
106
|
|
|
82
107
|
@dataclass
|
|
@@ -98,7 +123,12 @@ class RosettaConfig:
|
|
|
98
123
|
RAGFLOW_PAGE_SIZE: Page size for listing operations (default: 1000)
|
|
99
124
|
RAGFLOW_PARSE_TIMEOUT: Timeout for parsing operations in seconds (default: 1200)
|
|
100
125
|
RAGFLOW_TIMEOUT: HTTP request timeout in seconds (default: 30)
|
|
101
|
-
ENVIRONMENT: Environment
|
|
126
|
+
ENVIRONMENT: Environment display label. Takes precedence over URL keyword
|
|
127
|
+
detection, but not over an explicit --env argument. When unset, the
|
|
128
|
+
label is detected from RAGFLOW_BASE_URL by a case-insensitive scan for
|
|
129
|
+
the first of preprod, prod, stag, qa, dev, test, uat, sandbox, perf to
|
|
130
|
+
appear in it (the resolved value is that keyword itself, so
|
|
131
|
+
"https://ims-dev.example.com" yields "dev"); "local" if none appear.
|
|
102
132
|
|
|
103
133
|
Examples:
|
|
104
134
|
>>> config = RosettaConfig.from_env(".env")
|
|
@@ -194,9 +224,11 @@ class RosettaConfig:
|
|
|
194
224
|
Args:
|
|
195
225
|
environment: Optional explicit environment name (e.g., "local",
|
|
196
226
|
"dev", "remote"). If provided, this value is used and takes
|
|
197
|
-
precedence over
|
|
198
|
-
|
|
199
|
-
|
|
227
|
+
precedence over everything else. If not provided, the
|
|
228
|
+
ENVIRONMENT variable is used; failing that, the label is
|
|
229
|
+
detected from a keyword in RAGFLOW_BASE_URL (see
|
|
230
|
+
ENVIRONMENT_KEYWORDS), defaulting to "local" when no keyword
|
|
231
|
+
appears.
|
|
200
232
|
|
|
201
233
|
Returns:
|
|
202
234
|
RosettaConfig instance
|
|
@@ -213,9 +245,11 @@ class RosettaConfig:
|
|
|
213
245
|
api_key = os.getenv("RAGFLOW_API_KEY", "")
|
|
214
246
|
dataset_default = os.getenv("RAGFLOW_DATASET_DEFAULT", "aia")
|
|
215
247
|
dataset_template = os.getenv("RAGFLOW_DATASET_TEMPLATE", "aia-{release}")
|
|
216
|
-
#
|
|
248
|
+
# Environment is a display label only - nothing branches on its value.
|
|
249
|
+
# Precedence: explicit argument (--env) > ENVIRONMENT > keyword detected in
|
|
250
|
+
# the RAGFlow URL > "local".
|
|
217
251
|
if not environment:
|
|
218
|
-
environment = os.getenv("ENVIRONMENT") or
|
|
252
|
+
environment = os.getenv("ENVIRONMENT") or _environment_from_url(base_url) or "local"
|
|
219
253
|
|
|
220
254
|
# Dataset creation settings
|
|
221
255
|
embedding_model = os.getenv("RAGFLOW_EMBEDDING_MODEL") or None
|
|
@@ -23,7 +23,7 @@ from pathlib import Path
|
|
|
23
23
|
from typing import cast
|
|
24
24
|
|
|
25
25
|
from .services.document_service import DocumentService
|
|
26
|
-
from .services.document_data import DocumentData
|
|
26
|
+
from .services.document_data import DocumentData, InvalidTextEncodingError
|
|
27
27
|
from .ragflow_client import DocumentMetadata, RAGFlowClient, RAGFlowClientError
|
|
28
28
|
from .rosetta_config import DEFAULT_PARSE_TIMEOUT
|
|
29
29
|
from .typing_utils import DocumentLike, JsonDict
|
|
@@ -171,6 +171,7 @@ class ContentPublisher:
|
|
|
171
171
|
print("Reading files...")
|
|
172
172
|
all_caches = [] # ALL successfully read caches (for cleanup/orphan detection)
|
|
173
173
|
results = []
|
|
174
|
+
read_failures: list[Path] = []
|
|
174
175
|
|
|
175
176
|
for file in files:
|
|
176
177
|
try:
|
|
@@ -178,17 +179,32 @@ class ContentPublisher:
|
|
|
178
179
|
cache = DocumentData.from_file(
|
|
179
180
|
file,
|
|
180
181
|
self.workspace_root,
|
|
181
|
-
self.file_extensions,
|
|
182
182
|
publish_root=folder,
|
|
183
183
|
)
|
|
184
184
|
all_caches.append(cache)
|
|
185
185
|
|
|
186
186
|
except Exception as e:
|
|
187
|
+
read_failures.append(file)
|
|
187
188
|
print(f"✗ Error reading {file.name}: {e}")
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
189
|
+
results.append(PublishResult(
|
|
190
|
+
success=False,
|
|
191
|
+
document_id="",
|
|
192
|
+
file_path=str(file),
|
|
193
|
+
tags=[],
|
|
194
|
+
error=str(e),
|
|
195
|
+
))
|
|
196
|
+
|
|
197
|
+
# Destructive cleanup requires a complete local inventory. If even one
|
|
198
|
+
# file could not be read, preserve remote documents until a full run can
|
|
199
|
+
# determine which paths are truly stale.
|
|
200
|
+
if read_failures:
|
|
201
|
+
print(
|
|
202
|
+
"\nDuplicate cleanup skipped "
|
|
203
|
+
f"({len(read_failures)} local file(s) could not be read)"
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
# Remove stale server copies of the same file before publishing.
|
|
207
|
+
self._cleanup_duplicates(all_caches, dry_run)
|
|
192
208
|
|
|
193
209
|
# Check for changes after cleanup so skip decisions reflect current server state.
|
|
194
210
|
doc_caches = [] # Only changed caches (for publishing)
|
|
@@ -271,15 +287,23 @@ class ContentPublisher:
|
|
|
271
287
|
# docs — running orphan detection would delete everything else.
|
|
272
288
|
is_full_publish = folder.name == "instructions" or folder.parent == self.workspace_root
|
|
273
289
|
if is_full_publish:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
290
|
+
if read_failures:
|
|
291
|
+
# The local inventory is incomplete, so a failed read must not
|
|
292
|
+
# make an existing server document look like an orphan.
|
|
293
|
+
print(
|
|
294
|
+
"\nOrphan detection skipped "
|
|
295
|
+
f"({len(read_failures)} local file(s) could not be read)"
|
|
296
|
+
)
|
|
297
|
+
else:
|
|
298
|
+
managed_domains_by_dataset: dict[str, set[str]] = {}
|
|
299
|
+
for cache in all_caches:
|
|
300
|
+
dataset_name = self._resolve_dataset_name({"release": cache.release})
|
|
301
|
+
managed_domains_by_dataset.setdefault(dataset_name, set()).add(cache.domain)
|
|
302
|
+
self._cleanup_orphans(
|
|
303
|
+
all_caches,
|
|
304
|
+
dry_run,
|
|
305
|
+
managed_domains_by_dataset=managed_domains_by_dataset,
|
|
306
|
+
)
|
|
283
307
|
else:
|
|
284
308
|
print("\nOrphan detection skipped (subfolder publish)")
|
|
285
309
|
|
|
@@ -347,12 +371,20 @@ class ContentPublisher:
|
|
|
347
371
|
)
|
|
348
372
|
|
|
349
373
|
# Create cache on-the-fly (still better than reading multiple times)
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
374
|
+
try:
|
|
375
|
+
cache = DocumentData.from_file(
|
|
376
|
+
file,
|
|
377
|
+
self.workspace_root,
|
|
378
|
+
publish_root=file.parent,
|
|
379
|
+
)
|
|
380
|
+
except InvalidTextEncodingError as e:
|
|
381
|
+
return PublishResult(
|
|
382
|
+
success=False,
|
|
383
|
+
document_id="",
|
|
384
|
+
file_path=str(file),
|
|
385
|
+
tags=[],
|
|
386
|
+
error=str(e),
|
|
387
|
+
)
|
|
356
388
|
|
|
357
389
|
ims_doc_id = cache.ims_doc_id
|
|
358
390
|
content = cache.content
|
|
@@ -641,13 +673,16 @@ class ContentPublisher:
|
|
|
641
673
|
return name[: -len(path.name)] + new_name
|
|
642
674
|
|
|
643
675
|
# Collect all docs to delete into one list: (doc, label)
|
|
644
|
-
# Start with
|
|
676
|
+
# Start with managed docs carrying incomplete metadata: original_path is
|
|
677
|
+
# set (so the doc is ours) but ims_doc_id is absent. A doc with no
|
|
678
|
+
# original_path is custom/unmanaged and is never touched - see the
|
|
679
|
+
# docstring guarantee, which _cleanup_orphans enforces the same way.
|
|
645
680
|
duplicates: list[tuple[DocumentLike, str]] = []
|
|
646
681
|
for doc in all_docs:
|
|
647
682
|
meta = getattr(doc, "meta_fields", {}) or {}
|
|
648
683
|
ims_doc_id = meta.get("ims_doc_id") if isinstance(meta, dict) else getattr(meta, "ims_doc_id", None)
|
|
649
684
|
doc_original_path = meta.get("original_path", "") if isinstance(meta, dict) else getattr(meta, "original_path", "")
|
|
650
|
-
if
|
|
685
|
+
if doc_original_path and not ims_doc_id:
|
|
651
686
|
doc_name = getattr(doc, "name", "") or doc.id
|
|
652
687
|
duplicates.append((doc, doc_name))
|
|
653
688
|
|
|
@@ -674,8 +709,10 @@ class ContentPublisher:
|
|
|
674
709
|
duplicates.append((doc, original_path))
|
|
675
710
|
|
|
676
711
|
# Name duplicates: foo.md + foo(1).md + foo(2).md ...
|
|
712
|
+
# Grouped over managed_docs, not all_docs: a custom doc whose stripped
|
|
713
|
+
# name happens to collide with a managed one must not be deleted.
|
|
677
714
|
name_groups: dict[str, list[DocumentLike]] = defaultdict(list)
|
|
678
|
-
for doc in
|
|
715
|
+
for doc in managed_docs:
|
|
679
716
|
doc_name = getattr(doc, "name", "") or ""
|
|
680
717
|
if not doc_name:
|
|
681
718
|
continue
|
|
@@ -18,6 +18,13 @@ except Exception: # pragma: no cover - guarded at runtime
|
|
|
18
18
|
frontmatter = None
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
class InvalidTextEncodingError(ValueError):
|
|
22
|
+
"""Raised when a text-suffix file cannot be decoded as UTF-8."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, file_path: Path):
|
|
25
|
+
super().__init__(f"{file_path} is not valid UTF-8")
|
|
26
|
+
|
|
27
|
+
|
|
21
28
|
def _is_r2_or_later(release: str) -> bool:
|
|
22
29
|
"""Check if release is r2 or later (r2, r3, r2.5, etc.)."""
|
|
23
30
|
if not release or not release.startswith("r"):
|
|
@@ -85,8 +92,8 @@ class DocumentData:
|
|
|
85
92
|
if is_text:
|
|
86
93
|
try:
|
|
87
94
|
content_str = content.decode("utf-8")
|
|
88
|
-
except UnicodeDecodeError:
|
|
89
|
-
|
|
95
|
+
except UnicodeDecodeError as error:
|
|
96
|
+
raise InvalidTextEncodingError(file_path) from error
|
|
90
97
|
|
|
91
98
|
# Count lines platform-independently: \r\n, \n\r, \r, \n all count as separators
|
|
92
99
|
line_count = None
|
|
@@ -111,7 +118,7 @@ class DocumentData:
|
|
|
111
118
|
doc_title = cls._compute_doc_title(parsed_path, file_path.name)
|
|
112
119
|
|
|
113
120
|
content_hash = cls._calculate_hash(
|
|
114
|
-
content_str if content_str is not None else
|
|
121
|
+
content_str if content_str is not None else hashlib.sha256(content).hexdigest(),
|
|
115
122
|
tags,
|
|
116
123
|
domain,
|
|
117
124
|
release,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|