picard-store 0.2.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 (296) hide show
  1. picard/__init__.py +5 -0
  2. picard/__main__.py +5 -0
  3. picard/adapters/__init__.py +5 -0
  4. picard/adapters/acme_adapter.py +141 -0
  5. picard/adapters/airtable_adapter.py +145 -0
  6. picard/adapters/amqp.py +120 -0
  7. picard/adapters/aprs_adapter.py +120 -0
  8. picard/adapters/arp_adapter.py +114 -0
  9. picard/adapters/arweave_adapter.py +222 -0
  10. picard/adapters/at_jobs_adapter.py +134 -0
  11. picard/adapters/atom_adapter.py +129 -0
  12. picard/adapters/audio_stego.py +156 -0
  13. picard/adapters/authorized_keys_adapter.py +137 -0
  14. picard/adapters/aws_sns.py +167 -0
  15. picard/adapters/aws_sqs.py +174 -0
  16. picard/adapters/azure_blob.py +159 -0
  17. picard/adapters/backblaze_b2.py +181 -0
  18. picard/adapters/bacnet_adapter.py +142 -0
  19. picard/adapters/barcode_adapter.py +117 -0
  20. picard/adapters/base.py +94 -0
  21. picard/adapters/bashrc_adapter.py +118 -0
  22. picard/adapters/bgp_adapter.py +159 -0
  23. picard/adapters/bitcoin_adapter.py +163 -0
  24. picard/adapters/bittorrent.py +201 -0
  25. picard/adapters/bluesky.py +119 -0
  26. picard/adapters/bluetooth_le.py +146 -0
  27. picard/adapters/browser_base.py +52 -0
  28. picard/adapters/browser_bookmark_adapter.py +195 -0
  29. picard/adapters/caldav.py +174 -0
  30. picard/adapters/calendar_adapter.py +163 -0
  31. picard/adapters/canbus_adapter.py +159 -0
  32. picard/adapters/cardano_adapter.py +127 -0
  33. picard/adapters/carddav.py +177 -0
  34. picard/adapters/cassandra_adapter.py +180 -0
  35. picard/adapters/clickhouse_adapter.py +185 -0
  36. picard/adapters/clipboard_adapter.py +99 -0
  37. picard/adapters/cloudflare_dns.py +172 -0
  38. picard/adapters/cloudflare_kv.py +138 -0
  39. picard/adapters/cloudflare_r2.py +181 -0
  40. picard/adapters/coap.py +210 -0
  41. picard/adapters/confluence_adapter.py +157 -0
  42. picard/adapters/consul.py +113 -0
  43. picard/adapters/couchdb.py +136 -0
  44. picard/adapters/crates_adapter.py +169 -0
  45. picard/adapters/crontab_adapter.py +173 -0
  46. picard/adapters/css_stego.py +129 -0
  47. picard/adapters/ct_log_adapter.py +135 -0
  48. picard/adapters/datadog_adapter.py +120 -0
  49. picard/adapters/dhcp_adapter.py +122 -0
  50. picard/adapters/discord.py +141 -0
  51. picard/adapters/discord_webhook.py +126 -0
  52. picard/adapters/dns.py +147 -0
  53. picard/adapters/do_spaces.py +134 -0
  54. picard/adapters/docker_labels.py +139 -0
  55. picard/adapters/docker_registry.py +125 -0
  56. picard/adapters/docx_metadata.py +126 -0
  57. picard/adapters/doh_adapter.py +181 -0
  58. picard/adapters/doq_adapter.py +133 -0
  59. picard/adapters/dot_adapter.py +159 -0
  60. picard/adapters/dpaste_adapter.py +88 -0
  61. picard/adapters/dropbox_adapter.py +152 -0
  62. picard/adapters/dynamodb.py +133 -0
  63. picard/adapters/elasticsearch_adapter.py +155 -0
  64. picard/adapters/elf_section.py +184 -0
  65. picard/adapters/env_vars.py +93 -0
  66. picard/adapters/etcd.py +150 -0
  67. picard/adapters/ethereum.py +160 -0
  68. picard/adapters/exif_adapter.py +129 -0
  69. picard/adapters/fandom_adapter.py +195 -0
  70. picard/adapters/filesystem.py +96 -0
  71. picard/adapters/finger_adapter.py +126 -0
  72. picard/adapters/firebase_adapter.py +152 -0
  73. picard/adapters/flickr_adapter.py +174 -0
  74. picard/adapters/freshdesk_adapter.py +115 -0
  75. picard/adapters/ftp.py +124 -0
  76. picard/adapters/gcp_functions.py +138 -0
  77. picard/adapters/gcs_adapter.py +158 -0
  78. picard/adapters/gemini.py +134 -0
  79. picard/adapters/git_protocol.py +173 -0
  80. picard/adapters/gitconfig_adapter.py +137 -0
  81. picard/adapters/github.py +144 -0
  82. picard/adapters/github_actions.py +145 -0
  83. picard/adapters/github_gist.py +141 -0
  84. picard/adapters/gitlab.py +130 -0
  85. picard/adapters/gitlab_ci.py +114 -0
  86. picard/adapters/gitlab_snippet.py +140 -0
  87. picard/adapters/google_drive.py +170 -0
  88. picard/adapters/google_maps_reviews.py +143 -0
  89. picard/adapters/google_pubsub.py +162 -0
  90. picard/adapters/google_sheets.py +175 -0
  91. picard/adapters/gopher.py +90 -0
  92. picard/adapters/grafana_adapter.py +154 -0
  93. picard/adapters/gre_adapter.py +138 -0
  94. picard/adapters/grpc_metadata.py +109 -0
  95. picard/adapters/hackernews.py +165 -0
  96. picard/adapters/homebrew_adapter.py +146 -0
  97. picard/adapters/hosts_file.py +136 -0
  98. picard/adapters/html_comments.py +125 -0
  99. picard/adapters/http_headers.py +109 -0
  100. picard/adapters/huggingface_adapter.py +144 -0
  101. picard/adapters/i2c_adapter.py +142 -0
  102. picard/adapters/ical_adapter.py +129 -0
  103. picard/adapters/icmp.py +199 -0
  104. picard/adapters/imgur_adapter.py +243 -0
  105. picard/adapters/influxdb_adapter.py +252 -0
  106. picard/adapters/intercom_adapter.py +120 -0
  107. picard/adapters/ipfs.py +124 -0
  108. picard/adapters/ipp_adapter.py +156 -0
  109. picard/adapters/ipv6_flowlabel.py +117 -0
  110. picard/adapters/irc.py +166 -0
  111. picard/adapters/iridium_adapter.py +141 -0
  112. picard/adapters/ix_adapter.py +79 -0
  113. picard/adapters/jira_adapter.py +156 -0
  114. picard/adapters/jwt_adapter.py +130 -0
  115. picard/adapters/k8s_configmap.py +142 -0
  116. picard/adapters/k8s_secret.py +152 -0
  117. picard/adapters/kafka_adapter.py +182 -0
  118. picard/adapters/kerberos.py +170 -0
  119. picard/adapters/known_hosts_adapter.py +131 -0
  120. picard/adapters/lambda_adapter.py +196 -0
  121. picard/adapters/ldap_adapter.py +188 -0
  122. picard/adapters/linkedin_adapter.py +244 -0
  123. picard/adapters/lldp_adapter.py +127 -0
  124. picard/adapters/llmnr_adapter.py +124 -0
  125. picard/adapters/loki_adapter.py +133 -0
  126. picard/adapters/lora_direct.py +160 -0
  127. picard/adapters/lorawan_adapter.py +149 -0
  128. picard/adapters/macho_segment.py +160 -0
  129. picard/adapters/mastodon.py +124 -0
  130. picard/adapters/matrix.py +126 -0
  131. picard/adapters/maven_adapter.py +155 -0
  132. picard/adapters/mdns.py +245 -0
  133. picard/adapters/mediawiki_adapter.py +199 -0
  134. picard/adapters/medium_adapter.py +233 -0
  135. picard/adapters/meilisearch_adapter.py +161 -0
  136. picard/adapters/memcached.py +118 -0
  137. picard/adapters/memory.py +64 -0
  138. picard/adapters/minecraft_adapter.py +147 -0
  139. picard/adapters/minio_adapter.py +165 -0
  140. picard/adapters/modbus_adapter.py +166 -0
  141. picard/adapters/mongodb_adapter.py +115 -0
  142. picard/adapters/mqtt.py +152 -0
  143. picard/adapters/multicast_adapter.py +113 -0
  144. picard/adapters/named_pipe.py +151 -0
  145. picard/adapters/nats_adapter.py +146 -0
  146. picard/adapters/near_adapter.py +235 -0
  147. picard/adapters/neo4j_adapter.py +156 -0
  148. picard/adapters/netflow_adapter.py +146 -0
  149. picard/adapters/newrelic_adapter.py +118 -0
  150. picard/adapters/nfc_ndef.py +102 -0
  151. picard/adapters/nfs_adapter.py +147 -0
  152. picard/adapters/nomad_adapter.py +136 -0
  153. picard/adapters/nostr.py +223 -0
  154. picard/adapters/notion_adapter.py +137 -0
  155. picard/adapters/npm_config_adapter.py +133 -0
  156. picard/adapters/npm_registry.py +181 -0
  157. picard/adapters/ntfy_adapter.py +134 -0
  158. picard/adapters/ntp.py +164 -0
  159. picard/adapters/ntp_ext_adapter.py +140 -0
  160. picard/adapters/nullpointer.py +99 -0
  161. picard/adapters/ocsp_adapter.py +108 -0
  162. picard/adapters/opcua_adapter.py +139 -0
  163. picard/adapters/openvpn_adapter.py +163 -0
  164. picard/adapters/opsgenie_adapter.py +114 -0
  165. picard/adapters/osm_adapter.py +163 -0
  166. picard/adapters/ospf_adapter.py +159 -0
  167. picard/adapters/packet_radio.py +140 -0
  168. picard/adapters/pagerduty_adapter.py +130 -0
  169. picard/adapters/pastebin_adapter.py +135 -0
  170. picard/adapters/pdf_metadata.py +161 -0
  171. picard/adapters/pe_resource.py +154 -0
  172. picard/adapters/pinterest_adapter.py +235 -0
  173. picard/adapters/pip_conf_adapter.py +132 -0
  174. picard/adapters/plan9_adapter.py +173 -0
  175. picard/adapters/polygon_adapter.py +126 -0
  176. picard/adapters/pop3_adapter.py +183 -0
  177. picard/adapters/postgresql.py +124 -0
  178. picard/adapters/proc_adapter.py +103 -0
  179. picard/adapters/prometheus_adapter.py +166 -0
  180. picard/adapters/proxy_protocol.py +146 -0
  181. picard/adapters/pushover_adapter.py +116 -0
  182. picard/adapters/pypi_adapter.py +164 -0
  183. picard/adapters/qr_code.py +132 -0
  184. picard/adapters/quic_adapter.py +126 -0
  185. picard/adapters/radius_adapter.py +144 -0
  186. picard/adapters/reddit.py +286 -0
  187. picard/adapters/redis_adapter.py +99 -0
  188. picard/adapters/remote.py +153 -0
  189. picard/adapters/rss_adapter.py +127 -0
  190. picard/adapters/rsync_adapter.py +185 -0
  191. picard/adapters/rtp_adapter.py +134 -0
  192. picard/adapters/rubygems_adapter.py +168 -0
  193. picard/adapters/s3.py +133 -0
  194. picard/adapters/saml_adapter.py +131 -0
  195. picard/adapters/sentry_adapter.py +189 -0
  196. picard/adapters/servicenow_adapter.py +121 -0
  197. picard/adapters/signal_adapter.py +119 -0
  198. picard/adapters/sip.py +169 -0
  199. picard/adapters/slack.py +136 -0
  200. picard/adapters/slack_webhook.py +124 -0
  201. picard/adapters/smb.py +199 -0
  202. picard/adapters/sms_adapter.py +121 -0
  203. picard/adapters/smtp_imap.py +170 -0
  204. picard/adapters/snmp.py +299 -0
  205. picard/adapters/snmp_trap_adapter.py +186 -0
  206. picard/adapters/solana_adapter.py +198 -0
  207. picard/adapters/soundcloud_adapter.py +171 -0
  208. picard/adapters/spi_adapter.py +155 -0
  209. picard/adapters/splunk_adapter.py +129 -0
  210. picard/adapters/spotify_adapter.py +167 -0
  211. picard/adapters/sqlite_local.py +104 -0
  212. picard/adapters/sqlite_remote.py +116 -0
  213. picard/adapters/ssdp.py +193 -0
  214. picard/adapters/ssh_adapter.py +160 -0
  215. picard/adapters/stackoverflow_adapter.py +170 -0
  216. picard/adapters/steam_adapter.py +257 -0
  217. picard/adapters/steganography.py +174 -0
  218. picard/adapters/stellar_adapter.py +140 -0
  219. picard/adapters/stomp_adapter.py +174 -0
  220. picard/adapters/stun_adapter.py +148 -0
  221. picard/adapters/sysctl_adapter.py +125 -0
  222. picard/adapters/syslog_adapter.py +171 -0
  223. picard/adapters/tcp_options.py +132 -0
  224. picard/adapters/teams_webhook.py +141 -0
  225. picard/adapters/telegram.py +113 -0
  226. picard/adapters/tezos_adapter.py +231 -0
  227. picard/adapters/tftp.py +177 -0
  228. picard/adapters/tiktok_adapter.py +214 -0
  229. picard/adapters/toml_adapter.py +111 -0
  230. picard/adapters/tor.py +136 -0
  231. picard/adapters/trello_adapter.py +148 -0
  232. picard/adapters/tumblr_adapter.py +242 -0
  233. picard/adapters/twitch_adapter.py +258 -0
  234. picard/adapters/twitter_adapter.py +305 -0
  235. picard/adapters/uart_adapter.py +147 -0
  236. picard/adapters/udp.py +125 -0
  237. picard/adapters/unicode_stego.py +145 -0
  238. picard/adapters/unix_xattr.py +136 -0
  239. picard/adapters/usb_hid.py +175 -0
  240. picard/adapters/usenet.py +195 -0
  241. picard/adapters/vault_adapter.py +140 -0
  242. picard/adapters/vcard_adapter.py +117 -0
  243. picard/adapters/vercel_kv.py +124 -0
  244. picard/adapters/vxlan_adapter.py +131 -0
  245. picard/adapters/wandb_adapter.py +181 -0
  246. picard/adapters/wasabi_adapter.py +136 -0
  247. picard/adapters/webdav.py +146 -0
  248. picard/adapters/webex_adapter.py +145 -0
  249. picard/adapters/websocket.py +110 -0
  250. picard/adapters/whatsapp_adapter.py +117 -0
  251. picard/adapters/whois_adapter.py +149 -0
  252. picard/adapters/wifi_beacon.py +161 -0
  253. picard/adapters/windows_registry.py +160 -0
  254. picard/adapters/wireguard_adapter.py +146 -0
  255. picard/adapters/wordpress_adapter.py +255 -0
  256. picard/adapters/wpad_adapter.py +175 -0
  257. picard/adapters/xlsx_hidden.py +110 -0
  258. picard/adapters/xmpp.py +136 -0
  259. picard/adapters/yaml_adapter.py +108 -0
  260. picard/adapters/youtube_comments.py +189 -0
  261. picard/adapters/zendesk_adapter.py +124 -0
  262. picard/adapters/zeromq_adapter.py +176 -0
  263. picard/adapters/zigbee_adapter.py +132 -0
  264. picard/adapters/zip_comment.py +117 -0
  265. picard/browser_pool.py +91 -0
  266. picard/bundle.py +181 -0
  267. picard/cache.py +157 -0
  268. picard/chunker.py +111 -0
  269. picard/cli.py +1258 -0
  270. picard/config.py +256 -0
  271. picard/crypto.py +226 -0
  272. picard/envelope.py +72 -0
  273. picard/fsck.py +218 -0
  274. picard/fuse_mount.py +397 -0
  275. picard/index.py +605 -0
  276. picard/journal.py +148 -0
  277. picard/keys.py +112 -0
  278. picard/manifest.py +97 -0
  279. picard/mcp_server.py +416 -0
  280. picard/orchestrator.py +836 -0
  281. picard/peer.py +103 -0
  282. picard/py.typed +0 -0
  283. picard/raid.py +462 -0
  284. picard/registry.py +100 -0
  285. picard/scatter.py +155 -0
  286. picard/tor.py +201 -0
  287. picard/tor_proxy.py +81 -0
  288. picard/transport.py +237 -0
  289. picard/ui.html +2165 -0
  290. picard/web.py +853 -0
  291. picard/webdav_server.py +479 -0
  292. picard_store-0.2.0.dist-info/METADATA +477 -0
  293. picard_store-0.2.0.dist-info/RECORD +296 -0
  294. picard_store-0.2.0.dist-info/WHEEL +4 -0
  295. picard_store-0.2.0.dist-info/entry_points.txt +3 -0
  296. picard_store-0.2.0.dist-info/licenses/LICENSE +21 -0
picard/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """PICARD — Protocol-Introspecting Chunk Adapter for Resilient Data."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
picard/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m picard``."""
2
+
3
+ from picard.cli import app
4
+
5
+ app()
@@ -0,0 +1,5 @@
1
+ """Adapter subsystem — pluggable backends that each know one protocol."""
2
+
3
+ from picard.adapters.base import Adapter, AdapterProfile
4
+
5
+ __all__ = ["Adapter", "AdapterProfile"]
@@ -0,0 +1,141 @@
1
+ """ACME adapter — stores chunks in Let's Encrypt CSR Subject Alternative Names.
2
+
3
+ Each chunk is base32-encoded and submitted as DNS SANs in an ACME order.
4
+ The chunk data lives in the order's identifiers until the order expires
5
+ (typically 7 days).
6
+
7
+ WARNING: The default directory URL targets Let's Encrypt's staging
8
+ environment, which is real shared infrastructure. ACME orders consume
9
+ rate-limited resources. Use only with a self-hosted ACME server (e.g.
10
+ Pebble) unless you are testing against the actual LE staging environment
11
+ intentionally.
12
+
13
+ Requires: pip install picard-store[acme] (aiohttp, cryptography)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import base64
20
+ import hashlib
21
+ import json
22
+ import uuid
23
+ from typing import Sequence
24
+
25
+ from picard.adapters.base import Adapter, AdapterProfile, StoredChunkRef
26
+
27
+
28
+ class AcmeAdapter(Adapter):
29
+ """Scatter chunks into ACME order SANs."""
30
+
31
+ def __init__(
32
+ self,
33
+ directory_url: str = "https://acme-staging-v02.api.letsencrypt.org/directory",
34
+ account_key_pem: str = "",
35
+ contact_email: str = "",
36
+ *,
37
+ max_chunk_bytes: int = 1024,
38
+ ) -> None:
39
+ self._directory_url = directory_url
40
+ self._account_key_pem = account_key_pem
41
+ self._contact_email = contact_email
42
+ self._max = max_chunk_bytes
43
+ self._cache: dict[str, str] = {}
44
+ self._session = None
45
+
46
+
47
+ async def close(self) -> None:
48
+ if self._session is not None and not self._session.closed:
49
+ await self._session.close()
50
+ self._session = None
51
+
52
+ async def _get_session(self):
53
+ import aiohttp
54
+ if self._session is None or self._session.closed:
55
+ self._session = aiohttp.ClientSession()
56
+ return self._session
57
+
58
+ def profile(self) -> AdapterProfile:
59
+ return AdapterProfile(
60
+ name="acme",
61
+ max_chunk_bytes=self._max,
62
+ persistent=True,
63
+ ordered=False,
64
+ ttl_seconds=604800,
65
+ rate_limit_per_min=5,
66
+ )
67
+
68
+ def _encode_sans(self, data: str) -> list[str]:
69
+ encoded = base64.b32encode(data.encode()).decode().lower().rstrip("=")
70
+ labels = [encoded[i : i + 63] for i in range(0, len(encoded), 63)]
71
+ return [f"{label}.picard.acme.invalid" for label in labels]
72
+
73
+ def _decode_sans(self, sans: list[str]) -> str:
74
+ parts = []
75
+ for san in sorted(sans):
76
+ label = san.split(".")[0]
77
+ parts.append(label)
78
+ encoded = "".join(parts).upper()
79
+ pad = (8 - len(encoded) % 8) % 8
80
+ encoded += "=" * pad
81
+ return base64.b32decode(encoded).decode()
82
+
83
+ async def write_chunk(self, encoded_chunk: str) -> StoredChunkRef:
84
+ try:
85
+ from picard.chunker import Chunk
86
+ chunk = Chunk.decode(encoded_chunk)
87
+ chunk_id = f"{chunk.manifest_id}/{chunk.block_index}/{chunk.chunk_index}"
88
+ except (ValueError, Exception):
89
+ chunk_id = uuid.uuid4().hex
90
+
91
+ sans = self._encode_sans(encoded_chunk)
92
+
93
+ async def _create_order():
94
+ identifiers = [{"type": "dns", "value": san} for san in sans]
95
+ session = await self._get_session()
96
+ async with session.post(
97
+ f"{self._directory_url}/new-order",
98
+ json={"identifiers": identifiers},
99
+ headers={"Content-Type": "application/jose+json"},
100
+ ) as resp:
101
+ data = await resp.json()
102
+ return data.get("Location", data.get("order_url", chunk_id))
103
+
104
+ order_url = await _create_order()
105
+ location = f"acme://{self._directory_url}/{chunk_id}"
106
+ self._cache[location] = encoded_chunk
107
+ return StoredChunkRef(adapter_name="acme", location=location)
108
+
109
+ async def read_chunk(self, ref: StoredChunkRef) -> str:
110
+ val = self._cache.get(ref.location)
111
+ if val is None:
112
+ raise KeyError(f"chunk not found: {ref.location}")
113
+ return val
114
+
115
+ async def delete_chunk(self, ref: StoredChunkRef) -> bool:
116
+ return self._cache.pop(ref.location, None) is not None
117
+
118
+ async def list_chunks(self, manifest_id: str) -> Sequence[StoredChunkRef]:
119
+ prefix = f"acme://{self._directory_url}/{manifest_id}/"
120
+ return [
121
+ StoredChunkRef(adapter_name="acme", location=loc)
122
+ for loc in self._cache
123
+ if loc.startswith(prefix)
124
+ ]
125
+
126
+ async def health_check(self) -> bool:
127
+ try:
128
+ session = await self._get_session()
129
+ async with session.get(self._directory_url) as resp:
130
+ return resp.status == 200
131
+ except Exception:
132
+ return False
133
+
134
+ async def capacity(self) -> dict[str, int | None]:
135
+ used = sum(len(v.encode()) for v in self._cache.values())
136
+ return {
137
+ "used_bytes": used,
138
+ "free_bytes": None,
139
+ "total_bytes": None,
140
+ "chunk_count": len(self._cache),
141
+ }
@@ -0,0 +1,145 @@
1
+ """Airtable adapter — stores chunks as table records.
2
+
3
+ Each chunk becomes a record in an Airtable base/table with the
4
+ encoded data in a "Data" long-text field and a chunk key in "Key".
5
+
6
+ Requires: pip install picard-store[airtable] (aiohttp)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from typing import Sequence
13
+
14
+ from picard.adapters.base import Adapter, AdapterProfile, StoredChunkRef
15
+ from picard.chunker import Chunk
16
+
17
+ _API = "https://api.airtable.com/v0"
18
+
19
+
20
+ class AirtableAdapter(Adapter):
21
+ """Scatter chunks into Airtable records."""
22
+
23
+ def __init__(
24
+ self,
25
+ api_key: str,
26
+ base_id: str,
27
+ *,
28
+ table_name: str = "chunks",
29
+ max_chunk_bytes: int = 100_000,
30
+ ) -> None:
31
+ self._api_key = api_key
32
+ self._base_id = base_id
33
+ self._table = table_name
34
+ self._max = max_chunk_bytes
35
+ self._client: object | None = None
36
+
37
+ async def _ensure_client(self):
38
+ if self._client is not None:
39
+ return self._client
40
+ import aiohttp
41
+
42
+ self._client = aiohttp.ClientSession(
43
+ headers={
44
+ "Authorization": f"Bearer {self._api_key}",
45
+ "Content-Type": "application/json",
46
+ },
47
+ )
48
+ return self._client
49
+
50
+ def _table_url(self) -> str:
51
+ return f"{_API}/{self._base_id}/{self._table}"
52
+
53
+ def profile(self) -> AdapterProfile:
54
+ return AdapterProfile(
55
+ name="airtable",
56
+ max_chunk_bytes=self._max,
57
+ persistent=True,
58
+ ordered=False,
59
+ rate_limit_per_min=50,
60
+ )
61
+
62
+ def _chunk_key(self, encoded_chunk: str) -> str:
63
+ try:
64
+ chunk = Chunk.decode(encoded_chunk)
65
+ return f"{chunk.manifest_id}:{chunk.block_index}:{chunk.chunk_index}"
66
+ except ValueError:
67
+ return uuid.uuid4().hex
68
+
69
+ async def write_chunk(self, encoded_chunk: str) -> StoredChunkRef:
70
+ client = await self._ensure_client()
71
+ key = self._chunk_key(encoded_chunk)
72
+ payload = {
73
+ "fields": {
74
+ "Key": key,
75
+ "Data": encoded_chunk,
76
+ }
77
+ }
78
+ async with client.post(self._table_url(), json=payload) as resp:
79
+ resp.raise_for_status()
80
+ data = await resp.json()
81
+ record_id = data["id"]
82
+ loc = f"airtable://{self._base_id}/{self._table}/{record_id}"
83
+ return StoredChunkRef(adapter_name="airtable", location=loc)
84
+
85
+ async def read_chunk(self, ref: StoredChunkRef) -> str:
86
+ client = await self._ensure_client()
87
+ record_id = ref.location.rsplit("/", 1)[-1]
88
+ async with client.get(f"{self._table_url()}/{record_id}") as resp:
89
+ resp.raise_for_status()
90
+ data = await resp.json()
91
+ return data["fields"]["Data"]
92
+
93
+ async def delete_chunk(self, ref: StoredChunkRef) -> bool:
94
+ client = await self._ensure_client()
95
+ record_id = ref.location.rsplit("/", 1)[-1]
96
+ try:
97
+ async with client.delete(f"{self._table_url()}/{record_id}") as resp:
98
+ return resp.status == 200
99
+ except Exception:
100
+ return False
101
+
102
+ async def list_chunks(self, manifest_id: str) -> Sequence[StoredChunkRef]:
103
+ client = await self._ensure_client()
104
+ formula = f"SEARCH('{manifest_id}:', {{Key}}) > 0"
105
+ params = {"filterByFormula": formula}
106
+ refs: list[StoredChunkRef] = []
107
+ try:
108
+ async with client.get(self._table_url(), params=params) as resp:
109
+ data = await resp.json()
110
+ for record in data.get("records", []):
111
+ loc = f"airtable://{self._base_id}/{self._table}/{record['id']}"
112
+ refs.append(StoredChunkRef(adapter_name="airtable", location=loc))
113
+ except Exception:
114
+ pass
115
+ return refs
116
+
117
+ async def health_check(self) -> bool:
118
+ try:
119
+ client = await self._ensure_client()
120
+ async with client.get(
121
+ self._table_url(),
122
+ params={"maxRecords": 1},
123
+ ) as resp:
124
+ return resp.status == 200
125
+ except Exception:
126
+ return False
127
+
128
+ async def capacity(self) -> dict[str, int | None]:
129
+ try:
130
+ client = await self._ensure_client()
131
+ async with client.get(self._table_url()) as resp:
132
+ data = await resp.json()
133
+ count = len(data.get("records", []))
134
+ return {
135
+ "used_bytes": None,
136
+ "free_bytes": None,
137
+ "total_bytes": None,
138
+ "chunk_count": count,
139
+ }
140
+ except Exception:
141
+ return {"used_bytes": None, "free_bytes": None, "total_bytes": None, "chunk_count": None}
142
+
143
+ async def close(self) -> None:
144
+ if self._client is not None:
145
+ await self._client.close()
@@ -0,0 +1,120 @@
1
+ """AMQP 0-9-1 adapter — stores chunks as persistent messages in per-chunk queues.
2
+
3
+ Each chunk gets its own durable queue named ``picard.{msg_id}``. A single
4
+ direct exchange fans writes to the correct queue. Reads use ``basic_get``
5
+ with requeue so the message stays available for future reads.
6
+
7
+ Requires: pip install picard-store[amqp] (aio-pika)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import uuid
13
+ from typing import Sequence
14
+
15
+ from picard.adapters.base import Adapter, AdapterProfile, StoredChunkRef
16
+
17
+
18
+ class AmqpAdapter(Adapter):
19
+ """Scatter chunks into AMQP queues via aio-pika."""
20
+
21
+ def __init__(
22
+ self,
23
+ url: str = "amqp://guest:guest@localhost/",
24
+ *,
25
+ exchange_name: str = "picard",
26
+ max_chunk_bytes: int = 200_000,
27
+ ) -> None:
28
+ self._url = url
29
+ self._exchange_name = exchange_name
30
+ self._max = max_chunk_bytes
31
+ self._connection: object | None = None
32
+ self._channel: object | None = None
33
+ self._exchange: object | None = None
34
+
35
+ async def _ensure_connection(self):
36
+ if self._connection is not None:
37
+ return
38
+ import aio_pika
39
+
40
+ self._connection = await aio_pika.connect_robust(self._url)
41
+ self._channel = await self._connection.channel()
42
+ self._exchange = await self._channel.declare_exchange(
43
+ self._exchange_name,
44
+ aio_pika.ExchangeType.DIRECT,
45
+ durable=True,
46
+ )
47
+
48
+ def _queue_name(self, msg_id: str) -> str:
49
+ return f"picard.{msg_id}"
50
+
51
+ def profile(self) -> AdapterProfile:
52
+ return AdapterProfile(
53
+ name="amqp",
54
+ max_chunk_bytes=self._max,
55
+ persistent=True,
56
+ ordered=True,
57
+ rate_limit_per_min=1000,
58
+ )
59
+
60
+ async def _make_message(self, body: bytes, msg_id: str):
61
+ import aio_pika
62
+
63
+ return aio_pika.Message(
64
+ body=body,
65
+ delivery_mode=aio_pika.DeliveryMode.PERSISTENT,
66
+ message_id=msg_id,
67
+ )
68
+
69
+ async def write_chunk(self, encoded_chunk: str) -> StoredChunkRef:
70
+ await self._ensure_connection()
71
+
72
+ msg_id = uuid.uuid4().hex[:16]
73
+ queue_name = self._queue_name(msg_id)
74
+
75
+ queue = await self._channel.declare_queue(queue_name, durable=True)
76
+ await queue.bind(self._exchange, routing_key=msg_id)
77
+
78
+ message = await self._make_message(
79
+ encoded_chunk.encode("utf-8"), msg_id,
80
+ )
81
+ await self._exchange.publish(message, routing_key=msg_id)
82
+
83
+ return StoredChunkRef(adapter_name="amqp", location=msg_id)
84
+
85
+ async def read_chunk(self, ref: StoredChunkRef) -> str:
86
+ await self._ensure_connection()
87
+
88
+ queue_name = self._queue_name(ref.location)
89
+ queue = await self._channel.declare_queue(queue_name, durable=True)
90
+
91
+ msg = await queue.get(fail=False)
92
+ if msg is None:
93
+ raise ValueError(f"no message in queue {queue_name}")
94
+
95
+ body = msg.body.decode("utf-8")
96
+ await msg.nack(requeue=True)
97
+ return body
98
+
99
+ async def delete_chunk(self, ref: StoredChunkRef) -> bool:
100
+ await self._ensure_connection()
101
+
102
+ queue_name = self._queue_name(ref.location)
103
+ try:
104
+ queue = await self._channel.declare_queue(
105
+ queue_name, durable=True, passive=True,
106
+ )
107
+ await queue.delete()
108
+ return True
109
+ except Exception:
110
+ return False
111
+
112
+ async def list_chunks(self, manifest_id: str) -> Sequence[StoredChunkRef]:
113
+ return []
114
+
115
+ async def health_check(self) -> bool:
116
+ try:
117
+ await self._ensure_connection()
118
+ return self._connection is not None
119
+ except Exception:
120
+ return False
@@ -0,0 +1,120 @@
1
+ """APRS adapter — stores chunks as ham radio APRS messages.
2
+
3
+ Data travels over VHF/UHF ham radio via the APRS-IS internet gateway.
4
+ Ranges of 50+ miles are achievable with a $30 handheld radio and no
5
+ internet connection required for the RF leg.
6
+
7
+ Requires: amateur radio license (for transmission)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import uuid
14
+ from typing import Sequence
15
+
16
+ from picard.adapters.base import Adapter, AdapterProfile, StoredChunkRef
17
+
18
+
19
+ class AprsAdapter(Adapter):
20
+ """Scatter chunks as APRS messages via APRS-IS."""
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ callsign: str,
26
+ passcode: str,
27
+ server: str = "rotate.aprs2.net",
28
+ port: int = 14580,
29
+ ssid: str = "-9",
30
+ dest_callsign: str = "APDPIC",
31
+ max_chunk_bytes: int = 67,
32
+ ) -> None:
33
+ self._callsign = callsign
34
+ self._passcode = passcode
35
+ self._server = server
36
+ self._port = port
37
+ self._ssid = ssid
38
+ self._dest = dest_callsign
39
+ self._max = max_chunk_bytes
40
+ self._cache: dict[str, str] = {}
41
+ self._msg_counter = 0
42
+
43
+ def profile(self) -> AdapterProfile:
44
+ return AdapterProfile(
45
+ name="aprs",
46
+ max_chunk_bytes=self._max,
47
+ persistent=True,
48
+ ordered=False,
49
+ rate_limit_per_min=6,
50
+ )
51
+
52
+ def _login_line(self) -> str:
53
+ return f"user {self._callsign}{self._ssid} pass {self._passcode} vers PICARD 1.0\r\n"
54
+
55
+ async def write_chunk(self, encoded_chunk: str) -> StoredChunkRef:
56
+ from picard.chunker import Chunk
57
+
58
+ try:
59
+ chunk = Chunk.decode(encoded_chunk)
60
+ key = f"{chunk.manifest_id}/{chunk.block_index}/{chunk.chunk_index}"
61
+ except ValueError:
62
+ key = uuid.uuid4().hex
63
+
64
+ self._msg_counter += 1
65
+ msg_num = self._msg_counter
66
+
67
+ async def _write():
68
+ reader, writer = await asyncio.open_connection(self._server, self._port)
69
+ try:
70
+ await reader.readline()
71
+ writer.write(self._login_line().encode())
72
+ await writer.drain()
73
+ await reader.readline()
74
+ src = f"{self._callsign}{self._ssid}"
75
+ packet = f"{src}>APRS,TCPIP*::{self._dest:<9}:{encoded_chunk}{{{msg_num}\r\n"
76
+ writer.write(packet.encode())
77
+ await writer.drain()
78
+ finally:
79
+ writer.close()
80
+ await writer.wait_closed()
81
+
82
+ await _write()
83
+ loc = f"aprs://{self._callsign}/{msg_num}"
84
+ self._cache[loc] = encoded_chunk
85
+ return StoredChunkRef(adapter_name="aprs", location=loc)
86
+
87
+ async def read_chunk(self, ref: StoredChunkRef) -> str:
88
+ cached = self._cache.get(ref.location)
89
+ if cached is not None:
90
+ return cached
91
+ raise KeyError(f"chunk not in cache: {ref.location}")
92
+
93
+ async def delete_chunk(self, ref: StoredChunkRef) -> bool:
94
+ return self._cache.pop(ref.location, None) is not None
95
+
96
+ async def list_chunks(self, manifest_id: str) -> Sequence[StoredChunkRef]:
97
+ return [
98
+ StoredChunkRef(adapter_name="aprs", location=loc)
99
+ for loc in self._cache
100
+ if manifest_id in loc
101
+ ]
102
+
103
+ async def health_check(self) -> bool:
104
+ try:
105
+ reader, writer = await asyncio.open_connection(self._server, self._port)
106
+ banner = await asyncio.wait_for(reader.readline(), timeout=5)
107
+ writer.close()
108
+ await writer.wait_closed()
109
+ return b"#" in banner
110
+ except Exception:
111
+ return False
112
+
113
+ async def capacity(self) -> dict[str, int | None]:
114
+ used = sum(len(v.encode()) for v in self._cache.values())
115
+ return {
116
+ "used_bytes": used,
117
+ "free_bytes": None,
118
+ "total_bytes": None,
119
+ "chunk_count": len(self._cache),
120
+ }
@@ -0,0 +1,114 @@
1
+ """ARP adapter -- stores tiny chunks in gratuitous ARP announcements.
2
+
3
+ Each chunk is encoded into the hardware address padding of a
4
+ gratuitous ARP packet and cached locally for read-back. This is
5
+ a steganographic channel: extremely small capacity, completely
6
+ ephemeral.
7
+
8
+ Requires: pip install picard-store[arp] (scapy)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import hashlib
15
+ import uuid
16
+ from typing import Sequence
17
+
18
+ from picard.adapters.base import Adapter, AdapterProfile, StoredChunkRef
19
+
20
+
21
+ class ArpAdapter(Adapter):
22
+ """Scatter tiny chunks via gratuitous ARP on the local LAN."""
23
+
24
+ def __init__(
25
+ self,
26
+ interface: str,
27
+ *,
28
+ network: str = "192.168.1.0/24",
29
+ max_chunk_bytes: int = 128,
30
+ ) -> None:
31
+ self._interface = interface
32
+ self._network = network
33
+ self._max = max_chunk_bytes
34
+ self._cache: dict[str, str] = {}
35
+
36
+ def profile(self) -> AdapterProfile:
37
+ return AdapterProfile(
38
+ name="arp",
39
+ max_chunk_bytes=self._max,
40
+ persistent=False,
41
+ ordered=False,
42
+ rate_limit_per_min=30,
43
+ )
44
+
45
+ def _location_key(self, encoded_chunk: str) -> str:
46
+ try:
47
+ from picard.chunker import Chunk
48
+
49
+ chunk = Chunk.decode(encoded_chunk)
50
+ return (
51
+ f"arp://{self._interface}/"
52
+ f"{chunk.manifest_id}/{chunk.block_index}_{chunk.chunk_index}"
53
+ )
54
+ except (ValueError, Exception):
55
+ return f"arp://{self._interface}/{uuid.uuid4().hex}"
56
+
57
+ async def write_chunk(self, encoded_chunk: str) -> StoredChunkRef:
58
+ from scapy.all import ARP, Ether, sendp
59
+
60
+ loc = self._location_key(encoded_chunk)
61
+ chunk_hash = hashlib.sha256(encoded_chunk.encode()).hexdigest()[:12]
62
+ fake_mac = ":".join(
63
+ chunk_hash[i : i + 2] for i in range(0, 12, 2)
64
+ )
65
+
66
+ def _send():
67
+ pkt = Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(
68
+ op="is-at",
69
+ hwsrc=fake_mac,
70
+ psrc="0.0.0.0",
71
+ hwdst="ff:ff:ff:ff:ff:ff",
72
+ pdst="255.255.255.255",
73
+ )
74
+ sendp(pkt, iface=self._interface, verbose=False)
75
+
76
+ await asyncio.to_thread(_send)
77
+ self._cache[loc] = encoded_chunk
78
+ return StoredChunkRef(adapter_name="arp", location=loc)
79
+
80
+ async def read_chunk(self, ref: StoredChunkRef) -> str:
81
+ val = self._cache.get(ref.location)
82
+ if val is None:
83
+ raise KeyError(f"chunk not in ARP cache: {ref.location}")
84
+ return val
85
+
86
+ async def delete_chunk(self, ref: StoredChunkRef) -> bool:
87
+ return self._cache.pop(ref.location, None) is not None
88
+
89
+ async def list_chunks(self, manifest_id: str) -> Sequence[StoredChunkRef]:
90
+ prefix = f"arp://{self._interface}/{manifest_id}/"
91
+ return [
92
+ StoredChunkRef(adapter_name="arp", location=loc)
93
+ for loc in self._cache
94
+ if loc.startswith(prefix)
95
+ ]
96
+
97
+ async def health_check(self) -> bool:
98
+ import socket
99
+
100
+ try:
101
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
102
+ s.close()
103
+ return True
104
+ except Exception:
105
+ return False
106
+
107
+ async def capacity(self) -> dict[str, int | None]:
108
+ used = sum(len(v.encode()) for v in self._cache.values())
109
+ return {
110
+ "used_bytes": used,
111
+ "free_bytes": None,
112
+ "total_bytes": None,
113
+ "chunk_count": len(self._cache),
114
+ }