pulsecrawl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. pulsecrawl/__init__.py +72 -0
  2. pulsecrawl/adapters/__init__.py +1 -0
  3. pulsecrawl/adapters/inbound/__init__.py +1 -0
  4. pulsecrawl/adapters/inbound/cli/__init__.py +1 -0
  5. pulsecrawl/adapters/inbound/cli/main.py +385 -0
  6. pulsecrawl/adapters/inbound/cli/parser.py +220 -0
  7. pulsecrawl/adapters/inbound/python/__init__.py +1 -0
  8. pulsecrawl/adapters/inbound/python/ephemeral.py +142 -0
  9. pulsecrawl/adapters/inbound/python/intent.py +103 -0
  10. pulsecrawl/adapters/inbound/python/local.py +175 -0
  11. pulsecrawl/adapters/inbound/python/resources.py +15 -0
  12. pulsecrawl/adapters/inbound/python/schedules.py +27 -0
  13. pulsecrawl/adapters/inbound/python/tasks.py +25 -0
  14. pulsecrawl/adapters/inbound/python/workspace.py +71 -0
  15. pulsecrawl/adapters/inbound/scheduler/__init__.py +1 -0
  16. pulsecrawl/adapters/inbound/scheduler/process.py +62 -0
  17. pulsecrawl/adapters/outbound/__init__.py +1 -0
  18. pulsecrawl/adapters/outbound/credentials/__init__.py +1 -0
  19. pulsecrawl/adapters/outbound/credentials/profiles.py +37 -0
  20. pulsecrawl/adapters/outbound/cron/__init__.py +1 -0
  21. pulsecrawl/adapters/outbound/cron/calculator.py +54 -0
  22. pulsecrawl/adapters/outbound/duckdb/__init__.py +1 -0
  23. pulsecrawl/adapters/outbound/duckdb/local_result.py +112 -0
  24. pulsecrawl/adapters/outbound/duckdb/query.py +271 -0
  25. pulsecrawl/adapters/outbound/filesystem/__init__.py +1 -0
  26. pulsecrawl/adapters/outbound/filesystem/atomic.py +119 -0
  27. pulsecrawl/adapters/outbound/filesystem/backup_io.py +34 -0
  28. pulsecrawl/adapters/outbound/filesystem/backups.py +196 -0
  29. pulsecrawl/adapters/outbound/filesystem/catalogs.py +46 -0
  30. pulsecrawl/adapters/outbound/filesystem/dataset_files.py +83 -0
  31. pulsecrawl/adapters/outbound/filesystem/doctor.py +106 -0
  32. pulsecrawl/adapters/outbound/filesystem/downloads.py +140 -0
  33. pulsecrawl/adapters/outbound/filesystem/drafts.py +51 -0
  34. pulsecrawl/adapters/outbound/filesystem/legacy_execution.py +156 -0
  35. pulsecrawl/adapters/outbound/filesystem/manifests.py +228 -0
  36. pulsecrawl/adapters/outbound/filesystem/pages.py +32 -0
  37. pulsecrawl/adapters/outbound/filesystem/publication.py +95 -0
  38. pulsecrawl/adapters/outbound/filesystem/retention.py +120 -0
  39. pulsecrawl/adapters/outbound/filesystem/windows_publication.py +66 -0
  40. pulsecrawl/adapters/outbound/filesystem/workspace.py +68 -0
  41. pulsecrawl/adapters/outbound/html/__init__.py +1 -0
  42. pulsecrawl/adapters/outbound/html/extraction/__init__.py +38 -0
  43. pulsecrawl/adapters/outbound/html/extraction/css.py +69 -0
  44. pulsecrawl/adapters/outbound/html/extraction/jsonld.py +123 -0
  45. pulsecrawl/adapters/outbound/html/extraction/meta.py +56 -0
  46. pulsecrawl/adapters/outbound/html/inspection.py +161 -0
  47. pulsecrawl/adapters/outbound/html/links.py +25 -0
  48. pulsecrawl/adapters/outbound/http/__init__.py +1 -0
  49. pulsecrawl/adapters/outbound/http/artifacts.py +104 -0
  50. pulsecrawl/adapters/outbound/http/client.py +194 -0
  51. pulsecrawl/adapters/outbound/http/constants.py +5 -0
  52. pulsecrawl/adapters/outbound/http/fetcher.py +273 -0
  53. pulsecrawl/adapters/outbound/http/resources/__init__.py +1 -0
  54. pulsecrawl/adapters/outbound/http/resources/base.py +6 -0
  55. pulsecrawl/adapters/outbound/http/resources/datasets.py +114 -0
  56. pulsecrawl/adapters/outbound/http/resources/executions.py +110 -0
  57. pulsecrawl/adapters/outbound/http/resources/fields.py +24 -0
  58. pulsecrawl/adapters/outbound/http/resources/jobs.py +119 -0
  59. pulsecrawl/adapters/outbound/http/resources/organizations.py +34 -0
  60. pulsecrawl/adapters/outbound/http/resources/schedules.py +29 -0
  61. pulsecrawl/adapters/outbound/http/resources/schemas.py +26 -0
  62. pulsecrawl/adapters/outbound/http/resources/templates.py +43 -0
  63. pulsecrawl/adapters/outbound/http/resources/urls.py +27 -0
  64. pulsecrawl/adapters/outbound/http/scope.py +31 -0
  65. pulsecrawl/adapters/outbound/http/workspace.py +40 -0
  66. pulsecrawl/adapters/outbound/memory/__init__.py +1 -0
  67. pulsecrawl/adapters/outbound/memory/crawling.py +215 -0
  68. pulsecrawl/adapters/outbound/memory/records.py +12 -0
  69. pulsecrawl/adapters/outbound/parquet/__init__.py +1 -0
  70. pulsecrawl/adapters/outbound/parquet/compaction.py +120 -0
  71. pulsecrawl/adapters/outbound/parquet/publication.py +151 -0
  72. pulsecrawl/adapters/outbound/sqlite/__init__.py +1 -0
  73. pulsecrawl/adapters/outbound/sqlite/clock.py +36 -0
  74. pulsecrawl/adapters/outbound/sqlite/crawling.py +305 -0
  75. pulsecrawl/adapters/outbound/sqlite/definitions.py +53 -0
  76. pulsecrawl/adapters/outbound/sqlite/executions.py +223 -0
  77. pulsecrawl/adapters/outbound/sqlite/health.py +46 -0
  78. pulsecrawl/adapters/outbound/sqlite/history.py +67 -0
  79. pulsecrawl/adapters/outbound/sqlite/leases.py +108 -0
  80. pulsecrawl/adapters/outbound/sqlite/maintenance_lease.py +77 -0
  81. pulsecrawl/adapters/outbound/sqlite/records.py +211 -0
  82. pulsecrawl/adapters/outbound/sqlite/scheduler_lease.py +46 -0
  83. pulsecrawl/adapters/outbound/sqlite/schedules.py +121 -0
  84. pulsecrawl/adapters/outbound/sqlite/state.py +365 -0
  85. pulsecrawl/application/__init__.py +1 -0
  86. pulsecrawl/application/crawling/__init__.py +1 -0
  87. pulsecrawl/application/crawling/admission.py +160 -0
  88. pulsecrawl/application/crawling/dispatch.py +152 -0
  89. pulsecrawl/application/crawling/engine.py +125 -0
  90. pulsecrawl/application/crawling/pages.py +291 -0
  91. pulsecrawl/application/crawling/records.py +50 -0
  92. pulsecrawl/application/crawling/robots.py +124 -0
  93. pulsecrawl/application/datasets/__init__.py +1 -0
  94. pulsecrawl/application/datasets/publication.py +24 -0
  95. pulsecrawl/application/datasets/service.py +169 -0
  96. pulsecrawl/application/executions/__init__.py +1 -0
  97. pulsecrawl/application/executions/service.py +310 -0
  98. pulsecrawl/application/ports/__init__.py +1 -0
  99. pulsecrawl/application/ports/cloud.py +23 -0
  100. pulsecrawl/application/ports/crawling.py +150 -0
  101. pulsecrawl/application/ports/workspace.py +98 -0
  102. pulsecrawl/application/scheduling/__init__.py +1 -0
  103. pulsecrawl/application/scheduling/service.py +126 -0
  104. pulsecrawl/application/synchronization/__init__.py +1 -0
  105. pulsecrawl/application/synchronization/profiles.py +110 -0
  106. pulsecrawl/application/synchronization/service.py +231 -0
  107. pulsecrawl/application/telemetry.py +46 -0
  108. pulsecrawl/application/templates/__init__.py +1 -0
  109. pulsecrawl/application/templates/authoring.py +398 -0
  110. pulsecrawl/application/templates/resolution.py +91 -0
  111. pulsecrawl/application/templates/validation.py +246 -0
  112. pulsecrawl/application/time.py +11 -0
  113. pulsecrawl/application/workspace/__init__.py +1 -0
  114. pulsecrawl/application/workspace/catalogs.py +154 -0
  115. pulsecrawl/application/workspace/jobs.py +240 -0
  116. pulsecrawl/application/workspace/maintenance.py +19 -0
  117. pulsecrawl/application/workspace/validation.py +117 -0
  118. pulsecrawl/bootstrap/__init__.py +1 -0
  119. pulsecrawl/bootstrap/authoring.py +46 -0
  120. pulsecrawl/bootstrap/cloud.py +124 -0
  121. pulsecrawl/bootstrap/crawling.py +135 -0
  122. pulsecrawl/bootstrap/datasets.py +107 -0
  123. pulsecrawl/bootstrap/ephemeral.py +46 -0
  124. pulsecrawl/bootstrap/maintenance.py +46 -0
  125. pulsecrawl/bootstrap/workspace.py +134 -0
  126. pulsecrawl/cli.py +9 -0
  127. pulsecrawl/client.py +8 -0
  128. pulsecrawl/cloud.py +6 -0
  129. pulsecrawl/core/__init__.py +37 -0
  130. pulsecrawl/core/adapters/__init__.py +0 -0
  131. pulsecrawl/core/adapters/aiohttp_fetcher.py +8 -0
  132. pulsecrawl/core/adapters/memory.py +8 -0
  133. pulsecrawl/core/coerce.py +8 -0
  134. pulsecrawl/core/engine.py +15 -0
  135. pulsecrawl/core/extraction/__init__.py +6 -0
  136. pulsecrawl/core/extraction/css.py +8 -0
  137. pulsecrawl/core/extraction/jsonld.py +8 -0
  138. pulsecrawl/core/extraction/meta.py +8 -0
  139. pulsecrawl/core/local.py +8 -0
  140. pulsecrawl/core/model.py +13 -0
  141. pulsecrawl/core/politeness.py +8 -0
  142. pulsecrawl/core/ports.py +8 -0
  143. pulsecrawl/core/robots.py +8 -0
  144. pulsecrawl/core/urls.py +4 -0
  145. pulsecrawl/domain/__init__.py +1 -0
  146. pulsecrawl/domain/crawling/__init__.py +1 -0
  147. pulsecrawl/domain/crawling/model.py +359 -0
  148. pulsecrawl/domain/crawling/politeness.py +19 -0
  149. pulsecrawl/domain/crawling/retry.py +25 -0
  150. pulsecrawl/domain/crawling/urls.py +134 -0
  151. pulsecrawl/domain/datasets/__init__.py +1 -0
  152. pulsecrawl/domain/datasets/identity.py +43 -0
  153. pulsecrawl/domain/errors.py +46 -0
  154. pulsecrawl/domain/executions/__init__.py +1 -0
  155. pulsecrawl/domain/executions/lifecycle.py +73 -0
  156. pulsecrawl/domain/scheduling/__init__.py +1 -0
  157. pulsecrawl/domain/scheduling/identity.py +8 -0
  158. pulsecrawl/domain/synchronization/__init__.py +1 -0
  159. pulsecrawl/domain/synchronization/planner.py +325 -0
  160. pulsecrawl/domain/synchronization/profiles.py +11 -0
  161. pulsecrawl/domain/templates/__init__.py +1 -0
  162. pulsecrawl/domain/templates/authoring.py +188 -0
  163. pulsecrawl/domain/templates/coercion.py +71 -0
  164. pulsecrawl/domain/templates/selection.py +44 -0
  165. pulsecrawl/domain/workspace/__init__.py +1 -0
  166. pulsecrawl/domain/workspace/definitions.py +136 -0
  167. pulsecrawl/errors.py +6 -0
  168. pulsecrawl/local.py +8 -0
  169. pulsecrawl/local_dataset.py +6 -0
  170. pulsecrawl/local_scheduler.py +28 -0
  171. pulsecrawl/observability.py +6 -0
  172. pulsecrawl/sqlite_state.py +10 -0
  173. pulsecrawl/template_authoring.py +6 -0
  174. pulsecrawl/workspace.py +8 -0
  175. pulsecrawl/workspace_maintenance.py +6 -0
  176. pulsecrawl/workspace_runtime.py +8 -0
  177. pulsecrawl-0.1.0.dist-info/METADATA +104 -0
  178. pulsecrawl-0.1.0.dist-info/RECORD +182 -0
  179. pulsecrawl-0.1.0.dist-info/WHEEL +5 -0
  180. pulsecrawl-0.1.0.dist-info/entry_points.txt +2 -0
  181. pulsecrawl-0.1.0.dist-info/licenses/LICENSE +661 -0
  182. pulsecrawl-0.1.0.dist-info/top_level.txt +1 -0
pulsecrawl/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """PulseCrawl public API. Heavy/optional adapters load only when requested."""
2
+
3
+ from importlib import import_module
4
+
5
+ _EXPORTS = {
6
+ "API_KEY_ENV": "pulsecrawl.client",
7
+ "BASE_URL_ENV": "pulsecrawl.client",
8
+ "DEFAULT_BASE_URL": "pulsecrawl.client",
9
+ "PulseCrawl": "pulsecrawl.client",
10
+ "DatasetCorruptionError": "pulsecrawl.errors",
11
+ "ExecutionTimeout": "pulsecrawl.errors",
12
+ "InsufficientStorageError": "pulsecrawl.errors",
13
+ "PulseCrawlError": "pulsecrawl.errors",
14
+ "QueryTimeoutError": "pulsecrawl.errors",
15
+ "WorkspaceBusyError": "pulsecrawl.errors",
16
+ "WorkspaceError": "pulsecrawl.errors",
17
+ "WorkspaceReadOnlyError": "pulsecrawl.errors",
18
+ "LocalResult": "pulsecrawl.local",
19
+ "crawl": "pulsecrawl.local",
20
+ "Workspace": "pulsecrawl.workspace_runtime",
21
+ "CloudProfile": "pulsecrawl.cloud",
22
+ "CloudProfiles": "pulsecrawl.cloud",
23
+ "CloudWorkspaceSync": "pulsecrawl.cloud",
24
+ "export_workspace_bundle": "pulsecrawl.cloud",
25
+ "PageInspection": "pulsecrawl.template_authoring",
26
+ "RepresentativePage": "pulsecrawl.template_authoring",
27
+ "TemplateDraft": "pulsecrawl.template_authoring",
28
+ "inspect_page": "pulsecrawl.template_authoring",
29
+ "validate_template_stack": "pulsecrawl.template_authoring",
30
+ }
31
+
32
+
33
+ def __getattr__(name):
34
+ if name not in _EXPORTS:
35
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
36
+ value = getattr(import_module(_EXPORTS[name]), name)
37
+ globals()[name] = value
38
+ return value
39
+
40
+
41
+ def __dir__():
42
+ return sorted(set(globals()) | set(_EXPORTS))
43
+
44
+
45
+ __all__ = [
46
+ "PulseCrawl",
47
+ "crawl",
48
+ "LocalResult",
49
+ "Workspace",
50
+ "CloudProfile",
51
+ "CloudProfiles",
52
+ "CloudWorkspaceSync",
53
+ "export_workspace_bundle",
54
+ "PulseCrawlError",
55
+ "ExecutionTimeout",
56
+ "WorkspaceError",
57
+ "WorkspaceReadOnlyError",
58
+ "WorkspaceBusyError",
59
+ "InsufficientStorageError",
60
+ "DatasetCorruptionError",
61
+ "QueryTimeoutError",
62
+ "DEFAULT_BASE_URL",
63
+ "BASE_URL_ENV",
64
+ "API_KEY_ENV",
65
+ "PageInspection",
66
+ "RepresentativePage",
67
+ "TemplateDraft",
68
+ "inspect_page",
69
+ "validate_template_stack",
70
+ ]
71
+
72
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """PulseCrawl adapters."""
@@ -0,0 +1 @@
1
+ """PulseCrawl adapters inbound."""
@@ -0,0 +1 @@
1
+ """PulseCrawl adapters inbound cli."""
@@ -0,0 +1,385 @@
1
+ """Command-line interface for a self-contained local workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import sqlite3
10
+ import sys
11
+ from typing import Any
12
+
13
+ from pulsecrawl.domain.errors import (
14
+ DatasetCorruptionError,
15
+ InsufficientStorageError,
16
+ QueryTimeoutError,
17
+ WorkspaceBusyError,
18
+ WorkspaceError,
19
+ WorkspaceReadOnlyError,
20
+ PulseCrawlError,
21
+ )
22
+ from pulsecrawl.adapters.outbound.http.client import API_KEY_ENV
23
+ from pulsecrawl.bootstrap.cloud import (
24
+ CloudProfiles,
25
+ CloudWorkspaceSync,
26
+ export_workspace_bundle,
27
+ )
28
+ from pulsecrawl.adapters.inbound.python.workspace import Workspace
29
+ from pulsecrawl.bootstrap.maintenance import WorkspaceMaintenance
30
+ from pulsecrawl.adapters.inbound.cli.parser import build_parser, _UsageError
31
+
32
+
33
+ def _print(value: Any) -> None:
34
+ print(json.dumps(value, indent=2, sort_keys=True, default=str))
35
+
36
+
37
+ def _workspace(path: str) -> Workspace:
38
+ return Workspace(Path(path))
39
+
40
+
41
+ def _json_argument(value: str, *, expected: type) -> Any:
42
+ """Parse compact JSON or ``@path`` without accepting unbounded input."""
43
+ if value.startswith("@"):
44
+ path = Path(value[1:])
45
+ if path.stat().st_size > 1024 * 1024:
46
+ raise ValueError("JSON definition files must not exceed 1 MiB")
47
+ encoded = path.read_text(encoding="utf-8")
48
+ else:
49
+ encoded = value
50
+ if len(encoded.encode("utf-8")) > 1024 * 1024:
51
+ raise ValueError("JSON definitions must not exceed 1 MiB")
52
+ result = json.loads(encoded)
53
+ if not isinstance(result, expected):
54
+ raise ValueError(f"JSON definition must be a {expected.__name__}")
55
+ return result
56
+
57
+
58
+ def _main(argv: list[str] | None = None) -> int:
59
+ args = build_parser().parse_args(argv)
60
+ if args.command == "cloud":
61
+ profiles = CloudProfiles()
62
+ if args.cloud_command == "profiles":
63
+ _print([profile.__dict__ for profile in profiles.list()])
64
+ return 0
65
+ if args.cloud_command == "connect":
66
+ environment_api_key = os.environ.get(API_KEY_ENV)
67
+ api_key = environment_api_key
68
+ if not api_key:
69
+ api_key = getpass.getpass("PulseCrawl API key: ")
70
+ profile = profiles.connect(
71
+ args.profile,
72
+ base_url=args.base_url,
73
+ api_key=api_key,
74
+ store_secret=environment_api_key is None,
75
+ )
76
+ _print(profile.__dict__)
77
+ return 0
78
+ if args.cloud_command == "disconnect":
79
+ profiles.disconnect(args.profile)
80
+ _print({"profile": args.profile, "connected": False})
81
+ return 0
82
+ if args.cloud_command == "whoami":
83
+ with profiles.client(args.profile) as client:
84
+ _print(client.whoami())
85
+ return 0
86
+ if args.cloud_command == "pull":
87
+ with profiles.client(args.profile) as client:
88
+ paths = client.datasets.download_files(
89
+ args.dataset_id,
90
+ args.destination,
91
+ overwrite=args.overwrite,
92
+ )
93
+ _print(
94
+ {"datasetId": args.dataset_id, "files": [str(path) for path in paths]}
95
+ )
96
+ return 0
97
+ with _workspace(args.workspace) as workspace:
98
+ bundle = export_workspace_bundle(workspace)
99
+ if args.cloud_command == "bundle":
100
+ _print(bundle)
101
+ return 0
102
+ with profiles.client(args.profile) as client:
103
+ sync = CloudWorkspaceSync(client)
104
+ if args.cloud_command == "plan":
105
+ _print(sync.plan(bundle))
106
+ else:
107
+ if args.cloud_command == "run":
108
+ sync.require_scopes({"executions:run"})
109
+ mapping = sync.apply(bundle)
110
+ if args.cloud_command == "run":
111
+ cloud_job_id = mapping["jobIds"].get(args.job_id)
112
+ if not cloud_job_id:
113
+ raise KeyError(f"unknown local job: {args.job_id}")
114
+ mapping["execution"] = client.jobs.run(cloud_job_id)
115
+ _print(mapping)
116
+ return 0
117
+ if args.command == "init":
118
+ workspace = Workspace.init(args.path, name=args.name)
119
+ try:
120
+ _print({"workspace": str(workspace.path), **workspace.validate()})
121
+ finally:
122
+ workspace.close()
123
+ return 0
124
+ if args.command == "backup" and args.backup_command == "verify":
125
+ _print(WorkspaceMaintenance.verify_backup(args.source))
126
+ return 0
127
+ if args.command == "backup" and args.backup_command == "restore":
128
+ _print(WorkspaceMaintenance.restore(args.source, args.destination))
129
+ return 0
130
+
131
+ with _workspace(args.workspace) as workspace:
132
+ if args.command == "validate":
133
+ result = workspace.validate()
134
+ _print(result)
135
+ return 0 if result["valid"] else 1
136
+ if args.command == "doctor":
137
+ result = workspace.maintenance.doctor(repair_safe=args.repair_safe)
138
+ _print(result)
139
+ return 0 if result["valid"] else 1
140
+ if args.command == "health":
141
+ result = workspace.health(readiness=args.readiness)
142
+ _print(result)
143
+ return 0 if result["healthy"] else 1
144
+ if args.command == "backup":
145
+ _print(
146
+ workspace.maintenance.backup(
147
+ args.destination,
148
+ include_raw_pages=not args.exclude_raw_pages,
149
+ )
150
+ )
151
+ return 0
152
+ if args.command == "field":
153
+ if args.field_command == "list":
154
+ _print(workspace.fields.list())
155
+ elif args.field_command == "show":
156
+ _print(workspace.fields.get(args.field_id))
157
+ else:
158
+ _print(
159
+ workspace.fields.create(
160
+ args.name,
161
+ args.type,
162
+ field_id=args.id,
163
+ description=args.description,
164
+ )
165
+ )
166
+ return 0
167
+ if args.command == "schema":
168
+ if args.schema_command == "list":
169
+ _print(workspace.schemas.list())
170
+ elif args.schema_command == "show":
171
+ _print(workspace.schemas.get(args.schema_id))
172
+ else:
173
+ _print(
174
+ workspace.schemas.create(
175
+ args.name, args.field_ids, schema_id=args.id
176
+ )
177
+ )
178
+ return 0
179
+ if args.command == "template":
180
+ if args.template_command == "list":
181
+ _print(workspace.templates.list())
182
+ elif args.template_command == "show":
183
+ _print(workspace.templates.get(args.template_id))
184
+ else:
185
+ _print(
186
+ workspace.templates.create(
187
+ args.name,
188
+ output_schema_id=args.schema_id,
189
+ engine=args.engine,
190
+ mappings=_json_argument(args.mappings, expected=dict),
191
+ template_id=args.id,
192
+ )
193
+ )
194
+ return 0
195
+ if args.command == "job":
196
+ if args.job_command == "list":
197
+ _print(workspace.jobs.list())
198
+ elif args.job_command == "show":
199
+ _print(workspace.jobs.get(args.job_id))
200
+ elif args.job_command == "create":
201
+ _print(
202
+ workspace.jobs.create(
203
+ args.name,
204
+ seeds=args.seed,
205
+ dataset_id=args.dataset_id,
206
+ templates=_json_argument(args.templates, expected=list),
207
+ system_templates=(
208
+ _json_argument(args.system_templates, expected=list)
209
+ if args.system_templates
210
+ else ()
211
+ ),
212
+ scope=(
213
+ _json_argument(args.scope, expected=dict)
214
+ if args.scope
215
+ else None
216
+ ),
217
+ politeness=(
218
+ _json_argument(args.politeness, expected=dict)
219
+ if args.politeness
220
+ else None
221
+ ),
222
+ store_raw_pages=args.store_raw_pages,
223
+ )
224
+ )
225
+ else:
226
+ _print(workspace.jobs.run(args.job_id))
227
+ return 0
228
+ if args.command == "execution":
229
+ if args.execution_command == "list":
230
+ _print(workspace.executions.list(args.job_id))
231
+ elif args.execution_command == "show":
232
+ _print(workspace.executions.get(args.execution_id))
233
+ elif args.execution_command == "cancel":
234
+ _print(workspace.executions.cancel(args.execution_id, force=args.force))
235
+ elif args.execution_command == "urls":
236
+ _print(
237
+ workspace.executions.urls(
238
+ args.execution_id,
239
+ cursor=args.cursor,
240
+ limit=args.limit,
241
+ newness=args.newness,
242
+ host=args.host,
243
+ url_contains=args.contains,
244
+ )
245
+ )
246
+ else:
247
+ _print(workspace.executions.resume(args.execution_id))
248
+ return 0
249
+ if args.command == "dataset":
250
+ if args.dataset_command == "list":
251
+ _print(workspace.datasets.list())
252
+ elif args.dataset_command == "show":
253
+ _print(workspace.datasets.get(args.dataset_id))
254
+ elif args.dataset_command == "create":
255
+ _print(
256
+ workspace.datasets.create(
257
+ args.name,
258
+ fields=(
259
+ _json_argument(args.fields, expected=list)
260
+ if args.fields
261
+ else None
262
+ ),
263
+ schema_id=args.schema_id,
264
+ mode=args.mode,
265
+ identity_field=args.identity_field,
266
+ )
267
+ )
268
+ elif args.dataset_command == "files":
269
+ _print(workspace.datasets.files(args.dataset_id, version=args.version))
270
+ elif args.dataset_command == "query":
271
+ _print(
272
+ workspace.datasets.query(
273
+ args.dataset_id,
274
+ args.sql,
275
+ version=args.version,
276
+ row_cap=args.row_cap,
277
+ timeout_s=args.timeout,
278
+ )
279
+ )
280
+ elif args.dataset_command == "urls":
281
+ _print(
282
+ workspace.datasets.urls(
283
+ args.dataset_id,
284
+ cursor=args.cursor,
285
+ limit=args.limit,
286
+ newness=args.newness,
287
+ host=args.host,
288
+ url_contains=args.contains,
289
+ )
290
+ )
291
+ elif args.dataset_command == "compact":
292
+ _print(
293
+ {
294
+ "partitionsCompacted": workspace.datasets.compact(
295
+ args.dataset_id, minimum_files=args.minimum_files
296
+ )
297
+ }
298
+ )
299
+ else:
300
+ _print(
301
+ workspace.datasets.prune(
302
+ args.dataset_id,
303
+ keep_versions=args.keep_versions,
304
+ older_than_days=args.older_than_days,
305
+ dry_run=not args.apply,
306
+ )
307
+ )
308
+ return 0
309
+ if args.command == "schedule":
310
+ if args.schedule_command == "list":
311
+ _print(workspace.schedules.list())
312
+ elif args.schedule_command == "status":
313
+ _print(workspace.schedules.status())
314
+ elif args.schedule_command == "set":
315
+ _print(
316
+ workspace.schedules.set(
317
+ args.job_id, args.cron, timezone_name=args.timezone
318
+ )
319
+ )
320
+ elif args.schedule_command == "pause":
321
+ _print(workspace.schedules.pause(args.job_id))
322
+ elif args.schedule_command == "resume":
323
+ _print(workspace.schedules.resume(args.job_id))
324
+ elif args.schedule_command == "run-due":
325
+ _print(workspace.schedules.run_due())
326
+ else:
327
+ workspace.schedules.serve(poll_seconds=args.poll_seconds)
328
+ return 0
329
+ return 2
330
+
331
+
332
+ def main(argv: list[str] | None = None) -> int:
333
+ """Run the CLI with a stable, machine-readable failure envelope."""
334
+ error: Exception
335
+ try:
336
+ return _main(argv)
337
+ except _UsageError as exc:
338
+ error = exc
339
+ code, exit_code = "usage", 2
340
+ except (KeyError, FileNotFoundError) as exc:
341
+ error = exc
342
+ code, exit_code = "not_found", 3
343
+ except (ValueError, TypeError) as exc:
344
+ error = exc
345
+ code, exit_code = "invalid_input", 4
346
+ except QueryTimeoutError as exc:
347
+ error = exc
348
+ code, exit_code = "timeout", 6
349
+ except (
350
+ DatasetCorruptionError,
351
+ InsufficientStorageError,
352
+ WorkspaceBusyError,
353
+ WorkspaceError,
354
+ WorkspaceReadOnlyError,
355
+ sqlite3.DatabaseError,
356
+ ) as exc:
357
+ error = exc
358
+ code, exit_code = "workspace_error", 5
359
+ except ImportError as exc:
360
+ error = exc
361
+ code, exit_code = "missing_dependency", 7
362
+ except PulseCrawlError as exc:
363
+ error = exc
364
+ code, exit_code = "cloud_error", 8
365
+ except Exception as exc: # pragma: no cover - last-resort process boundary
366
+ error = exc
367
+ code, exit_code = "internal_error", 1
368
+ print(
369
+ json.dumps(
370
+ {
371
+ "error": {
372
+ "code": code,
373
+ "type": type(error).__name__,
374
+ "message": str(error).strip("'"),
375
+ }
376
+ },
377
+ sort_keys=True,
378
+ ),
379
+ file=sys.stderr,
380
+ )
381
+ return exit_code
382
+
383
+
384
+ if __name__ == "__main__": # pragma: no cover
385
+ raise SystemExit(main())
@@ -0,0 +1,220 @@
1
+ """Command-line interface for a self-contained local workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+
8
+ class _UsageError(Exception):
9
+ pass
10
+
11
+
12
+ class _ArgumentParser(argparse.ArgumentParser):
13
+ def error(self, message: str) -> None:
14
+ raise _UsageError(message)
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = _ArgumentParser(prog="pulsecrawl")
19
+ parser.add_argument("--workspace", "-w", default=".")
20
+ commands = parser.add_subparsers(dest="command", required=True)
21
+
22
+ init = commands.add_parser("init", help="create a portable workspace")
23
+ init.add_argument("path", nargs="?", default=".")
24
+ init.add_argument("--name")
25
+ commands.add_parser("validate", help="validate definitions and active manifests")
26
+ doctor = commands.add_parser("doctor", help="check workspace durability and state")
27
+ doctor.add_argument("--repair-safe", action="store_true")
28
+ health = commands.add_parser("health", help="report liveness or readiness")
29
+ health.add_argument("--readiness", action="store_true")
30
+
31
+ field = commands.add_parser("field")
32
+ field_commands = field.add_subparsers(dest="field_command", required=True)
33
+ field_commands.add_parser("list")
34
+ field_show = field_commands.add_parser("show")
35
+ field_show.add_argument("field_id")
36
+ field_create = field_commands.add_parser("create")
37
+ field_create.add_argument("name")
38
+ field_create.add_argument(
39
+ "type", choices=("string", "number", "boolean", "datetime", "url")
40
+ )
41
+ field_create.add_argument("--id")
42
+ field_create.add_argument("--description", default="")
43
+
44
+ schema = commands.add_parser("schema")
45
+ schema_commands = schema.add_subparsers(dest="schema_command", required=True)
46
+ schema_commands.add_parser("list")
47
+ schema_show = schema_commands.add_parser("show")
48
+ schema_show.add_argument("schema_id")
49
+ schema_create = schema_commands.add_parser("create")
50
+ schema_create.add_argument("name")
51
+ schema_create.add_argument("field_ids", nargs="+")
52
+ schema_create.add_argument("--id")
53
+
54
+ template = commands.add_parser("template")
55
+ template_commands = template.add_subparsers(dest="template_command", required=True)
56
+ template_commands.add_parser("list")
57
+ template_show = template_commands.add_parser("show")
58
+ template_show.add_argument("template_id")
59
+ template_create = template_commands.add_parser("create")
60
+ template_create.add_argument("name")
61
+ template_create.add_argument("--schema-id", required=True)
62
+ template_create.add_argument(
63
+ "--engine", choices=("css", "jsonld", "meta"), required=True
64
+ )
65
+ template_create.add_argument(
66
+ "--mappings", required=True, help="JSON object or @file"
67
+ )
68
+ template_create.add_argument("--id")
69
+
70
+ job = commands.add_parser("job")
71
+ job_commands = job.add_subparsers(dest="job_command", required=True)
72
+ job_commands.add_parser("list")
73
+ job_show = job_commands.add_parser("show")
74
+ job_show.add_argument("job_id")
75
+ job_create = job_commands.add_parser("create")
76
+ job_create.add_argument("name")
77
+ job_create.add_argument("--dataset-id", required=True)
78
+ job_create.add_argument("--seed", action="append", required=True)
79
+ job_create.add_argument("--templates", required=True, help="JSON array or @file")
80
+ job_create.add_argument("--system-templates", help="JSON array or @file")
81
+ job_create.add_argument("--scope", help="JSON object or @file")
82
+ job_create.add_argument("--politeness", help="JSON object or @file")
83
+ job_create.add_argument("--store-raw-pages", action="store_true")
84
+ job_run = job_commands.add_parser("run")
85
+ job_run.add_argument("job_id")
86
+
87
+ execution = commands.add_parser("execution")
88
+ execution_commands = execution.add_subparsers(
89
+ dest="execution_command", required=True
90
+ )
91
+ execution_list = execution_commands.add_parser("list")
92
+ execution_list.add_argument("--job-id")
93
+ execution_show = execution_commands.add_parser("show")
94
+ execution_show.add_argument("execution_id")
95
+ execution_resume = execution_commands.add_parser("resume")
96
+ execution_resume.add_argument("execution_id")
97
+ execution_urls = execution_commands.add_parser("urls")
98
+ execution_urls.add_argument("execution_id")
99
+ execution_urls.add_argument("--cursor")
100
+ execution_urls.add_argument("--limit", type=int, default=50)
101
+ execution_urls.add_argument(
102
+ "--newness", choices=("all", "new", "repeat"), default="all"
103
+ )
104
+ execution_urls.add_argument("--host")
105
+ execution_urls.add_argument("--contains")
106
+ execution_cancel = execution_commands.add_parser("cancel")
107
+ execution_cancel.add_argument("execution_id")
108
+ execution_cancel.add_argument("--force", action="store_true")
109
+
110
+ dataset = commands.add_parser("dataset")
111
+ dataset_commands = dataset.add_subparsers(dest="dataset_command", required=True)
112
+ dataset_commands.add_parser("list")
113
+ dataset_show = dataset_commands.add_parser("show")
114
+ dataset_show.add_argument("dataset_id")
115
+ dataset_create = dataset_commands.add_parser("create")
116
+ dataset_create.add_argument("name")
117
+ dataset_create_source = dataset_create.add_mutually_exclusive_group(required=True)
118
+ dataset_create_source.add_argument("--schema-id")
119
+ dataset_create_source.add_argument("--fields", help="JSON array or @file")
120
+ dataset_create.add_argument(
121
+ "--mode", choices=("append_all", "new_urls_only"), default="append_all"
122
+ )
123
+ dataset_create.add_argument("--identity-field", default="_source_url")
124
+ files = dataset_commands.add_parser("files")
125
+ files.add_argument("dataset_id")
126
+ files.add_argument("--version", type=int)
127
+ query = dataset_commands.add_parser("query")
128
+ query.add_argument("dataset_id")
129
+ query.add_argument("sql")
130
+ query.add_argument("--version", type=int)
131
+ query.add_argument("--row-cap", type=int, default=1_000)
132
+ query.add_argument("--timeout", type=float, default=30.0)
133
+ urls = dataset_commands.add_parser("urls")
134
+ urls.add_argument("dataset_id")
135
+ urls.add_argument("--cursor")
136
+ urls.add_argument("--limit", type=int, default=50)
137
+ urls.add_argument("--newness", choices=("all", "new", "repeat"), default="all")
138
+ urls.add_argument("--host")
139
+ urls.add_argument("--contains")
140
+ compact = dataset_commands.add_parser("compact")
141
+ compact.add_argument("dataset_id")
142
+ compact.add_argument("--minimum-files", type=int, default=2)
143
+ prune = dataset_commands.add_parser("prune")
144
+ prune.add_argument("dataset_id")
145
+ prune.add_argument(
146
+ "--keep-versions",
147
+ "--keep-snapshots",
148
+ dest="keep_versions",
149
+ type=int,
150
+ default=30,
151
+ help="number of dataset versions to retain (default: 30)",
152
+ )
153
+ prune.add_argument("--older-than-days", type=int)
154
+ prune.add_argument("--apply", action="store_true")
155
+
156
+ schedule = commands.add_parser("schedule")
157
+ schedule_commands = schedule.add_subparsers(dest="schedule_command", required=True)
158
+ schedule_commands.add_parser("list")
159
+ schedule_commands.add_parser("status")
160
+ schedule_set = schedule_commands.add_parser("set")
161
+ schedule_set.add_argument("job_id")
162
+ schedule_set.add_argument("cron")
163
+ schedule_set.add_argument("--timezone", default="UTC")
164
+ for action in ("pause", "resume"):
165
+ item = schedule_commands.add_parser(action)
166
+ item.add_argument("job_id")
167
+ schedule_commands.add_parser("run-due")
168
+ serve = schedule_commands.add_parser("start")
169
+ serve.add_argument("--poll-seconds", type=float, default=30.0)
170
+
171
+ backup = commands.add_parser("backup")
172
+ backup_commands = backup.add_subparsers(dest="backup_command", required=True)
173
+ backup_create = backup_commands.add_parser("create")
174
+ backup_create.add_argument("destination")
175
+ backup_create.add_argument("--exclude-raw-pages", action="store_true")
176
+ backup_verify = backup_commands.add_parser("verify")
177
+ backup_verify.add_argument("source")
178
+ backup_restore = backup_commands.add_parser("restore")
179
+ backup_restore.add_argument("source")
180
+ backup_restore.add_argument("destination")
181
+
182
+ cloud = commands.add_parser(
183
+ "cloud", help="explicitly connect this OSS workspace to cloud"
184
+ )
185
+ cloud_commands = cloud.add_subparsers(dest="cloud_command", required=True)
186
+ cloud_commands.add_parser("profiles", help="list configured cloud profiles")
187
+ cloud_connect = cloud_commands.add_parser(
188
+ "connect", help="validate and store a cloud profile"
189
+ )
190
+ cloud_connect.add_argument("--profile", default="default")
191
+ cloud_connect.add_argument("--base-url", required=True)
192
+ cloud_disconnect = cloud_commands.add_parser("disconnect")
193
+ cloud_disconnect.add_argument("--profile", default="default")
194
+ cloud_whoami = cloud_commands.add_parser("whoami")
195
+ cloud_whoami.add_argument("--profile", default="default")
196
+ cloud_bundle = cloud_commands.add_parser(
197
+ "bundle", help="print a credential-free workspace bundle"
198
+ )
199
+ cloud_bundle.add_argument("--profile", default="default", help=argparse.SUPPRESS)
200
+ cloud_plan = cloud_commands.add_parser(
201
+ "plan", help="preview local definition import"
202
+ )
203
+ cloud_plan.add_argument("--profile", default="default")
204
+ cloud_push = cloud_commands.add_parser(
205
+ "push", help="idempotently import local definitions"
206
+ )
207
+ cloud_push.add_argument("--profile", default="default")
208
+ cloud_run = cloud_commands.add_parser(
209
+ "run", help="push definitions and run a local job in cloud"
210
+ )
211
+ cloud_run.add_argument("job_id")
212
+ cloud_run.add_argument("--profile", default="default")
213
+ cloud_pull = cloud_commands.add_parser(
214
+ "pull", help="download active cloud dataset Parquet"
215
+ )
216
+ cloud_pull.add_argument("dataset_id")
217
+ cloud_pull.add_argument("destination")
218
+ cloud_pull.add_argument("--profile", default="default")
219
+ cloud_pull.add_argument("--overwrite", action="store_true")
220
+ return parser
@@ -0,0 +1 @@
1
+ """PulseCrawl adapters inbound python."""