cognee 1.2.2.dev2__py3-none-any.whl → 1.2.2.dev4__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.
@@ -18,6 +18,7 @@ async def export(
18
18
  destination: Optional[Union[str, Path]] = None,
19
19
  user=None,
20
20
  link_relations: bool = False,
21
+ include_permissions: bool = False,
21
22
  ) -> GraphSnapshot: ...
22
23
 
23
24
 
@@ -28,6 +29,7 @@ async def export(
28
29
  destination: Optional[Union[str, Path]] = None,
29
30
  user=None,
30
31
  link_relations: bool = False,
32
+ include_permissions: bool = False,
31
33
  ) -> ExportResult: ...
32
34
 
33
35
 
@@ -39,6 +41,7 @@ async def export(
39
41
  destination: Optional[Union[str, Path]] = None,
40
42
  user=None,
41
43
  link_relations: bool = False,
44
+ include_permissions: bool = False,
42
45
  ) -> ExportResult: ...
43
46
 
44
47
 
@@ -48,6 +51,7 @@ async def export(
48
51
  destination: Optional[Union[str, Path]] = None,
49
52
  user=None,
50
53
  link_relations: bool = False,
54
+ include_permissions: bool = False,
51
55
  ) -> Union[ExportResult, GraphSnapshot]:
52
56
  """Export a dataset's knowledge graph.
53
57
 
@@ -97,4 +101,5 @@ async def export(
97
101
  destination=destination,
98
102
  user=user,
99
103
  link_relations=link_relations,
104
+ include_permissions=include_permissions,
100
105
  )
@@ -169,6 +169,7 @@ RECORD_FILES: Dict[str, str] = {
169
169
 
170
170
  MANIFEST_FILE = "manifest.json"
171
171
  RAW_NODES_FILE = "nodes.jsonl"
172
+ PERMISSIONS_FILE = "permissions.json"
172
173
 
173
174
 
174
175
  class COGXManifest(BaseModel):
@@ -177,6 +178,11 @@ class COGXManifest(BaseModel):
177
178
  exported_at: Optional[datetime] = None
178
179
  counts: Dict[str, int] = Field(default_factory=dict)
179
180
  embedding_model: Optional[str] = None
181
+ # Cognee-origin archives only: the source store's stamped data-migration
182
+ # revision at export time. Raw nodes keep their source-store ids, so the
183
+ # importing store must not claim a newer revision than the exported data
184
+ # actually has (None = unknown; the import keeps its own stamp).
185
+ migration_revision: Optional[str] = None
180
186
  notes: List[str] = Field(default_factory=list)
181
187
 
182
188
 
@@ -209,12 +215,13 @@ class COGXArchiveWriter:
209
215
  def __init__(self, directory: Union[str, Path], source_system: str = "cognee"):
210
216
  self.directory = Path(directory)
211
217
  self.directory.mkdir(parents=True, exist_ok=True)
212
- for file_name in (*RECORD_FILES.values(), RAW_NODES_FILE, MANIFEST_FILE):
218
+ for file_name in (*RECORD_FILES.values(), RAW_NODES_FILE, MANIFEST_FILE, PERMISSIONS_FILE):
213
219
  (self.directory / file_name).unlink(missing_ok=True)
214
220
  self.source_system = source_system
215
221
  self.counts: Dict[str, int] = {}
216
222
  self.notes: List[str] = []
217
223
  self.embedding_model: Optional[str] = None
224
+ self.migration_revision: Optional[str] = None
218
225
  self._handles: Dict[str, Any] = {}
219
226
 
220
227
  def __enter__(self) -> "COGXArchiveWriter":
@@ -254,6 +261,7 @@ class COGXArchiveWriter:
254
261
  exported_at=datetime.now(timezone.utc),
255
262
  counts=self.counts,
256
263
  embedding_model=self.embedding_model,
264
+ migration_revision=self.migration_revision,
257
265
  notes=self.notes,
258
266
  )
259
267
  manifest_path = self.directory / MANIFEST_FILE
@@ -269,6 +277,25 @@ def read_manifest(directory: Union[str, Path]) -> Optional[COGXManifest]:
269
277
  return manifest
270
278
 
271
279
 
280
+ def write_social_layer(directory: Union[str, Path], payload: Dict[str, Any]) -> None:
281
+ """Persist the dataset's social layer (owner + grants, WITH credentials).
282
+
283
+ Written only on explicit request (``export(include_permissions=True)``):
284
+ the payload carries user emails, password HASHES, and account flags, so an
285
+ archive containing this file must be handled as a secret, not just data.
286
+ """
287
+ (Path(directory) / PERMISSIONS_FILE).write_text(
288
+ json.dumps(payload, indent=2, default=str), encoding="utf-8"
289
+ )
290
+
291
+
292
+ def read_social_layer(directory: Union[str, Path]) -> Optional[Dict[str, Any]]:
293
+ permissions_path = Path(directory) / PERMISSIONS_FILE
294
+ if not permissions_path.exists():
295
+ return None
296
+ return json.loads(permissions_path.read_text(encoding="utf-8"))
297
+
298
+
272
299
  def _archive_file(base: Path, file_name: str) -> Optional[Path]:
273
300
  """Resolve ``base / file_name`` and confirm it stays inside ``base``.
274
301
 
@@ -14,7 +14,7 @@ Formats:
14
14
 
15
15
  from dataclasses import dataclass
16
16
  from pathlib import Path
17
- from typing import Optional, Union
17
+ from typing import Any, Dict, Optional, Union
18
18
  from uuid import UUID
19
19
 
20
20
  from cognee.modules.migration.cogx import (
@@ -23,6 +23,7 @@ from cognee.modules.migration.cogx import (
23
23
  COGXEntity,
24
24
  COGXFact,
25
25
  parse_timestamp,
26
+ write_social_layer,
26
27
  )
27
28
  from cognee.modules.migration.formats import write_cypher, write_graphml, write_json
28
29
  from cognee.modules.migration.snapshot import GraphSnapshot, build_snapshot
@@ -51,13 +52,98 @@ class ExportResult:
51
52
  )
52
53
 
53
54
 
55
+ def _user_payload(user_row) -> Dict[str, Any]:
56
+ return {
57
+ "email": user_row.email,
58
+ "hashed_password": user_row.hashed_password,
59
+ "is_active": user_row.is_active,
60
+ "is_superuser": user_row.is_superuser,
61
+ "is_verified": user_row.is_verified,
62
+ }
63
+
64
+
65
+ async def _gather_social_layer(dataset_obj) -> Dict[str, Any]:
66
+ """The dataset's owner and ACL grants, including user credentials.
67
+
68
+ Emails, password hashes, and account flags are included so a
69
+ cognee-to-cognee restore can recreate functional accounts — which makes
70
+ an archive carrying this payload a SECRET. Only gathered on explicit
71
+ request (``include_permissions=True``).
72
+ """
73
+ from sqlalchemy import select
74
+
75
+ from cognee.infrastructure.databases.relational import get_relational_engine
76
+ from cognee.modules.users.models import ACL, Permission, User
77
+
78
+ db_engine = get_relational_engine()
79
+ async with db_engine.get_async_session() as session:
80
+ owner = await session.get(User, dataset_obj.owner_id)
81
+ rows = (
82
+ await session.execute(
83
+ select(User, Permission.name)
84
+ .join(ACL, ACL.principal_id == User.id)
85
+ .join(Permission, Permission.id == ACL.permission_id)
86
+ .where(ACL.dataset_id == dataset_obj.id)
87
+ )
88
+ ).all()
89
+
90
+ grants: Dict[str, Dict[str, Any]] = {}
91
+ for user_row, permission_name in rows:
92
+ grant = grants.setdefault(
93
+ user_row.email, {"user": _user_payload(user_row), "permissions": []}
94
+ )
95
+ grant["permissions"].append(permission_name)
96
+ return {
97
+ "owner": _user_payload(owner) if owner else None,
98
+ "grants": sorted(grants.values(), key=lambda g: g["user"]["email"]),
99
+ }
100
+
101
+
102
+ async def _stored_migration_revision(dataset_id) -> Optional[str]:
103
+ """The source store's stamped data-migration revision, for the manifest.
104
+
105
+ Per-dataset bookkeeping with backend access control on, the global row
106
+ with it off. None (no row / never migrated / bookkeeping unreadable)
107
+ means "unknown" — the importer then keeps its own stamp.
108
+ """
109
+ from cognee.context_global_variables import backend_access_control_enabled
110
+ from cognee.infrastructure.databases.relational import get_relational_engine
111
+
112
+ try:
113
+ db_engine = get_relational_engine()
114
+ if backend_access_control_enabled():
115
+ from cognee.modules.users.models import DatasetDatabase
116
+
117
+ async with db_engine.get_async_session() as session:
118
+ record = await session.get(DatasetDatabase, dataset_id)
119
+ return record.migration_revision if record else None
120
+
121
+ from cognee.modules.migrations.models import (
122
+ GLOBAL_DATABASE_VERSION_ROW_ID,
123
+ GlobalDatabaseVersion,
124
+ )
125
+
126
+ async with db_engine.get_async_session() as session:
127
+ record = await session.get(GlobalDatabaseVersion, GLOBAL_DATABASE_VERSION_ROW_ID)
128
+ return record.global_migration_revision if record else None
129
+ except Exception as error: # noqa: BLE001 — manifest metadata is best effort
130
+ logger.debug("Could not determine migration revision for manifest: %s", error)
131
+ return None
132
+
133
+
54
134
  def _write_cogx(
55
- nodes, edges, destination: Path, dataset_name: str, embedding_model: Optional[str] = None
135
+ nodes,
136
+ edges,
137
+ destination: Path,
138
+ dataset_name: str,
139
+ embedding_model: Optional[str] = None,
140
+ migration_revision: Optional[str] = None,
56
141
  ) -> None:
57
142
  """Map graph nodes/edges onto typed COGX records; keep the rest raw."""
58
143
  with COGXArchiveWriter(destination, source_system="cognee") as writer:
59
144
  writer.add_note(f"Exported from Cognee dataset {dataset_name!r}.")
60
145
  writer.embedding_model = embedding_model
146
+ writer.migration_revision = migration_revision
61
147
  for node_id, properties in nodes:
62
148
  properties = properties or {}
63
149
  node_type = properties.get("type")
@@ -112,9 +198,16 @@ async def export_dataset(
112
198
  destination: Optional[Union[str, Path]] = None,
113
199
  user=None,
114
200
  link_relations: bool = False,
201
+ include_permissions: bool = False,
115
202
  ) -> Union[ExportResult, GraphSnapshot]:
116
203
  """Export an authorized dataset's graph. Requires read permission.
117
204
 
205
+ ``include_permissions`` (cogx only): additionally write the dataset's
206
+ social layer — owner and ACL grants WITH user credentials — into
207
+ ``permissions.json`` so a cognee-to-cognee restore recreates functional
208
+ accounts and grants. The archive then contains password hashes: treat it
209
+ as a secret.
210
+
118
211
  ``format="pydantic"`` returns a :class:`GraphSnapshot` (typed DataPoint
119
212
  instances, in memory; pass ``destination`` to also persist it as JSON).
120
213
  All other formats write a file and return an :class:`ExportResult`.
@@ -179,7 +272,16 @@ async def export_dataset(
179
272
  embedding_model = get_embedding_context_config().embedding_model
180
273
  except Exception as error: # noqa: BLE001 — manifest metadata is best effort
181
274
  logger.debug("Could not determine embedding model for manifest: %s", error)
182
- _write_cogx(nodes, edges, destination, dataset_obj.name, embedding_model=embedding_model)
275
+ _write_cogx(
276
+ nodes,
277
+ edges,
278
+ destination,
279
+ dataset_obj.name,
280
+ embedding_model=embedding_model,
281
+ migration_revision=await _stored_migration_revision(dataset_obj.id),
282
+ )
283
+ if include_permissions:
284
+ write_social_layer(destination, await _gather_social_layer(dataset_obj))
183
285
  elif format == "json":
184
286
  write_json(nodes, edges, destination)
185
287
  elif format == "graphml":
@@ -42,6 +42,210 @@ DATA_ITEMS_PER_ADD = 200
42
42
  _GRAPH_RECORD_KINDS = ("entity", "fact", "raw_node")
43
43
 
44
44
 
45
+ async def _ensure_user(user_payload: Dict[str, Any]):
46
+ """Create-or-match a user by email, transferring credentials on creation.
47
+
48
+ An existing target user is returned untouched — their credentials are
49
+ never clobbered by an import. A missing user is created and then given
50
+ the archived credentials (hashed password + account flags) directly, so
51
+ restored accounts authenticate with their original passwords.
52
+ """
53
+ import secrets
54
+
55
+ from cognee.infrastructure.databases.relational import get_relational_engine
56
+ from cognee.modules.users.methods import create_user, get_user_by_email
57
+ from cognee.modules.users.models import User
58
+
59
+ existing = await get_user_by_email(user_payload["email"])
60
+ if existing is not None:
61
+ return existing
62
+
63
+ created = await create_user(user_payload["email"], secrets.token_urlsafe(32))
64
+ db_engine = get_relational_engine()
65
+ async with db_engine.get_async_session() as session:
66
+ record = await session.get(User, created.id)
67
+ record.hashed_password = user_payload["hashed_password"]
68
+ record.is_active = user_payload.get("is_active", True)
69
+ record.is_superuser = user_payload.get("is_superuser", False)
70
+ record.is_verified = user_payload.get("is_verified", False)
71
+ await session.commit()
72
+ logger.info("Restored user %s from archive social layer.", user_payload["email"])
73
+ return created
74
+
75
+
76
+ async def _resolve_import_user(source: MemorySource, user):
77
+ """The identity the import runs as.
78
+
79
+ Archives carrying a social layer import AS the archived dataset OWNER
80
+ (created/matched by email first): per-dataset databases derive their
81
+ physical location from the owner id, so ownership must be right BEFORE
82
+ the rows land — it cannot be reassigned afterwards. All other imports
83
+ run as the caller's user, exactly as before.
84
+
85
+ Processing a social layer requires a SUPERUSER importer: the archive
86
+ supplies emails, password hashes, and account flags verbatim, so an
87
+ unprivileged importer could otherwise mint arbitrary accounts (including
88
+ superusers) with credentials of their choosing — both via the SDK and via
89
+ the /v1/remember archive-upload endpoint.
90
+ """
91
+ social_layer = getattr(source, "social_layer", None)
92
+ owner_payload = (social_layer or {}).get("owner")
93
+ if owner_payload is None:
94
+ return user
95
+
96
+ importer = user
97
+ if importer is None:
98
+ from cognee.modules.users.methods import get_default_user
99
+
100
+ importer = await get_default_user()
101
+ if not importer.is_superuser:
102
+ from cognee.modules.users.exceptions.exceptions import PermissionDeniedError
103
+
104
+ raise PermissionDeniedError(
105
+ message="Importing an archive that carries a social layer (permissions.json) "
106
+ "requires a superuser: it restores user accounts and credentials."
107
+ )
108
+ return await _ensure_user(owner_payload)
109
+
110
+
111
+ async def _apply_social_grants(source: MemorySource, dataset_name: str, owner, importer) -> None:
112
+ """Re-apply the archive's ACL grants onto the freshly imported dataset.
113
+
114
+ Users are created/matched by email (credentials transfer on creation);
115
+ ``give_permission_on_dataset`` deduplicates existing ACL rows, so
116
+ re-importing is idempotent. The importing user additionally keeps read
117
+ access — they held the archive bytes, and this prevents silent lockout
118
+ when restoring someone else's dataset.
119
+ """
120
+ social_layer = getattr(source, "social_layer", None)
121
+ if not social_layer:
122
+ return
123
+
124
+ from cognee.modules.data.methods import get_authorized_existing_datasets
125
+ from cognee.modules.users.permissions.methods import give_permission_on_dataset
126
+
127
+ datasets = await get_authorized_existing_datasets([dataset_name], "read", owner)
128
+ if not datasets:
129
+ logger.warning(
130
+ "No dataset %r found after import; cannot restore its social layer.", dataset_name
131
+ )
132
+ return
133
+ dataset_id = datasets[0].id
134
+
135
+ for grant in social_layer.get("grants", []):
136
+ principal = await _ensure_user(grant["user"])
137
+ for permission_name in grant.get("permissions", []):
138
+ await give_permission_on_dataset(principal, dataset_id, permission_name)
139
+
140
+ if importer is None:
141
+ from cognee.modules.users.methods import get_default_user
142
+
143
+ importer = await get_default_user()
144
+ if importer.id != owner.id:
145
+ await give_permission_on_dataset(importer, dataset_id, "read")
146
+
147
+
148
+ def _revision_to_stamp(
149
+ archive_revision: Optional[str],
150
+ stored_revision: Optional[str],
151
+ ordered_revisions: List[str],
152
+ ) -> Optional[str]:
153
+ """The revision the imported store should be re-stamped at, or None.
154
+
155
+ Stamps only BACKWARD — when the archive's revision is strictly behind the
156
+ store's current stamp — so the next migration run replays exactly
157
+ archive -> head over the imported rows (idempotent over already-current
158
+ data). Never forward: stamping ahead would mark the store's own data as
159
+ migrated when it is not. Unknown revisions (either side written by newer
160
+ code) and an unstamped store (None = base, already minimal) leave the
161
+ stamp untouched.
162
+ """
163
+ if archive_revision is None or stored_revision is None:
164
+ return None
165
+ if archive_revision not in ordered_revisions or stored_revision not in ordered_revisions:
166
+ return None
167
+ if ordered_revisions.index(archive_revision) < ordered_revisions.index(stored_revision):
168
+ return archive_revision
169
+ return None
170
+
171
+
172
+ async def _restamp_to_source_revision(source: MemorySource, dataset_name: str, user) -> None:
173
+ """Align the target's migration stamp with a cognee-origin archive.
174
+
175
+ Preserve/hybrid imports write the archive's raw nodes with their
176
+ source-store ids, so the target must not claim a newer data-migration
177
+ revision than the exported data actually has. When the archive carries a
178
+ revision behind the target's stamp, re-stamp backward; the next migration
179
+ gate then replays revision -> head over the imported data. External
180
+ sources carry no revision (their records are written entirely by
181
+ current-code pipelines) and are skipped, as are re-derive imports (no raw
182
+ nodes land).
183
+ """
184
+ archive_revision = getattr(source, "migration_revision", None)
185
+ if archive_revision is None or source.mode == "re-derive":
186
+ return
187
+
188
+ from cognee.context_global_variables import backend_access_control_enabled
189
+ from cognee.infrastructure.databases.relational import get_relational_engine
190
+ from cognee.modules.migrations.migration import order_migrations
191
+ from cognee.modules.migrations.registry import MIGRATIONS
192
+ from cognee.modules.migrations.runner import stamp_revisions
193
+
194
+ ordered_revisions = [migration.revision for migration in order_migrations(MIGRATIONS)]
195
+ if archive_revision not in ordered_revisions:
196
+ logger.warning(
197
+ "Archive migration revision %r is unknown to this chain — the archive was "
198
+ "exported by newer code; leaving the store's migration stamp unchanged.",
199
+ archive_revision,
200
+ )
201
+ return
202
+
203
+ db_engine = get_relational_engine()
204
+ if backend_access_control_enabled():
205
+ from cognee.modules.data.methods import get_authorized_existing_datasets
206
+ from cognee.modules.users.methods import get_default_user
207
+ from cognee.modules.users.models import DatasetDatabase
208
+
209
+ if user is None:
210
+ user = await get_default_user()
211
+ datasets = await get_authorized_existing_datasets([dataset_name], "read", user)
212
+ if not datasets:
213
+ logger.warning(
214
+ "No dataset %r found after import; cannot align its migration stamp.",
215
+ dataset_name,
216
+ )
217
+ return
218
+ dataset_id = datasets[0].id
219
+ async with db_engine.get_async_session() as session:
220
+ record = await session.get(DatasetDatabase, dataset_id)
221
+ stored_revision = record.migration_revision if record else None
222
+ target = _revision_to_stamp(archive_revision, stored_revision, ordered_revisions)
223
+ if target is None:
224
+ return
225
+ await stamp_revisions(target=target, dataset_ids=[dataset_id])
226
+ else:
227
+ from cognee.modules.migrations.models import (
228
+ GLOBAL_DATABASE_VERSION_ROW_ID,
229
+ GlobalDatabaseVersion,
230
+ )
231
+
232
+ async with db_engine.get_async_session() as session:
233
+ record = await session.get(GlobalDatabaseVersion, GLOBAL_DATABASE_VERSION_ROW_ID)
234
+ stored_revision = record.global_migration_revision if record else None
235
+ target = _revision_to_stamp(archive_revision, stored_revision, ordered_revisions)
236
+ if target is None:
237
+ return
238
+ await stamp_revisions(target=target)
239
+
240
+ logger.info(
241
+ "Stamped store back to archive migration revision %r (was %r); the next "
242
+ "migration run replays %r -> head over the imported data.",
243
+ target,
244
+ stored_revision,
245
+ target,
246
+ )
247
+
248
+
45
249
  def _pipeline_run_id(pipeline_result: Any) -> Optional[str]:
46
250
  """Extract the pipeline run id from a run_custom_pipeline return value.
47
251
 
@@ -84,14 +288,34 @@ async def import_memory_source(
84
288
  # before its own gate). This also records the data-migration revision —
85
289
  # stamping a fresh store at head — BEFORE the imported rows arrive;
86
290
  # without it the populated store has no recorded revision and the first
87
- # migration-aware startup replays the entire data chain over it.
291
+ # migration-aware startup replays the entire data chain over it. It must
292
+ # run before user resolution: on a fresh store it also creates the
293
+ # relational schema that user lookup needs.
88
294
  await run_migrations_and_block(dataset_name, user)
89
295
 
296
+ # Archives carrying a social layer import AS the archived owner (see
297
+ # _resolve_import_user); everything else runs as the caller's user.
298
+ importer = user
299
+ user = await _resolve_import_user(source, user)
300
+
90
301
  node_set = node_set or [f"import:{source.source_system}"]
91
302
 
92
303
  if source.mode == "preserve" and getattr(source, "replayable", False):
93
- return await _import_streaming(source, dataset_name, user, run_in_background, node_set)
94
- return await _import_buffered(source, dataset_name, user, run_in_background, node_set, **kwargs)
304
+ result = await _import_streaming(source, dataset_name, user, run_in_background, node_set)
305
+ else:
306
+ result = await _import_buffered(
307
+ source, dataset_name, user, run_in_background, node_set, **kwargs
308
+ )
309
+
310
+ # After the rows land: cognee-origin archives may need the migration
311
+ # stamp aligned backward to the SOURCE store's revision (see
312
+ # _restamp_to_source_revision) and their social layer restored (grants
313
+ # re-applied for recreated users).
314
+ await _restamp_to_source_revision(source, dataset_name, user)
315
+ if getattr(source, "social_layer", None):
316
+ await _apply_social_grants(source, dataset_name, owner=user, importer=importer)
317
+
318
+ return result
95
319
 
96
320
 
97
321
  async def _import_streaming(
@@ -130,7 +354,12 @@ async def _import_streaming(
130
354
 
131
355
  logger.info("Importing from %s (mode=preserve, streaming): %s", source.source_system, counts)
132
356
 
133
- stats: Dict[str, int] = {"graph_nodes": 0, "graph_edges": 0, "skipped_facts": 0}
357
+ stats: Dict[str, int] = {
358
+ "graph_nodes": 0,
359
+ "graph_edges": 0,
360
+ "skipped_facts": 0,
361
+ "deduped_edges": 0,
362
+ }
134
363
  pipeline_result = None
135
364
  has_graph_records = any(counts.get(kind) for kind in _GRAPH_RECORD_KINDS)
136
365
  if has_graph_records:
@@ -175,6 +404,7 @@ async def _import_streaming(
175
404
  "graph_nodes": stats["graph_nodes"],
176
405
  "graph_edges": stats["graph_edges"],
177
406
  "skipped_facts": stats["skipped_facts"],
407
+ "deduped_edges": stats["deduped_edges"],
178
408
  "pipeline_run_id": run_id,
179
409
  }
180
410
  if backgrounded:
@@ -208,7 +438,13 @@ async def _import_buffered(
208
438
 
209
439
  # Translate the record stream directly: no full raw-record list is kept,
210
440
  # so peak memory is bounded by the translation output alone.
211
- translation = await translate_record_stream(source.records(), source.mode)
441
+ translation = await translate_record_stream(
442
+ source.records(),
443
+ source.mode,
444
+ # Cognee-origin archives keep source node UUIDs verbatim; other
445
+ # systems get class-namespaced ids (see _register_entity).
446
+ preserve_source_ids=source.source_system == "cognee",
447
+ )
212
448
 
213
449
  logger.info(
214
450
  "Importing %d records from %s (mode=%s): %s",