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.
Files changed (33) hide show
  1. {rosetta_cli-3.0.5/rosetta_cli.egg-info → rosetta_cli-3.0.7}/PKG-INFO +1 -1
  2. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/pyproject.toml +1 -1
  3. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/cleanup_command.py +10 -1
  4. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/parse_command.py +43 -44
  5. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/rosetta_config.py +49 -15
  6. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/rosetta_publisher.py +61 -24
  7. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/services/document_data.py +10 -3
  8. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7/rosetta_cli.egg-info}/PKG-INFO +1 -1
  9. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/MANIFEST.in +0 -0
  10. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/README.md +0 -0
  11. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/env.template +0 -0
  12. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/__init__.py +0 -0
  13. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/__main__.py +0 -0
  14. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/cli.py +0 -0
  15. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/__init__.py +0 -0
  16. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/base_command.py +0 -0
  17. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/list_command.py +0 -0
  18. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/publish_command.py +0 -0
  19. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/commands/verify_command.py +0 -0
  20. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/ragflow_client.py +0 -0
  21. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/rosetta_auth.py +0 -0
  22. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/rosetta_utils.py +0 -0
  23. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/services/__init__.py +0 -0
  24. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/services/auth_service.py +0 -0
  25. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/services/dataset_service.py +0 -0
  26. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/services/document_service.py +0 -0
  27. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli/typing_utils.py +0 -0
  28. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli.egg-info/SOURCES.txt +0 -0
  29. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli.egg-info/dependency_links.txt +0 -0
  30. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli.egg-info/entry_points.txt +0 -0
  31. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli.egg-info/requires.txt +0 -0
  32. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/rosetta_cli.egg-info/top_level.txt +0 -0
  33. {rosetta_cli-3.0.5 → rosetta_cli-3.0.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rosetta-cli
3
- Version: 3.0.5
3
+ Version: 3.0.7
4
4
  Summary: Rosetta CLI for publishing knowledge base content to RAGFlow
5
5
  Author-email: Rosetta Team <rosetta-support@griddynamics.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "rosetta-cli"
7
- version = "3.0.5"
7
+ version = "3.0.7"
8
8
  description = "Rosetta CLI for publishing knowledge base content to RAGFlow"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -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
- # Force mode: all documents
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
- # Default mode: filter by status (need parsing)
132
- documents_needing_parse = document_service.list_documents_by_status(
133
- dataset,
134
- statuses=["FAIL", "UNSTART", "CANCEL"],
135
- limit=self.config.page_size
136
- )
137
-
138
- # Tally skipped documents from the full list so the summary reflects
139
- # what this run will not re-trigger.
140
- all_documents = dataset.list_documents(page_size=self.config.page_size)
141
- for document in all_documents:
142
- self._count_parse_status(status_counts, document)
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
- def _first_subdomain(url: str) -> str | None:
73
- """Extract first subdomain from URL. 'https://ims-dev.example.com/' -> 'ims-dev'."""
74
- try:
75
- hostname = urlparse(url).hostname or ""
76
- parts = hostname.split(".")
77
- return parts[0] if len(parts) > 1 else None
78
- except Exception:
79
- return None
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 name (default: "local")
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 the ENVIRONMENT environment variable. If not
198
- provided, the ENVIRONMENT variable is used, defaulting to
199
- "local" when unset.
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
- # fallback to ENVIRONMENT env var, or default to "local"
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 _first_subdomain(base_url) or "local"
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
- continue
189
-
190
- # Duplicate cleanup: remove stale server copies of the same file (before publish)
191
- self._cleanup_duplicates(all_caches, dry_run)
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
- managed_domains_by_dataset: dict[str, set[str]] = {}
275
- for cache in all_caches:
276
- dataset_name = self._resolve_dataset_name({"release": cache.release})
277
- managed_domains_by_dataset.setdefault(dataset_name, set()).add(cache.domain)
278
- self._cleanup_orphans(
279
- all_caches,
280
- dry_run,
281
- managed_domains_by_dataset=managed_domains_by_dataset,
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
- cache = DocumentData.from_file(
351
- file,
352
- self.workspace_root,
353
- self.file_extensions,
354
- publish_root=file.parent,
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 unmanaged: incomplete metadata (ims_doc_id or original_path absent)
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 not ims_doc_id or not doc_original_path:
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 all_docs:
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
- content_str = content.decode("utf-8", errors="ignore")
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 str(len(content)),
121
+ content_str if content_str is not None else hashlib.sha256(content).hexdigest(),
115
122
  tags,
116
123
  domain,
117
124
  release,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rosetta-cli
3
- Version: 3.0.5
3
+ Version: 3.0.7
4
4
  Summary: Rosetta CLI for publishing knowledge base content to RAGFlow
5
5
  Author-email: Rosetta Team <rosetta-support@griddynamics.com>
6
6
  License-Expression: Apache-2.0
File without changes
File without changes
File without changes
File without changes