unique-sdk 2026.26.0.dev8__tar.gz → 2026.26.0.dev9__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 (84) hide show
  1. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/PKG-INFO +1 -1
  2. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/pyproject.toml +1 -1
  3. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_content.py +158 -0
  4. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/cli.py +70 -12
  5. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/files.py +136 -10
  6. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/shell.py +110 -26
  7. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/skills/unique-cli-file-management/SKILL.md +21 -3
  8. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/utils/file_io.py +7 -0
  9. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/README.md +0 -0
  10. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/__init__.py +0 -0
  11. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_api_requestor.py +0 -0
  12. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_api_resource.py +0 -0
  13. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_api_version.py +0 -0
  14. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_error.py +0 -0
  15. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_http_client.py +0 -0
  16. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_list_object.py +0 -0
  17. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_object_classes.py +0 -0
  18. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_request_options.py +0 -0
  19. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_unique_object.py +0 -0
  20. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_unique_ql.py +0 -0
  21. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_unique_response.py +0 -0
  22. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_util.py +0 -0
  23. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_version.py +0 -0
  24. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/_webhook.py +0 -0
  25. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/__init__.py +0 -0
  26. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_acronyms.py +0 -0
  27. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_agentic_table.py +0 -0
  28. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_analytics_order.py +0 -0
  29. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_benchmarking.py +0 -0
  30. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_briefing.py +0 -0
  31. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_chat_completion.py +0 -0
  32. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_dynamic_frontend.py +0 -0
  33. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_elicitation.py +0 -0
  34. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_embedding.py +0 -0
  35. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_event.py +0 -0
  36. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_folder.py +0 -0
  37. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_group.py +0 -0
  38. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_integrated.py +0 -0
  39. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_llm_models.py +0 -0
  40. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_mcp.py +0 -0
  41. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_message.py +0 -0
  42. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_message_assessment.py +0 -0
  43. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_message_execution.py +0 -0
  44. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_message_log.py +0 -0
  45. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_message_tool.py +0 -0
  46. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_module.py +0 -0
  47. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_scheduled_task.py +0 -0
  48. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_search.py +0 -0
  49. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_search_string.py +0 -0
  50. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_short_term_memory.py +0 -0
  51. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_space.py +0 -0
  52. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_user.py +0 -0
  53. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/api_resources/_web_search.py +0 -0
  54. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/__init__.py +0 -0
  55. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/__main__.py +0 -0
  56. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/__init__.py +0 -0
  57. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/_citation_manifest.py +0 -0
  58. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/cite_file.py +0 -0
  59. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/dynamic_frontend.py +0 -0
  60. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/elicitation.py +0 -0
  61. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/folders.py +0 -0
  62. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/mcp.py +0 -0
  63. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/navigation.py +0 -0
  64. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/read.py +0 -0
  65. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/scheduled_tasks.py +0 -0
  66. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/search.py +0 -0
  67. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/subagent.py +0 -0
  68. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/web_search.py +0 -0
  69. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/commands/web_search_config.py +0 -0
  70. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/config.py +0 -0
  71. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/formatting.py +0 -0
  72. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/skills/unique-cli-elicitation/SKILL.md +0 -0
  73. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/skills/unique-cli-mcp/SKILL.md +0 -0
  74. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/skills/unique-cli-scheduled-tasks/SKILL.md +0 -0
  75. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/skills/unique-cli-search/SKILL.md +0 -0
  76. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/skills/unique-cli-subagent/SKILL.md +0 -0
  77. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/skills/unique-cli-web-search/SKILL.md +0 -0
  78. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/cli/state.py +0 -0
  79. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/utils/analytics_order_run.py +0 -0
  80. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/utils/benchmarking_run.py +0 -0
  81. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/utils/chat_history.py +0 -0
  82. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/utils/chat_in_space.py +0 -0
  83. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/utils/sources.py +0 -0
  84. {unique_sdk-2026.26.0.dev8 → unique_sdk-2026.26.0.dev9}/unique_sdk/utils/token.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-sdk
3
- Version: 2026.26.0.dev8
3
+ Version: 2026.26.0.dev9
4
4
  Summary:
5
5
  Author: Martin Fadler, Konstantin Krauss, Andreas Hauri
6
6
  Author-email: Martin Fadler <martin.fadler@unique.ch>, Konstantin Krauss <konstantin@unique.ch>, Andreas Hauri <andreas@unique.ch>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "unique_sdk"
3
- version = "2026.26.0.dev8"
3
+ version = "2026.26.0.dev9"
4
4
  description = ""
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -170,6 +170,50 @@ class Content(APIResource["Content"]):
170
170
  # a SAS URL the caller PUTs the PDF bytes to. ``file_io.upload_file``
171
171
  # exposes this as ``preview_pdf_path`` for a one-call flow.
172
172
  previewPdfFileName: NotRequired[str | None]
173
+ # When true, the platform archives previous blobs for this
174
+ # content and makes them restorable through the version APIs.
175
+ versioningEnabled: NotRequired[bool | None]
176
+
177
+ class VersionsParams(RequestOptions):
178
+ contentId: str
179
+ skip: NotRequired[int | None]
180
+ take: NotRequired[int | None]
181
+
182
+ class ContentVersion(TypedDict):
183
+ id: str
184
+ contentId: str
185
+ versionNumber: int
186
+ reason: str
187
+ blobObjectKey: str
188
+ key: str
189
+ title: str | None
190
+ description: str | None
191
+ url: str | None
192
+ byteSize: int
193
+ mimeType: str
194
+ ownerType: str
195
+ ownerId: str
196
+ contentHash: str | None
197
+ archivedAt: str
198
+ archivedBy: str | None
199
+ originalCreatedAt: str
200
+ originalCreatedBy: str | None
201
+ createdBy: str | None
202
+
203
+ class PaginatedContentVersions(TypedDict):
204
+ data: list["Content.ContentVersion"]
205
+ object: str
206
+
207
+ class VersionDownloadUrlParams(RequestOptions):
208
+ contentVersionId: str
209
+
210
+ class ContentVersionDownloadUrl(TypedDict):
211
+ id: str
212
+ object: str
213
+ url: str
214
+
215
+ class RestoreVersionParams(RequestOptions):
216
+ contentVersionId: str
173
217
 
174
218
  class UpdateParams(RequestOptions):
175
219
  contentId: NotRequired[str]
@@ -467,6 +511,120 @@ class Content(APIResource["Content"]):
467
511
  ),
468
512
  )
469
513
 
514
+ @classmethod
515
+ def versions(
516
+ cls,
517
+ user_id: str,
518
+ company_id: str,
519
+ **params: Unpack["Content.VersionsParams"],
520
+ ) -> "Content.PaginatedContentVersions":
521
+ content_id = params.pop("contentId")
522
+ return cast(
523
+ Content.PaginatedContentVersions,
524
+ cls._static_request(
525
+ "get",
526
+ f"/content/{content_id}/versions",
527
+ user_id,
528
+ company_id,
529
+ params=params,
530
+ ),
531
+ )
532
+
533
+ @classmethod
534
+ async def versions_async(
535
+ cls,
536
+ user_id: str,
537
+ company_id: str,
538
+ **params: Unpack["Content.VersionsParams"],
539
+ ) -> "Content.PaginatedContentVersions":
540
+ content_id = params.pop("contentId")
541
+ return cast(
542
+ Content.PaginatedContentVersions,
543
+ await cls._static_request_async(
544
+ "get",
545
+ f"/content/{content_id}/versions",
546
+ user_id,
547
+ company_id,
548
+ params=params,
549
+ ),
550
+ )
551
+
552
+ @classmethod
553
+ def version_download_url(
554
+ cls,
555
+ user_id: str,
556
+ company_id: str,
557
+ **params: Unpack["Content.VersionDownloadUrlParams"],
558
+ ) -> "Content.ContentVersionDownloadUrl":
559
+ content_version_id = params.pop("contentVersionId")
560
+ return cast(
561
+ Content.ContentVersionDownloadUrl,
562
+ cls._static_request(
563
+ "get",
564
+ f"/content/versions/{content_version_id}/download-url",
565
+ user_id,
566
+ company_id,
567
+ params=params,
568
+ ),
569
+ )
570
+
571
+ @classmethod
572
+ async def version_download_url_async(
573
+ cls,
574
+ user_id: str,
575
+ company_id: str,
576
+ **params: Unpack["Content.VersionDownloadUrlParams"],
577
+ ) -> "Content.ContentVersionDownloadUrl":
578
+ content_version_id = params.pop("contentVersionId")
579
+ return cast(
580
+ Content.ContentVersionDownloadUrl,
581
+ await cls._static_request_async(
582
+ "get",
583
+ f"/content/versions/{content_version_id}/download-url",
584
+ user_id,
585
+ company_id,
586
+ params=params,
587
+ ),
588
+ )
589
+
590
+ @classmethod
591
+ def restore_version(
592
+ cls,
593
+ user_id: str,
594
+ company_id: str,
595
+ **params: Unpack["Content.RestoreVersionParams"],
596
+ ) -> "Content.ContentInfo":
597
+ content_version_id = params.pop("contentVersionId")
598
+ return cast(
599
+ Content.ContentInfo,
600
+ cls._static_request(
601
+ "post",
602
+ f"/content/versions/{content_version_id}/restore",
603
+ user_id,
604
+ company_id,
605
+ params=params,
606
+ ),
607
+ )
608
+
609
+ @classmethod
610
+ async def restore_version_async(
611
+ cls,
612
+ user_id: str,
613
+ company_id: str,
614
+ **params: Unpack["Content.RestoreVersionParams"],
615
+ ) -> "Content.ContentInfo":
616
+ content_version_id = params.pop("contentVersionId")
617
+ return cast(
618
+ Content.ContentInfo,
619
+ await cls._static_request_async(
620
+ "post",
621
+ f"/content/versions/{content_version_id}/restore",
622
+ user_id,
623
+ company_id,
624
+ params=params,
625
+ ),
626
+ )
627
+
470
628
  @classmethod
471
629
  def ingest_magic_table_sheets(
472
630
  cls,
@@ -20,7 +20,14 @@ from unique_sdk.cli.commands.elicitation import (
20
20
  cmd_elicit_respond,
21
21
  cmd_elicit_wait,
22
22
  )
23
- from unique_sdk.cli.commands.files import cmd_download, cmd_mv_file, cmd_rm, cmd_upload
23
+ from unique_sdk.cli.commands.files import (
24
+ cmd_download,
25
+ cmd_mv_file,
26
+ cmd_restore_version,
27
+ cmd_rm,
28
+ cmd_upload,
29
+ cmd_versions,
30
+ )
24
31
  from unique_sdk.cli.commands.folders import cmd_mkdir, cmd_mvdir, cmd_rmdir
25
32
  from unique_sdk.cli.commands.mcp import cmd_mcp
26
33
  from unique_sdk.cli.commands.navigation import cmd_cd, cmd_ls, cmd_pwd
@@ -91,6 +98,7 @@ Path formats accepted by all commands:
91
98
  \b
92
99
  File identifiers:
93
100
  report.pdf File name (matched in current directory)
101
+ /Reports/report.pdf File path (absolute or relative)
94
102
  cont_abc123 Content ID (used directly)
95
103
 
96
104
  \b
@@ -99,8 +107,10 @@ Examples:
99
107
  unique-cli ls List root folders
100
108
  unique-cli ls /Reports List a specific folder
101
109
  unique-cli search "revenue" -l 50 Search with custom limit
102
- unique-cli upload ./file.pdf Upload to current folder
110
+ unique-cli upload ./file.pdf Upload versioned to current folder
103
111
  unique-cli download cont_abc123 Download by content ID
112
+ unique-cli versions cont_abc123 List archived file versions
113
+ unique-cli restore-version cver_1 Restore a file from a version
104
114
  unique-cli elicit ask "Which?" Ask the user a question synchronously
105
115
  unique-cli subagent Legal "Review" Invoke a connected space/subagent
106
116
  unique-cli web-search search "x" Search the web via the public API
@@ -261,12 +271,13 @@ def mvdir(ctx: click.Context, old_name: str, new_name: str) -> None:
261
271
  @click.argument("destination", required=False, default=None)
262
272
  @click.pass_context
263
273
  def upload(ctx: click.Context, local_path: str, destination: str | None) -> None:
264
- """Upload a local file (works like Linux cp).
274
+ """Upload a local file with versioning enabled (works like Linux cp).
265
275
 
266
276
  \b
267
- Uploads LOCAL_PATH to the Unique platform. DESTINATION works like
268
- the target in cp -- it can be a folder path, a new filename, or
269
- a combination of both. MIME type is auto-detected.
277
+ Uploads LOCAL_PATH to the Unique platform with immutable versioning
278
+ enabled. DESTINATION works like the target in cp -- it can be a
279
+ folder path, a new filename, or a combination of both. MIME type is
280
+ auto-detected.
270
281
 
271
282
  \b
272
283
  Destination formats:
@@ -288,6 +299,48 @@ def upload(ctx: click.Context, local_path: str, destination: str | None) -> None
288
299
  click.echo(cmd_upload(LazyState.get(ctx), local_path, destination))
289
300
 
290
301
 
302
+ @main.command()
303
+ @click.argument("name_or_id")
304
+ @click.option("--skip", type=int, default=None, help="Number of versions to skip.")
305
+ @click.option("--take", type=int, default=None, help="Number of versions to return.")
306
+ @click.pass_context
307
+ def versions(
308
+ ctx: click.Context,
309
+ name_or_id: str,
310
+ skip: int | None,
311
+ take: int | None,
312
+ ) -> None:
313
+ """List archived versions for a file.
314
+
315
+ \b
316
+ NAME_OR_ID is a file path, a file name matched in the current
317
+ directory, or a content ID (cont_...) which is resolved directly.
318
+
319
+ \b
320
+ Examples:
321
+ unique-cli versions report.pdf
322
+ unique-cli versions /Reports/Q1/report.pdf
323
+ unique-cli versions cont_abc123 --take 10
324
+ """
325
+ click.echo(cmd_versions(LazyState.get(ctx), name_or_id, skip=skip, take=take))
326
+
327
+
328
+ @main.command(name="restore-version")
329
+ @click.argument("content_version_id")
330
+ @click.pass_context
331
+ def restore_version(ctx: click.Context, content_version_id: str) -> None:
332
+ """Restore a file from a content version ID.
333
+
334
+ \b
335
+ CONTENT_VERSION_ID is returned by `unique-cli versions`.
336
+
337
+ \b
338
+ Examples:
339
+ unique-cli restore-version cver_abc123
340
+ """
341
+ click.echo(cmd_restore_version(LazyState.get(ctx), content_version_id))
342
+
343
+
291
344
  @main.command()
292
345
  @click.argument("name_or_id")
293
346
  @click.argument("local_dest", required=False, default=None)
@@ -296,8 +349,8 @@ def download(ctx: click.Context, name_or_id: str, local_dest: str | None) -> Non
296
349
  """Download a file to your local machine.
297
350
 
298
351
  \b
299
- NAME_OR_ID is a file name (matched in the current directory) or
300
- a content ID (cont_...) which is resolved directly.
352
+ NAME_OR_ID is a file path, a file name matched in the current
353
+ directory, or a content ID (cont_...) which is resolved directly.
301
354
 
302
355
  \b
303
356
  LOCAL_DEST is an optional path (directory or file) to save to.
@@ -306,6 +359,7 @@ def download(ctx: click.Context, name_or_id: str, local_dest: str | None) -> Non
306
359
  \b
307
360
  Examples:
308
361
  unique-cli download annual.pdf
362
+ unique-cli download /Reports/Q1/annual.pdf
309
363
  unique-cli download annual.pdf ./downloads/
310
364
  unique-cli download cont_abc123 ~/Desktop/
311
365
  """
@@ -331,10 +385,12 @@ def cite(
331
385
  \b
332
386
  Registers [filesourceN] markers for pages you referenced in your answer.
333
387
  Does NOT read or extract the file — use your own tools for that.
388
+ NAME_OR_ID can be a file path, current-directory file name, or content ID.
334
389
 
335
390
  \b
336
391
  Examples:
337
392
  unique-cli cite report.pdf --pages 3,5,7
393
+ unique-cli cite /Reports/Q1/report.pdf --pages 3,5,7
338
394
  unique-cli cite cont_abc123 --pages 1-4
339
395
  """
340
396
  click.echo(cmd_cite_file(LazyState.get(ctx), name_or_id, pages))
@@ -442,15 +498,16 @@ def dynamic_frontend_list(ctx: click.Context, output_json: bool) -> None:
442
498
  @click.argument("name_or_id")
443
499
  @click.pass_context
444
500
  def rm(ctx: click.Context, name_or_id: str) -> None:
445
- """Delete a file by name or content ID.
501
+ """Delete a file by path, name, or content ID.
446
502
 
447
503
  \b
448
- NAME_OR_ID is a file name (matched in the current directory) or
449
- a content ID (cont_...).
504
+ NAME_OR_ID is a file path, a file name matched in the current
505
+ directory, or a content ID (cont_...).
450
506
 
451
507
  \b
452
508
  Examples:
453
509
  unique-cli rm report.pdf
510
+ unique-cli rm /Reports/Q1/report.pdf
454
511
  unique-cli rm cont_abc123
455
512
  """
456
513
  click.echo(cmd_rm(LazyState.get(ctx), name_or_id))
@@ -465,11 +522,12 @@ def mv(ctx: click.Context, old_name: str, new_name: str) -> None:
465
522
 
466
523
  \b
467
524
  Changes the file's display title without changing its content ID
468
- or location. OLD_NAME can be a file name or content ID.
525
+ or location. OLD_NAME can be a file path, file name, or content ID.
469
526
 
470
527
  \b
471
528
  Examples:
472
529
  unique-cli mv annual.pdf annual-2025.pdf
530
+ unique-cli mv /Reports/Q1/annual.pdf annual-2025.pdf
473
531
  unique-cli mv cont_abc123 "New Title.pdf"
474
532
  """
475
533
  click.echo(cmd_mv_file(LazyState.get(ctx), old_name, new_name))
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import mimetypes
6
6
  import shutil
7
+ from collections.abc import Mapping, Sequence
7
8
  from pathlib import Path
8
9
  from typing import Any
9
10
 
@@ -13,32 +14,113 @@ from unique_sdk.cli.state import ShellState
13
14
  from unique_sdk.utils.file_io import download_content, upload_file
14
15
 
15
16
 
17
+ def _normalize_unique_file_path(cwd: str, path: str) -> str:
18
+ """Normalize a Unique file path without allowing traversal above root."""
19
+ raw_parts = (
20
+ path.split("/") if path.startswith("/") else [*cwd.split("/"), *path.split("/")]
21
+ )
22
+ parts: list[str] = []
23
+ for part in raw_parts:
24
+ if part in ("", "."):
25
+ continue
26
+ if part == "..":
27
+ if not parts:
28
+ raise ValueError(f"File path escapes root: {path}")
29
+ parts.pop()
30
+ continue
31
+ parts.append(part)
32
+ return "/" + "/".join(parts)
33
+
34
+
16
35
  def _resolve_content_id(state: ShellState, name_or_id: str) -> tuple[str, str]:
17
36
  """Resolve a file name or content ID to (content_id, display_name).
18
37
 
19
- Accepts either a content ID (cont_...) or a file name/path.
38
+ Accepts a content ID (cont_...), a file name in the current folder,
39
+ or an absolute/relative Unique file path.
20
40
  """
21
41
  if name_or_id.startswith("cont_"):
42
+ if not state.is_content_within_workspace(name_or_id):
43
+ raise ValueError("permission denied (outside workspace scope)")
22
44
  return name_or_id, name_or_id
23
45
 
46
+ lookup_name = name_or_id
24
47
  scope_id = state.scope_id
48
+ if "/" in name_or_id:
49
+ unique_path = _normalize_unique_file_path(state.cwd, name_or_id)
50
+ folder_path, lookup_name = unique_path.rsplit("/", 1)
51
+ if not lookup_name:
52
+ raise ValueError(f"File path must include a file name: {name_or_id}")
53
+ if not folder_path:
54
+ folder_path = "/"
55
+ if not state.is_folder_target_within_workspace(folder_path):
56
+ raise ValueError("permission denied (outside workspace scope)")
57
+
58
+ info = unique_sdk.Folder.get_info(
59
+ user_id=state.config.user_id,
60
+ company_id=state.config.company_id,
61
+ folderPath=folder_path,
62
+ )
63
+ scope_id = info.get("id")
64
+ if not scope_id:
65
+ raise ValueError(f"folder not found: {folder_path}")
66
+ elif not state.is_within_workspace():
67
+ raise ValueError("permission denied (outside workspace scope)")
68
+
25
69
  params: dict[str, Any] = {}
26
70
  if scope_id:
27
71
  params["parentId"] = scope_id
28
72
 
29
- result = unique_sdk.Content.get_infos(
30
- user_id=state.config.user_id,
31
- company_id=state.config.company_id,
32
- **params,
33
- )
34
- for info in result.get("contentInfos", []):
35
- title = info.get("title") or info.get("key") or ""
36
- if title == name_or_id:
37
- return info["id"], title
73
+ take = 100
74
+ skip = 0
75
+ while True:
76
+ result = unique_sdk.Content.get_infos(
77
+ user_id=state.config.user_id,
78
+ company_id=state.config.company_id,
79
+ skip=skip,
80
+ take=take,
81
+ **params,
82
+ )
83
+ content_infos = result.get("contentInfos", [])
84
+ if not content_infos:
85
+ break
86
+
87
+ for info in content_infos:
88
+ title = info.get("title") or ""
89
+ key = info.get("key") or ""
90
+ if lookup_name in {title, key}:
91
+ return info["id"], title or key
92
+
93
+ skip += len(content_infos)
38
94
 
39
95
  raise ValueError(f"File not found: {name_or_id}")
40
96
 
41
97
 
98
+ def _format_version_value(value: Any) -> str:
99
+ if value is None:
100
+ return ""
101
+ return str(value)
102
+
103
+
104
+ def _format_content_versions(versions: Sequence[Mapping[str, Any]]) -> str:
105
+ if not versions:
106
+ return "No versions found."
107
+
108
+ lines = ["VERSION VERSION_ID ARCHIVED_AT REASON TITLE"]
109
+ for version in versions:
110
+ lines.append(
111
+ " ".join(
112
+ [
113
+ _format_version_value(version.get("versionNumber")),
114
+ _format_version_value(version.get("id")),
115
+ _format_version_value(version.get("archivedAt")),
116
+ _format_version_value(version.get("reason")),
117
+ _format_version_value(version.get("title") or version.get("key")),
118
+ ]
119
+ )
120
+ )
121
+ return "\n".join(lines)
122
+
123
+
42
124
  def _resolve_upload_destination(
43
125
  state: ShellState,
44
126
  local_filename: str,
@@ -144,6 +226,7 @@ def cmd_upload(
144
226
  displayed_filename=display_name,
145
227
  mime_type=mime_type,
146
228
  scope_or_unique_path=scope_id,
229
+ versioning_enabled=True,
147
230
  )
148
231
 
149
232
  content_id = result.id if hasattr(result, "id") else "?"
@@ -158,6 +241,49 @@ def cmd_upload(
158
241
  return f"upload: {e}"
159
242
 
160
243
 
244
+ def cmd_versions(
245
+ state: ShellState,
246
+ name_or_id: str,
247
+ skip: int | None = None,
248
+ take: int | None = None,
249
+ ) -> str:
250
+ """List archived versions for a file by name or content ID."""
251
+ try:
252
+ content_id, display_name = _resolve_content_id(state, name_or_id)
253
+ params: dict[str, Any] = {"contentId": content_id}
254
+ if skip is not None:
255
+ params["skip"] = skip
256
+ if take is not None:
257
+ params["take"] = take
258
+
259
+ result = unique_sdk.Content.versions(
260
+ user_id=state.config.user_id,
261
+ company_id=state.config.company_id,
262
+ **params,
263
+ )
264
+ data = result.get("data", [])
265
+ return f"Versions for {display_name} ({content_id}):\n{_format_content_versions(data)}"
266
+ except (ValueError, unique_sdk.APIError) as e:
267
+ return f"versions: {e}"
268
+
269
+
270
+ def cmd_restore_version(state: ShellState, content_version_id: str) -> str:
271
+ """Restore a file from an archived content version ID."""
272
+ if not state.is_within_workspace():
273
+ return "restore-version: permission denied (outside workspace scope)"
274
+ try:
275
+ result = unique_sdk.Content.restore_version(
276
+ user_id=state.config.user_id,
277
+ company_id=state.config.company_id,
278
+ contentVersionId=content_version_id,
279
+ )
280
+ title = result.get("title") or result.get("key") or result.get("id", "?")
281
+ content_id = result.get("id", "?")
282
+ return f"Restored: {title} ({content_id}) from version {content_version_id}"
283
+ except (ValueError, unique_sdk.APIError) as e:
284
+ return f"restore-version: {e}"
285
+
286
+
161
287
  def cmd_download(
162
288
  state: ShellState,
163
289
  name_or_id: str,
@@ -16,7 +16,14 @@ from unique_sdk.cli.commands.elicitation import (
16
16
  cmd_elicit_respond,
17
17
  cmd_elicit_wait,
18
18
  )
19
- from unique_sdk.cli.commands.files import cmd_download, cmd_mv_file, cmd_rm, cmd_upload
19
+ from unique_sdk.cli.commands.files import (
20
+ cmd_download,
21
+ cmd_mv_file,
22
+ cmd_restore_version,
23
+ cmd_rm,
24
+ cmd_upload,
25
+ cmd_versions,
26
+ )
20
27
  from unique_sdk.cli.commands.folders import cmd_mkdir, cmd_mvdir, cmd_rmdir
21
28
  from unique_sdk.cli.commands.mcp import cmd_mcp
22
29
  from unique_sdk.cli.commands.navigation import cmd_cd, cmd_ls, cmd_pwd
@@ -35,7 +42,7 @@ OVERVIEW_HELP = textwrap.dedent("""\
35
42
 
36
43
  Navigate the knowledge base like a Linux filesystem. Folders are
37
44
  identified by name, path, or scope ID. Files are identified by
38
- name or content ID.
45
+ name, path, or content ID.
39
46
 
40
47
  Navigation:
41
48
  pwd Print current working directory
@@ -48,11 +55,13 @@ OVERVIEW_HELP = textwrap.dedent("""\
48
55
  mvdir <old> <new> Rename a folder
49
56
 
50
57
  File operations:
51
- upload <local> [name] Upload a local file
52
- download <name|id> [dest] Download a file to local machine
53
- rm <name|id> Delete a file
54
- mv <old> <new> Rename a file
55
- cite <name|id> [--pages] Declare page citations for a file
58
+ upload <local> [name] Upload a local file with versioning
59
+ versions <name|path|id> List archived file versions
60
+ restore-version <ver_id> Restore a file from a version
61
+ download <name|path|id> [dest] Download a file to local machine
62
+ rm <name|path|id> Delete a file
63
+ mv <old|path|id> <new> Rename a file
64
+ cite <name|path|id> [--pages] Declare page citations for a file
56
65
 
57
66
  Search:
58
67
  search <query> [options] Combined search (vector + full-text)
@@ -292,13 +301,14 @@ class UniqueShell(cmd.Cmd):
292
301
  # -- File operations --
293
302
 
294
303
  def do_upload(self, arg: str) -> None:
295
- """Upload a local file (works like Linux cp).
304
+ """Upload a local file with versioning enabled (works like Linux cp).
296
305
 
297
306
  Usage: upload <local_path> [destination]
298
307
 
299
- Uploads a file from your local machine. The destination argument
300
- works like cp -- it can be a folder, a new filename, or both.
301
- MIME type is auto-detected from the file extension.
308
+ Uploads a file from your local machine with immutable versioning
309
+ enabled. The destination argument works like cp -- it can be a
310
+ folder, a new filename, or both. MIME type is auto-detected from
311
+ the file extension.
302
312
 
303
313
  Destination formats:
304
314
  (omitted) Upload to current dir, keep original name
@@ -330,17 +340,78 @@ class UniqueShell(cmd.Cmd):
330
340
  destination = parts[1] if len(parts) > 1 else None
331
341
  self._print(cmd_upload(self.state, local_path, destination))
332
342
 
343
+ def do_versions(self, arg: str) -> None:
344
+ """List archived versions for a file.
345
+
346
+ Usage: versions <name|path|content_id> [--skip N] [--take N]
347
+
348
+ Lists immutable versions for a file identified by its Unique
349
+ path, name (matched in the current directory), or content ID
350
+ (cont_...). Use the VERSION_ID column with restore-version.
351
+
352
+ Examples:
353
+ /Reports> versions annual.pdf
354
+ /Reports> versions /Reports/Q1/annual.pdf
355
+ /Reports> versions cont_mno345 --take 10
356
+ """
357
+ parts = shlex.split(arg)
358
+ if not parts:
359
+ self._print("Usage: versions <name|path|content_id> [--skip N] [--take N]")
360
+ return
361
+
362
+ name_or_id = parts[0]
363
+ skip: int | None = None
364
+ take: int | None = None
365
+ i = 1
366
+ while i < len(parts):
367
+ if parts[i] == "--skip" and i + 1 < len(parts):
368
+ try:
369
+ skip = int(parts[i + 1])
370
+ except ValueError:
371
+ self._print(f"Invalid --skip: {parts[i + 1]}")
372
+ return
373
+ i += 2
374
+ elif parts[i] == "--take" and i + 1 < len(parts):
375
+ try:
376
+ take = int(parts[i + 1])
377
+ except ValueError:
378
+ self._print(f"Invalid --take: {parts[i + 1]}")
379
+ return
380
+ i += 2
381
+ else:
382
+ self._print(f"Unknown option: {parts[i]}")
383
+ return
384
+
385
+ self._print(cmd_versions(self.state, name_or_id, skip=skip, take=take))
386
+
387
+ def do_restore_version(self, arg: str) -> None:
388
+ """Restore a file from a content version ID.
389
+
390
+ Usage: restore-version <content_version_id>
391
+
392
+ The content version ID is shown by the versions command.
393
+
394
+ Example:
395
+ /Reports> restore-version cver_abc123
396
+ Restored: annual.pdf (cont_mno345) from version cver_abc123
397
+ """
398
+ content_version_id = arg.strip()
399
+ if not content_version_id:
400
+ self._print("Usage: restore-version <content_version_id>")
401
+ return
402
+ self._print(cmd_restore_version(self.state, content_version_id))
403
+
333
404
  def do_download(self, arg: str) -> None:
334
405
  """Download a file from the platform to your local machine.
335
406
 
336
- Usage: download <name|content_id> [local_path]
407
+ Usage: download <name|path|content_id> [local_path]
337
408
 
338
- Downloads a file identified by its name (matched in the current
339
- directory) or content ID (cont_...). Saves to the specified local
340
- path, or the current working directory if omitted.
409
+ Downloads a file identified by its Unique path, name (matched in
410
+ the current directory), or content ID (cont_...). Saves to the
411
+ specified local path, or the current working directory if omitted.
341
412
 
342
413
  Arguments:
343
- name_or_id File name or content ID (cont_...)
414
+ name_or_id File path, file name, or content ID (cont_...)
344
415
  local_path Optional local directory or file path
345
416
 
346
417
  Examples:
@@ -350,12 +421,15 @@ class UniqueShell(cmd.Cmd):
350
421
  /Reports> download annual.pdf ./downloads/
351
422
  Downloaded: annual.pdf -> ./downloads/annual.pdf
352
423
 
424
+ /Reports> download /Reports/Q1/annual.pdf ./downloads/
425
+ Downloaded: annual.pdf -> ./downloads/annual.pdf
426
+
353
427
  /Reports> download cont_mno345 ~/Desktop/
354
428
  Downloaded: cont_mno345 -> ~/Desktop/cont_mno345
355
429
  """
356
430
  parts = shlex.split(arg)
357
431
  if not parts:
358
- self._print("Usage: download <name|content_id> [local_path]")
432
+ self._print("Usage: download <name|path|content_id> [local_path]")
359
433
  return
360
434
  name_or_id = parts[0]
361
435
  local_dest = parts[1] if len(parts) > 1 else None
@@ -364,17 +438,18 @@ class UniqueShell(cmd.Cmd):
364
438
  def do_cite(self, arg: str) -> None:
365
439
  """Declare page citations for a file.
366
440
 
367
- Usage: cite <name|content_id> [--pages RANGE]
441
+ Usage: cite <name|path|content_id> [--pages RANGE]
368
442
 
369
443
  Examples:
370
444
  /Reports> cite report.pdf --pages 3,5,7
445
+ /Reports> cite /Reports/Q1/report.pdf --pages 3,5,7
371
446
  /Reports> cite cont_abc123 --pages 1-4
372
447
  """
373
448
  from unique_sdk.cli.commands.cite_file import cmd_cite_file
374
449
 
375
450
  parts = shlex.split(arg)
376
451
  if not parts:
377
- self._print("Usage: cite <name|content_id> [--pages RANGE]")
452
+ self._print("Usage: cite <name|path|content_id> [--pages RANGE]")
378
453
  return
379
454
  pages: str | None = None
380
455
  positional: list[str] = []
@@ -391,7 +466,7 @@ class UniqueShell(cmd.Cmd):
391
466
  positional.append(token)
392
467
  index += 1
393
468
  if not positional:
394
- self._print("Usage: cite <name|content_id> [--pages RANGE]")
469
+ self._print("Usage: cite <name|path|content_id> [--pages RANGE]")
395
470
  return
396
471
  self._print(cmd_cite_file(self.state, positional[0], pages))
397
472
 
@@ -420,28 +495,31 @@ class UniqueShell(cmd.Cmd):
420
495
  def do_rm(self, arg: str) -> None:
421
496
  """Delete a file.
422
497
 
423
- Usage: rm <name|content_id>
498
+ Usage: rm <name|path|content_id>
424
499
 
425
- Permanently deletes a file by its name (matched in the current
426
- directory) or content ID.
500
+ Permanently deletes a file by its Unique path, name (matched in
501
+ the current directory), or content ID.
427
502
 
428
503
  Examples:
429
504
  /Reports> rm annual.pdf
430
505
  Deleted: annual.pdf (cont_mno345)
431
506
 
507
+ /Reports> rm /Reports/Q1/annual.pdf
508
+ Deleted: annual.pdf (cont_mno345)
509
+
432
510
  /Reports> rm cont_xyz789
433
511
  Deleted: cont_xyz789 (cont_xyz789)
434
512
  """
435
513
  name_or_id = arg.strip()
436
514
  if not name_or_id:
437
- self._print("Usage: rm <name|content_id>")
515
+ self._print("Usage: rm <name|path|content_id>")
438
516
  return
439
517
  self._print(cmd_rm(self.state, name_or_id))
440
518
 
441
519
  def do_mv(self, arg: str) -> None:
442
520
  """Rename a file.
443
521
 
444
- Usage: mv <old_name|content_id> <new_name>
522
+ Usage: mv <old_name|path|content_id> <new_name>
445
523
 
446
524
  Changes the file's display title. The content ID and location
447
525
  remain the same.
@@ -450,12 +528,15 @@ class UniqueShell(cmd.Cmd):
450
528
  /Reports> mv annual.pdf annual-2025.pdf
451
529
  Renamed: annual.pdf -> annual-2025.pdf
452
530
 
531
+ /Reports> mv /Reports/Q1/annual.pdf annual-2025.pdf
532
+ Renamed: annual.pdf -> annual-2025.pdf
533
+
453
534
  /Reports> mv cont_abc123 "New Title.pdf"
454
535
  Renamed: cont_abc123 -> New Title.pdf
455
536
  """
456
537
  parts = shlex.split(arg)
457
538
  if len(parts) != 2:
458
- self._print("Usage: mv <old_name|content_id> <new_name>")
539
+ self._print("Usage: mv <old_name|path|content_id> <new_name>")
459
540
  return
460
541
  self._print(cmd_mv_file(self.state, parts[0], parts[1]))
461
542
 
@@ -1103,6 +1184,9 @@ class UniqueShell(cmd.Cmd):
1103
1184
  return False
1104
1185
 
1105
1186
  def default(self, line: str) -> None:
1187
+ if line.startswith("restore-version"):
1188
+ self.do_restore_version(line[len("restore-version") :].strip())
1189
+ return
1106
1190
  self._print(
1107
1191
  f"Unknown command: {line.split()[0]}. Type 'help' for available commands."
1108
1192
  )
@@ -3,7 +3,7 @@ name: unique-cli-file-management
3
3
  description: >-
4
4
  Manage files and folders on the Unique AI Platform using the unique-cli
5
5
  command-line tool. Use when the user asks to upload, download, delete,
6
- rename, list, find, look for, or organize files and folders on Unique,
6
+ rename, list, find, restore versions, list versions, look for, or organize files and folders on Unique,
7
7
  or when working with scope IDs (scope_*) or content IDs (cont_*).
8
8
  IMPORTANT: When a user says they are "looking for a file" or wants to
9
9
  "find a file", they typically mean locating it within the Unique AI
@@ -50,11 +50,16 @@ unique-cli rmdir scope_abc123 -r
50
50
  # Rename a folder
51
51
  unique-cli mvdir Q1 "Q1-2025"
52
52
 
53
- # Upload a file (to current scope -- cd first or specify destination)
53
+ # Upload a file with versioning enabled (to current scope -- cd first or specify destination)
54
54
  unique-cli upload ./report.pdf
55
55
  unique-cli upload ./report.pdf /Reports/Q1/
56
56
  unique-cli upload ./data.csv scope_abc123
57
57
 
58
+ # List and restore file versions
59
+ unique-cli versions /Reports/Q1/report.pdf
60
+ unique-cli versions cont_abc123 --take 10
61
+ unique-cli restore-version cver_abc123
62
+
58
63
  # Download a file
59
64
  unique-cli download report.pdf ./local/
60
65
  unique-cli download cont_abc123 ~/Desktop/
@@ -81,10 +86,11 @@ unique-cli mv report.pdf "Annual Report 2025.pdf"
81
86
  | `..` | `..` | Parent directory |
82
87
  | `/` | `/` | Root |
83
88
  | Content ID | `cont_abc123` | File directly by ID |
89
+ | File path | `/Reports/Q1/report.pdf` | File in a folder |
84
90
 
85
91
  ## Upload Destination Resolution
86
92
 
87
- The `upload` destination works like Linux `cp`:
93
+ The `upload` command always enables immutable content versioning. It does not expose an unversioned upload mode. Its destination works like Linux `cp`:
88
94
 
89
95
  | Destination | Behavior |
90
96
  |-------------|----------|
@@ -116,6 +122,18 @@ unique-cli download "annual.pdf" ./downloads/
116
122
  unique-cli download cont_abc123 ./downloads/
117
123
  ```
118
124
 
125
+ ### Restore a previous file version
126
+
127
+ ```bash
128
+ # List versions for a file path, file name in the current folder, or content ID.
129
+ unique-cli versions /Reports/Q1/annual.pdf
130
+ unique-cli versions "annual.pdf"
131
+ unique-cli versions cont_abc123 --take 20
132
+
133
+ # Restore using the VERSION_ID shown by `versions`.
134
+ unique-cli restore-version cver_abc123
135
+ ```
136
+
119
137
  ### Create folder hierarchy and upload
120
138
 
121
139
  ```bash
@@ -106,6 +106,7 @@ def upload_file(
106
106
  ingestion_config: Content.IngestionConfig | None = None,
107
107
  metadata: dict[str, Any] | None = None,
108
108
  preview_pdf_path: str | None = None,
109
+ versioning_enabled: bool | None = None,
109
110
  ):
110
111
  """Upload *path_to_file* as a Unique :class:`Content`.
111
112
 
@@ -148,6 +149,9 @@ def upload_file(
148
149
  responsibility — there is no override kwarg, by design,
149
150
  so all callers land on the same ``${content.id}_pdfPreview``
150
151
  convention as the ingestion worker.
152
+ versioning_enabled: When ``True``, ask the platform to archive
153
+ previous blobs for the same content so they can be listed
154
+ and restored through the content version endpoints.
151
155
  """
152
156
  if not chat_id and not scope_or_unique_path:
153
157
  raise ValueError("chat_id or scope_or_unique_path must be provided")
@@ -177,6 +181,7 @@ def upload_file(
177
181
  },
178
182
  scopeId=scope_or_unique_path,
179
183
  chatId=chat_id,
184
+ versioningEnabled=versioning_enabled,
180
185
  )
181
186
 
182
187
  # Step 2 — PUT the original bytes to the SAS URL minted by Step 1.
@@ -229,6 +234,7 @@ def upload_file(
229
234
  },
230
235
  fileUrl=createdContent.readUrl,
231
236
  chatId=chat_id,
237
+ versioningEnabled=versioning_enabled,
232
238
  **preview_kwargs,
233
239
  )
234
240
  else:
@@ -246,6 +252,7 @@ def upload_file(
246
252
  },
247
253
  fileUrl=createdContent.readUrl,
248
254
  scopeId=scope_or_unique_path,
255
+ versioningEnabled=versioning_enabled,
249
256
  **preview_kwargs,
250
257
  )
251
258