pinecall-runtime 0.1.6__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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Where a call's audio is kept: the disk it was recorded on, or a bucket, under its org."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import shutil
|
|
6
|
+
from collections.abc import AsyncIterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from http import HTTPStatus
|
|
9
|
+
from pathlib import Path, PurePath
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from pinecall.domain.errors import DeclarationRefused, UpstreamFailed
|
|
14
|
+
from pinecall.process._objects import ObjectStore, object_store_of
|
|
15
|
+
from pinecall.process.sealed_audio import (
|
|
16
|
+
BROKEN,
|
|
17
|
+
HEADER,
|
|
18
|
+
MAGIC,
|
|
19
|
+
Sealed,
|
|
20
|
+
Span,
|
|
21
|
+
opened,
|
|
22
|
+
recorded_size,
|
|
23
|
+
span_of,
|
|
24
|
+
)
|
|
25
|
+
from pinecall.process.settings import Settings
|
|
26
|
+
|
|
27
|
+
# The recorder's file in the call's own directory: `<root>/<call>/audio.ogg` on the disk,
|
|
28
|
+
# `<org>/<call>/audio.ogg` in the bucket. Sealed under the call's own key, it is `audio.sealed`
|
|
29
|
+
# beside it (sealed_audio.py): the name says which a stored recording is.
|
|
30
|
+
AUDIO_FILE = "audio.ogg"
|
|
31
|
+
|
|
32
|
+
SEALED_FILE = "audio.sealed"
|
|
33
|
+
|
|
34
|
+
# An hour of a call is tens of megabytes: the default five seconds is a small file's. Under what
|
|
35
|
+
# a job is given to seal (worker/main.py SEALING_S): an upload that hangs leaves the file on the
|
|
36
|
+
# disk and the call is still sealed.
|
|
37
|
+
UPLOAD_TIMEOUT_S = 30.0
|
|
38
|
+
|
|
39
|
+
# What an upload reads of the file at a time: hashed first, then sent, and never held whole.
|
|
40
|
+
READ_BYTES = 256 * 1024
|
|
41
|
+
|
|
42
|
+
# What a player needs from the bucket's answer to seek.
|
|
43
|
+
PASSED_ON = ("content-length", "content-range", "accept-ranges")
|
|
44
|
+
|
|
45
|
+
# The answers a read passes on: the whole file, a range of it, a range past its end.
|
|
46
|
+
SERVED = frozenset({200, 206, 416})
|
|
47
|
+
|
|
48
|
+
# An org's erasure deletes its calls' objects this many at a time.
|
|
49
|
+
DELETED_AT_ONCE = 16
|
|
50
|
+
|
|
51
|
+
REFUSED = "the recordings bucket {bucket} answered {status} to {what} {name}: {text}"
|
|
52
|
+
|
|
53
|
+
UNREACHABLE = "the recordings bucket {bucket} did not answer {what} {name}: {why}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
NOT_UNDER_THE_ROOT = "call {call!r} names no directory under the recordings root"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Fetched:
|
|
61
|
+
"""A recording read from the bucket: the status and headers to pass on, and its bytes."""
|
|
62
|
+
|
|
63
|
+
status: int
|
|
64
|
+
headers: dict[str, str]
|
|
65
|
+
body: AsyncIterator[bytes]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Disk:
|
|
70
|
+
"""Recordings kept on the disk they were recorded on: a box with no bucket, as always."""
|
|
71
|
+
|
|
72
|
+
root: Path
|
|
73
|
+
|
|
74
|
+
async def store(self, org: str, call: str, audio: Path) -> None:
|
|
75
|
+
"""Nothing to do: the recorder's file is where it is kept."""
|
|
76
|
+
|
|
77
|
+
async def fetch(self, org: str, call: str, byte_range: str | None) -> Fetched | None:
|
|
78
|
+
"""None: the file on the disk is the recording, and the door serves it as a file."""
|
|
79
|
+
|
|
80
|
+
async def sealed(self, org: str, call: str) -> Sealed | None:
|
|
81
|
+
"""None: a sealed file on the disk is read from where the call's summary says it is."""
|
|
82
|
+
|
|
83
|
+
async def erase(self, org: str, calls: list[str]) -> int:
|
|
84
|
+
"""Remove each call's recording directory; how many calls had one."""
|
|
85
|
+
return len(await asyncio.to_thread(_removed, self.root, calls))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Bucket:
|
|
90
|
+
"""Recordings moved to a bucket of the object store once written, the disk holding one until."""
|
|
91
|
+
|
|
92
|
+
root: Path
|
|
93
|
+
name: str
|
|
94
|
+
objects: ObjectStore
|
|
95
|
+
|
|
96
|
+
async def store(self, org: str, call: str, audio: Path) -> None:
|
|
97
|
+
"""Upload the file under the org, then remove it from the disk; raise if it stayed."""
|
|
98
|
+
what = _object_name(org, call, audio.name)
|
|
99
|
+
size, digest = await asyncio.to_thread(_measured, audio)
|
|
100
|
+
url = self.objects.url_of(self.name, what)
|
|
101
|
+
kind = "audio/ogg" if audio.name == AUDIO_FILE else "application/octet-stream"
|
|
102
|
+
wanted = {"content-type": kind, "content-length": str(size)}
|
|
103
|
+
request = self.objects.http.build_request(
|
|
104
|
+
"PUT",
|
|
105
|
+
url,
|
|
106
|
+
headers=self.objects.signed("PUT", url, wanted, digest),
|
|
107
|
+
content=_read(audio),
|
|
108
|
+
timeout=UPLOAD_TIMEOUT_S,
|
|
109
|
+
)
|
|
110
|
+
answer = await self._sent(request, "an upload of", what)
|
|
111
|
+
await answer.aread()
|
|
112
|
+
await answer.aclose()
|
|
113
|
+
if answer.status_code != HTTPStatus.OK:
|
|
114
|
+
raise self._refused("an upload of", what, answer)
|
|
115
|
+
await asyncio.to_thread(_removed, self.root, [call])
|
|
116
|
+
|
|
117
|
+
async def fetch(self, org: str, call: str, byte_range: str | None) -> Fetched | None:
|
|
118
|
+
"""The recording's bytes, the range asked for when one is; None while it is not there."""
|
|
119
|
+
return await self._fetched(_object_name(org, call, AUDIO_FILE), byte_range)
|
|
120
|
+
|
|
121
|
+
async def sealed(self, org: str, call: str) -> Sealed | None:
|
|
122
|
+
"""The call's sealed object, read by ranges; None while it is not in the bucket."""
|
|
123
|
+
what = _object_name(org, call, SEALED_FILE)
|
|
124
|
+
first = await self._fetched(what, f"bytes=0-{HEADER - 1}")
|
|
125
|
+
if first is None:
|
|
126
|
+
return None
|
|
127
|
+
header = b"".join([piece async for piece in first.body])
|
|
128
|
+
size = first.headers.get("content-range", "").rpartition("/")[2]
|
|
129
|
+
if not header.startswith(MAGIC) or not size.isdigit():
|
|
130
|
+
raise UpstreamFailed(BROKEN.format(call=call, why=f"{what} is not a sealed recording"))
|
|
131
|
+
|
|
132
|
+
async def read(offset: int, length: int) -> AsyncIterator[bytes]:
|
|
133
|
+
part = await self._fetched(what, f"bytes={offset}-{offset + length - 1}")
|
|
134
|
+
if part is None:
|
|
135
|
+
raise UpstreamFailed(BROKEN.format(call=call, why=f"{what} went while it was read"))
|
|
136
|
+
async for piece in part.body:
|
|
137
|
+
yield piece
|
|
138
|
+
|
|
139
|
+
return Sealed(call, int(size), header[len(MAGIC) :], read)
|
|
140
|
+
|
|
141
|
+
async def _fetched(self, what: str, byte_range: str | None) -> Fetched | None:
|
|
142
|
+
url = self.objects.url_of(self.name, what)
|
|
143
|
+
wanted = {} if byte_range is None else {"range": byte_range}
|
|
144
|
+
# The bytes as stored, so the length and the range passed on are the file's own.
|
|
145
|
+
headers = {**self.objects.signed("GET", url, wanted), "accept-encoding": "identity"}
|
|
146
|
+
answer = await self._sent(
|
|
147
|
+
self.objects.http.build_request("GET", url, headers=headers), "a read of", what
|
|
148
|
+
)
|
|
149
|
+
if answer.status_code not in SERVED:
|
|
150
|
+
await answer.aread()
|
|
151
|
+
await answer.aclose()
|
|
152
|
+
if answer.status_code == HTTPStatus.NOT_FOUND:
|
|
153
|
+
return None
|
|
154
|
+
raise self._refused("a read of", what, answer)
|
|
155
|
+
kept = {name: answer.headers[name] for name in PASSED_ON if name in answer.headers}
|
|
156
|
+
return Fetched(answer.status_code, kept, _streamed(answer))
|
|
157
|
+
|
|
158
|
+
async def erase(self, org: str, calls: list[str]) -> int:
|
|
159
|
+
"""Delete each call's objects, and its file where one stayed; how many calls had either."""
|
|
160
|
+
deleted: set[str] = set()
|
|
161
|
+
for start in range(0, len(calls), DELETED_AT_ONCE):
|
|
162
|
+
chunk = calls[start : start + DELETED_AT_ONCE]
|
|
163
|
+
gone = await asyncio.gather(*(self._deleted(org, call) for call in chunk))
|
|
164
|
+
deleted |= {call for call, was_there in zip(chunk, gone, strict=True) if was_there}
|
|
165
|
+
return len(deleted | await asyncio.to_thread(_removed, self.root, calls))
|
|
166
|
+
|
|
167
|
+
# S3 answers a delete of nothing as it answers a delete: each object is looked for first, so
|
|
168
|
+
# the trail counts the recordings there were.
|
|
169
|
+
async def _deleted(self, org: str, call: str) -> bool:
|
|
170
|
+
kept = [await self._deleted_named(_object_name(org, call, name)) for name in NAMES]
|
|
171
|
+
return any(kept)
|
|
172
|
+
|
|
173
|
+
async def _deleted_named(self, what: str) -> bool:
|
|
174
|
+
url = self.objects.url_of(self.name, what)
|
|
175
|
+
existing = await self._response_to("HEAD", url, what)
|
|
176
|
+
if existing.status_code == HTTPStatus.NOT_FOUND:
|
|
177
|
+
return False
|
|
178
|
+
if existing.status_code != HTTPStatus.OK:
|
|
179
|
+
raise self._refused("a delete of", what, existing)
|
|
180
|
+
answer = await self._response_to("DELETE", url, what)
|
|
181
|
+
if answer.status_code not in {HTTPStatus.OK, HTTPStatus.NO_CONTENT}:
|
|
182
|
+
raise self._refused("a delete of", what, answer)
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
async def _response_to(self, method: str, url: str, what: str) -> httpx.Response:
|
|
186
|
+
request = self.objects.http.build_request(
|
|
187
|
+
method, url, headers=self.objects.signed(method, url, {})
|
|
188
|
+
)
|
|
189
|
+
answer = await self._sent(request, "a delete of", what)
|
|
190
|
+
await answer.aread()
|
|
191
|
+
await answer.aclose()
|
|
192
|
+
return answer
|
|
193
|
+
|
|
194
|
+
async def _sent(self, request: httpx.Request, what: str, name: str) -> httpx.Response:
|
|
195
|
+
try:
|
|
196
|
+
return await self.objects.http.send(request, stream=True)
|
|
197
|
+
except httpx.HTTPError as unreachable:
|
|
198
|
+
raise UpstreamFailed(
|
|
199
|
+
UNREACHABLE.format(
|
|
200
|
+
bucket=self.name,
|
|
201
|
+
what=what,
|
|
202
|
+
name=name,
|
|
203
|
+
why=str(unreachable) or type(unreachable).__name__,
|
|
204
|
+
)
|
|
205
|
+
) from unreachable
|
|
206
|
+
|
|
207
|
+
def _refused(self, what: str, name: str, answer: httpx.Response) -> UpstreamFailed:
|
|
208
|
+
return UpstreamFailed(
|
|
209
|
+
REFUSED.format(
|
|
210
|
+
bucket=self.name,
|
|
211
|
+
status=answer.status_code,
|
|
212
|
+
what=what,
|
|
213
|
+
name=name,
|
|
214
|
+
text=answer.text[:200],
|
|
215
|
+
)
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Every object a call's recording may be kept as.
|
|
220
|
+
NAMES = (AUDIO_FILE, SEALED_FILE)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
type Recordings = Disk | Bucket
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def recordings_of(settings: Settings, http: httpx.AsyncClient) -> Recordings:
|
|
227
|
+
"""The bucket PINECALL_RECORDINGS_BUCKET names in the object store, else the disk."""
|
|
228
|
+
root = Path(settings.recordings_root)
|
|
229
|
+
objects = object_store_of(settings, http)
|
|
230
|
+
if settings.recordings_bucket is None or objects is None:
|
|
231
|
+
return Disk(root)
|
|
232
|
+
return Bucket(root, settings.recordings_bucket, objects)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# The worker's pointer says which file it kept, never where: a summary that named any path would
|
|
236
|
+
# have the gateway serve any file its own disk holds.
|
|
237
|
+
def call_directory(root: Path, call: str) -> Path | None:
|
|
238
|
+
"""The call's directory under the root; None for an id that would name a path outside it."""
|
|
239
|
+
directory = (root / call).resolve()
|
|
240
|
+
if directory == root.resolve() or not directory.is_relative_to(root.resolve()):
|
|
241
|
+
return None
|
|
242
|
+
return directory
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# Where the worker's session writes the call's audio: the one place a call's file is named.
|
|
246
|
+
def recording_path(root: Path, call: str) -> Path:
|
|
247
|
+
"""The call's audio file, in a directory of its own under the root; refused outside it."""
|
|
248
|
+
directory = call_directory(root, call)
|
|
249
|
+
if directory is None:
|
|
250
|
+
raise DeclarationRefused(NOT_UNDER_THE_ROOT.format(call=call))
|
|
251
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
252
|
+
return directory / AUDIO_FILE
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def kept_on_disk(root: Path, call: str, pointer: str) -> Path | None:
|
|
256
|
+
"""The call's recording under the root, by the name its summary gave; None for any other."""
|
|
257
|
+
name = PurePath(pointer).name
|
|
258
|
+
path = (root / call / name).resolve()
|
|
259
|
+
if name not in NAMES or not path.is_relative_to(root.resolve()):
|
|
260
|
+
return None
|
|
261
|
+
return path
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def served_sealed(sealed: Sealed, key: bytes, byte_range: str | None) -> Fetched:
|
|
265
|
+
"""A sealed recording as a player reads it: the range it asked for, or the whole, opened."""
|
|
266
|
+
size = recorded_size(sealed.size)
|
|
267
|
+
span = span_of(byte_range, size)
|
|
268
|
+
if span is not None and span.first >= size:
|
|
269
|
+
return Fetched(
|
|
270
|
+
HTTPStatus.REQUESTED_RANGE_NOT_SATISFIABLE,
|
|
271
|
+
{"content-range": f"bytes */{size}"},
|
|
272
|
+
_nothing(),
|
|
273
|
+
)
|
|
274
|
+
whole = span or Span(0, size - 1)
|
|
275
|
+
body = opened(sealed, key, whole) if size else _nothing()
|
|
276
|
+
headers = {"accept-ranges": "bytes", "content-length": str(whole.length)}
|
|
277
|
+
if span is None:
|
|
278
|
+
return Fetched(HTTPStatus.OK, headers, body)
|
|
279
|
+
shown = {**headers, "content-range": f"bytes {span.first}-{span.last}/{size}"}
|
|
280
|
+
return Fetched(HTTPStatus.PARTIAL_CONTENT, shown, body)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _object_name(org: str, call: str, name: str) -> str:
|
|
284
|
+
return f"{org}/{call}/{name}"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _measured(audio: Path) -> tuple[int, str]:
|
|
288
|
+
digest = hashlib.sha256()
|
|
289
|
+
with audio.open("rb") as source:
|
|
290
|
+
while chunk := source.read(READ_BYTES):
|
|
291
|
+
digest.update(chunk)
|
|
292
|
+
return audio.stat().st_size, digest.hexdigest()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def _read(audio: Path) -> AsyncIterator[bytes]:
|
|
296
|
+
with audio.open("rb") as source:
|
|
297
|
+
while chunk := await asyncio.to_thread(source.read, READ_BYTES):
|
|
298
|
+
yield chunk
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
async def _nothing() -> AsyncIterator[bytes]:
|
|
302
|
+
for piece in ():
|
|
303
|
+
yield piece
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def _streamed(answer: httpx.Response) -> AsyncIterator[bytes]:
|
|
307
|
+
try:
|
|
308
|
+
async for chunk in answer.aiter_bytes():
|
|
309
|
+
yield chunk
|
|
310
|
+
finally:
|
|
311
|
+
await answer.aclose()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# A recording is a directory named for its call (recording_path): one under
|
|
315
|
+
# the root, whatever the id says, or nothing is removed.
|
|
316
|
+
def _removed(root: Path, calls: list[str]) -> set[str]:
|
|
317
|
+
removed: set[str] = set()
|
|
318
|
+
for call in calls:
|
|
319
|
+
directory = call_directory(root, call)
|
|
320
|
+
if directory is not None and directory.is_dir():
|
|
321
|
+
shutil.rmtree(directory)
|
|
322
|
+
removed.add(call)
|
|
323
|
+
return removed
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""What a name resolves to: every address, so a caller can hold them all to the public internet."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import socket
|
|
5
|
+
|
|
6
|
+
import dns.exception
|
|
7
|
+
import dns.rdatatype
|
|
8
|
+
import dns.rdtypes.ANY.TXT
|
|
9
|
+
import dns.resolver
|
|
10
|
+
|
|
11
|
+
type Address = ipaddress.IPv4Address | ipaddress.IPv6Address
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# A TXT lookup waits this long in all, over every nameserver tried.
|
|
15
|
+
TXT_WITHIN_S = 5.0
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def addresses_of(host: str) -> list[Address]:
|
|
19
|
+
"""Every address the name resolves to; OSError when it resolves to none."""
|
|
20
|
+
found = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
|
21
|
+
return [ipaddress.ip_address(str(item[4][0]).partition("%")[0]) for item in found]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def all_public(addresses: list[Address]) -> bool:
|
|
25
|
+
"""Whether there is an address and every one is on the public internet."""
|
|
26
|
+
return bool(addresses) and all(address.is_global for address in addresses)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# A record of several strings is one text, as the RFC reads it; a name with no TXT, or none at all,
|
|
30
|
+
# is an empty list, like one whose nameservers did not answer in time.
|
|
31
|
+
def txt_of(name: str) -> list[str]:
|
|
32
|
+
"""Every TXT record at the name, each as one string; none when there is none or no answer."""
|
|
33
|
+
try:
|
|
34
|
+
answer = dns.resolver.resolve(name, dns.rdatatype.TXT, lifetime=TXT_WITHIN_S)
|
|
35
|
+
except dns.exception.DNSException:
|
|
36
|
+
return []
|
|
37
|
+
return [
|
|
38
|
+
b"".join(rdata.strings).decode("utf-8", errors="replace")
|
|
39
|
+
for rdata in answer
|
|
40
|
+
if isinstance(rdata, dns.rdtypes.ANY.TXT.TXT)
|
|
41
|
+
]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""A recording sealed under its own key: AES-GCM a chunk at a time, so a range opens on its own."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import AsyncIterator, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from cryptography.exceptions import InvalidTag
|
|
10
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
11
|
+
|
|
12
|
+
from pinecall.domain.errors import UpstreamFailed
|
|
13
|
+
|
|
14
|
+
# What a sealed file starts with, then the 8 random bytes every chunk's nonce begins with.
|
|
15
|
+
MAGIC = b"PCSEAL1\n"
|
|
16
|
+
|
|
17
|
+
PREFIX_BYTES = 8
|
|
18
|
+
|
|
19
|
+
# A chunk of the recording as recorded: a range opens the chunks it covers and no other, and a
|
|
20
|
+
# read holds one at a time. 64 KiB is some seconds of a call's audio.
|
|
21
|
+
CHUNK = 64 * 1024
|
|
22
|
+
|
|
23
|
+
TAG = 16
|
|
24
|
+
|
|
25
|
+
# The last chunk is sealed as the last, so a file cut short never opens as a shorter recording.
|
|
26
|
+
LAST, MORE = b"\x01", b"\x00"
|
|
27
|
+
|
|
28
|
+
KEY_BITS = 256
|
|
29
|
+
|
|
30
|
+
BROKEN = "the sealed recording of {call} does not open: {why}"
|
|
31
|
+
|
|
32
|
+
# Reads `length` bytes of the sealed object from `offset`, in whatever pieces its source yields.
|
|
33
|
+
type Read = Callable[[int, int], AsyncIterator[bytes]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Sealed:
|
|
38
|
+
"""A sealed recording where it is kept: its size as kept, its nonces' prefix, its reader."""
|
|
39
|
+
|
|
40
|
+
call: str
|
|
41
|
+
size: int
|
|
42
|
+
prefix: bytes
|
|
43
|
+
read: Read
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Span:
|
|
48
|
+
"""The bytes of the recording as recorded that a reader asked for, first and last included."""
|
|
49
|
+
|
|
50
|
+
first: int
|
|
51
|
+
last: int
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def length(self) -> int:
|
|
55
|
+
"""How many bytes the span holds."""
|
|
56
|
+
return self.last - self.first + 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# The format's name and the nonces' prefix, then each chunk and its tag.
|
|
60
|
+
HEADER = len(MAGIC) + PREFIX_BYTES
|
|
61
|
+
|
|
62
|
+
SEALED_CHUNK = CHUNK + TAG
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def new_key() -> bytes:
|
|
66
|
+
"""A recording's own key."""
|
|
67
|
+
return AESGCM.generate_key(bit_length=KEY_BITS)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Blocking, for a thread: the file is read and written a chunk at a time, never whole.
|
|
71
|
+
def seal_file(plain: Path, sealed: Path, key: bytes) -> None:
|
|
72
|
+
"""Write `sealed` from `plain` under the key, then remove `plain`."""
|
|
73
|
+
cipher = AESGCM(key)
|
|
74
|
+
prefix = os.urandom(PREFIX_BYTES)
|
|
75
|
+
part = sealed.with_name(f"{sealed.name}.part")
|
|
76
|
+
with plain.open("rb") as source, part.open("wb") as target:
|
|
77
|
+
target.write(MAGIC + prefix)
|
|
78
|
+
chunk, index = source.read(CHUNK), 0
|
|
79
|
+
while True:
|
|
80
|
+
following = source.read(CHUNK)
|
|
81
|
+
flag = MORE if following else LAST
|
|
82
|
+
target.write(cipher.encrypt(_nonce(prefix, index), chunk, flag))
|
|
83
|
+
if not following:
|
|
84
|
+
break
|
|
85
|
+
chunk, index = following, index + 1
|
|
86
|
+
target.flush()
|
|
87
|
+
os.fsync(target.fileno())
|
|
88
|
+
part.rename(sealed)
|
|
89
|
+
plain.unlink()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def recorded_size(sealed_size: int) -> int:
|
|
93
|
+
"""How many bytes the recording had, from its sealed object's size."""
|
|
94
|
+
full, rest = divmod(sealed_size - HEADER, SEALED_CHUNK)
|
|
95
|
+
return full * CHUNK + max(rest - TAG, 0)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def span_of(byte_range: str | None, size: int) -> Span | None:
|
|
99
|
+
"""The one range a player asked for within the recording; None for the whole of it."""
|
|
100
|
+
if byte_range is None or not byte_range.startswith("bytes=") or "," in byte_range:
|
|
101
|
+
return None
|
|
102
|
+
first, _, last = byte_range.removeprefix("bytes=").strip().partition("-")
|
|
103
|
+
if not (first or last) or not all(bound.isdigit() for bound in (first, last) if bound):
|
|
104
|
+
return None
|
|
105
|
+
if not first:
|
|
106
|
+
return Span(max(size - int(last), 0), size - 1)
|
|
107
|
+
# A range that ends before it starts is no range: the whole is answered, as HTTP says.
|
|
108
|
+
if last and int(last) < int(first):
|
|
109
|
+
return None
|
|
110
|
+
return Span(int(first), min(int(last), size - 1) if last else size - 1)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def opened(sealed: Sealed, key: bytes, span: Span) -> AsyncIterator[bytes]:
|
|
114
|
+
"""The span's bytes as recorded, opening only the chunks it covers, one at a time."""
|
|
115
|
+
chunks = -(-(sealed.size - HEADER) // SEALED_CHUNK)
|
|
116
|
+
first, last = span.first // CHUNK, span.last // CHUNK
|
|
117
|
+
start = HEADER + first * SEALED_CHUNK
|
|
118
|
+
end = min(HEADER + (last + 1) * SEALED_CHUNK, sealed.size)
|
|
119
|
+
cipher = AESGCM(key)
|
|
120
|
+
index = first
|
|
121
|
+
async for piece in _in_chunks(sealed.read(start, end - start)):
|
|
122
|
+
flag = LAST if index == chunks - 1 else MORE
|
|
123
|
+
try:
|
|
124
|
+
plain = cipher.decrypt(_nonce(sealed.prefix, index), piece, flag)
|
|
125
|
+
except InvalidTag:
|
|
126
|
+
raise UpstreamFailed(BROKEN.format(call=sealed.call, why=f"chunk {index}")) from None
|
|
127
|
+
low = span.first - index * CHUNK if index == first else 0
|
|
128
|
+
high = span.last - index * CHUNK + 1 if index == last else len(plain)
|
|
129
|
+
yield plain[low:high]
|
|
130
|
+
index += 1
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def on_disk(call: str, path: Path) -> Sealed | None:
|
|
134
|
+
"""The sealed file at the path, read a chunk at a time off the loop; None when absent."""
|
|
135
|
+
try:
|
|
136
|
+
header, size = await asyncio.to_thread(_head_of, path)
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
return None
|
|
139
|
+
if not header.startswith(MAGIC):
|
|
140
|
+
raise UpstreamFailed(BROKEN.format(call=call, why=f"{path} is not a sealed recording"))
|
|
141
|
+
|
|
142
|
+
async def read(offset: int, length: int) -> AsyncIterator[bytes]:
|
|
143
|
+
with await asyncio.to_thread(path.open, "rb") as file:
|
|
144
|
+
await asyncio.to_thread(file.seek, offset)
|
|
145
|
+
left = length
|
|
146
|
+
while left > 0:
|
|
147
|
+
piece = await asyncio.to_thread(file.read, min(SEALED_CHUNK, left))
|
|
148
|
+
if not piece:
|
|
149
|
+
return
|
|
150
|
+
left -= len(piece)
|
|
151
|
+
yield piece
|
|
152
|
+
|
|
153
|
+
return Sealed(call, size, header[len(MAGIC) :], read)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _head_of(path: Path) -> tuple[bytes, int]:
|
|
157
|
+
with path.open("rb") as file:
|
|
158
|
+
return file.read(HEADER), path.stat().st_size
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _nonce(prefix: bytes, index: int) -> bytes:
|
|
162
|
+
return prefix + index.to_bytes(4, "big")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def _in_chunks(pieces: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
|
|
166
|
+
waiting = bytearray()
|
|
167
|
+
async for piece in pieces:
|
|
168
|
+
waiting += piece
|
|
169
|
+
while len(waiting) >= SEALED_CHUNK:
|
|
170
|
+
yield bytes(waiting[:SEALED_CHUNK])
|
|
171
|
+
del waiting[:SEALED_CHUNK]
|
|
172
|
+
if waiting:
|
|
173
|
+
yield bytes(waiting)
|