luckiest-co 1.0.47 → 1.0.49
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/skills/trends/scripts/lib/reddit_rss.py +0 -224
|
@@ -16,7 +16,7 @@ from __future__ import annotations
|
|
|
16
16
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
17
17
|
from typing import Callable
|
|
18
18
|
|
|
19
|
-
from . import log, schema
|
|
19
|
+
from . import log, schema, youtube_yt
|
|
20
20
|
|
|
21
21
|
# Sub-runs hit the same upstream APIs as the main topic. Cap parallelism so a
|
|
22
22
|
# 6-way fan-out does not stampede a single backend's rate limit.
|
|
@@ -46,11 +46,21 @@ def run_competitor_fanout(
|
|
|
46
46
|
Ordered list of (entity_name, Report) tuples for runs that succeeded.
|
|
47
47
|
Empty list if every run raised; the caller decides how to surface
|
|
48
48
|
partial-failure modes.
|
|
49
|
+
|
|
50
|
+
``main_topic`` is NOT guaranteed to be present: a main run that raised
|
|
51
|
+
is dropped like any other. Since the render treats element 0 as the
|
|
52
|
+
comparison's subject, a caller must verify ``main_topic`` survived
|
|
53
|
+
before using the list, or it will silently head the report with a
|
|
54
|
+
competitor.
|
|
49
55
|
"""
|
|
50
56
|
if not competitors:
|
|
51
57
|
report = main_runner()
|
|
52
58
|
return [(main_topic, report)]
|
|
53
59
|
|
|
60
|
+
# One clear for the whole comparison so entity sub-runs share the YouTube
|
|
61
|
+
# search cache without inheriting a prior run's results in this process.
|
|
62
|
+
youtube_yt.reset_search_cache()
|
|
63
|
+
|
|
54
64
|
workers = min(len(competitors) + 1, MAX_PARALLEL_SUBRUNS)
|
|
55
65
|
|
|
56
66
|
def _run_one(label: str, fn: Callable[[], schema.Report]) -> tuple[str, schema.Report | None, Exception | None]:
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Deterministic Atom rendering for the saved research library."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from xml.etree import ElementTree as ET
|
|
8
|
+
|
|
9
|
+
from .library import LibraryEntry
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ATOM_NS = "http://www.w3.org/2005/Atom"
|
|
13
|
+
ET.register_namespace("", ATOM_NS)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_atom(
|
|
17
|
+
entries: Sequence[LibraryEntry],
|
|
18
|
+
*,
|
|
19
|
+
library_id: str,
|
|
20
|
+
entry_urls: Mapping[str, str] | None = None,
|
|
21
|
+
feed_url: str | None = None,
|
|
22
|
+
title: str = "last30days research library",
|
|
23
|
+
author: str = "last30days research library",
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Render an Atom feed whose IDs and timestamps are stable across runs."""
|
|
26
|
+
urls = entry_urls or {}
|
|
27
|
+
feed_id = f"urn:last30days:research-library:{library_id}"
|
|
28
|
+
root = ET.Element(_tag("feed"))
|
|
29
|
+
ET.SubElement(root, _tag("id")).text = feed_id
|
|
30
|
+
ET.SubElement(root, _tag("title")).text = title
|
|
31
|
+
author_node = ET.SubElement(root, _tag("author"))
|
|
32
|
+
ET.SubElement(author_node, _tag("name")).text = author
|
|
33
|
+
updated = max((item.source_updated_at for item in entries), default=None)
|
|
34
|
+
ET.SubElement(root, _tag("updated")).text = (
|
|
35
|
+
_format_timestamp(updated) if updated else "1970-01-01T00:00:00Z"
|
|
36
|
+
)
|
|
37
|
+
if feed_url:
|
|
38
|
+
ET.SubElement(root, _tag("link"), {"rel": "self", "href": feed_url})
|
|
39
|
+
|
|
40
|
+
for item in entries:
|
|
41
|
+
node = ET.SubElement(root, _tag("entry"))
|
|
42
|
+
entry_id = item.entry_id.removeprefix("urn:last30days:")
|
|
43
|
+
ET.SubElement(node, _tag("id")).text = f"{feed_id}:{entry_id}"
|
|
44
|
+
ET.SubElement(node, _tag("title")).text = item.headline
|
|
45
|
+
ET.SubElement(node, _tag("updated")).text = _format_timestamp(item.source_updated_at)
|
|
46
|
+
ET.SubElement(node, _tag("published")).text = f"{item.published_date.isoformat()}T00:00:00Z"
|
|
47
|
+
ET.SubElement(node, _tag("category"), {"term": item.topic})
|
|
48
|
+
url = urls.get(item.entry_id, f"briefs/{item.output_name}")
|
|
49
|
+
ET.SubElement(node, _tag("link"), {"href": url})
|
|
50
|
+
ET.SubElement(node, _tag("summary"), {"type": "text"}).text = item.summary
|
|
51
|
+
|
|
52
|
+
ET.indent(root, space=" ")
|
|
53
|
+
return '<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(root, encoding="unicode") + "\n"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _tag(name: str) -> str:
|
|
57
|
+
return f"{{{ATOM_NS}}}{name}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _format_timestamp(value: datetime) -> str:
|
|
61
|
+
return value.isoformat().replace("+00:00", "Z")
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Run-scoped memo for source fetches.
|
|
2
|
+
|
|
3
|
+
Subquery streams for TikTok, Instagram, YouTube, and X all search the raw
|
|
4
|
+
topic, and the thin-source retry repeats that search (and, for Polymarket and
|
|
5
|
+
Digg, a planner subquery that equals the retry's core subject), so one run can
|
|
6
|
+
issue the same request several times. :func:`fetch_once` runs a fetch at most once per
|
|
7
|
+
key: the first caller fetches, concurrent callers wait for it (bounded by
|
|
8
|
+
their own timeout), and later callers get a copy of the cached result.
|
|
9
|
+
|
|
10
|
+
Only clean results are cached. A failure (a raised exception, a result the
|
|
11
|
+
caller's ``cacheable`` predicate rejects, or HTTP failures an adapter
|
|
12
|
+
swallowed into the failure sink) reaches only the callers already waiting on
|
|
13
|
+
that fetch; the next call fetches again, so the thin-source retry still makes
|
|
14
|
+
a real second attempt after a transient failure.
|
|
15
|
+
|
|
16
|
+
Reset once per command (``last30days.py``) and between tests (``conftest``).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import copy
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any, Callable, Hashable, TypeVar
|
|
25
|
+
|
|
26
|
+
from . import health, http
|
|
27
|
+
|
|
28
|
+
T = TypeVar("T")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MemoWaitTimeout(TimeoutError):
|
|
32
|
+
"""A waiter's budget expired before the in-flight fetch it shares finished."""
|
|
33
|
+
|
|
34
|
+
outcome_state = health.TIMEOUT
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Slot:
|
|
38
|
+
__slots__ = ("event", "outcome")
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self.event = threading.Event()
|
|
42
|
+
# (ok, value_or_exception, recorded_failures) once the owner finishes.
|
|
43
|
+
self.outcome: tuple[bool, Any, list] | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_CACHE: dict[Hashable, Any] = {}
|
|
47
|
+
_INFLIGHT: dict[Hashable, _Slot] = {}
|
|
48
|
+
_LOCK = threading.Lock()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def reset() -> None:
|
|
52
|
+
"""Forget cached and in-flight fetches. Called once per command, and by tests."""
|
|
53
|
+
with _LOCK:
|
|
54
|
+
_CACHE.clear()
|
|
55
|
+
_INFLIGHT.clear()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _always(_value: Any) -> bool:
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _deliver_shared(outcome: tuple[bool, Any, list]) -> Any:
|
|
63
|
+
ok, value, failures = outcome
|
|
64
|
+
# The owner's swallowed HTTP failures reach this caller's sink too, or a
|
|
65
|
+
# shared empty result reads as a clean no-results.
|
|
66
|
+
for failure in failures:
|
|
67
|
+
http._record_failure(failure)
|
|
68
|
+
if not ok:
|
|
69
|
+
raise value
|
|
70
|
+
return copy.deepcopy(value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fetch_once(
|
|
74
|
+
key: Hashable,
|
|
75
|
+
fetch: Callable[[], T],
|
|
76
|
+
*,
|
|
77
|
+
timeout: float,
|
|
78
|
+
cacheable: Callable[[T], bool] = _always,
|
|
79
|
+
) -> T:
|
|
80
|
+
"""Return ``fetch()``, running it at most once per ``key`` while it stays clean.
|
|
81
|
+
|
|
82
|
+
``key`` must include every input that changes the request. ``timeout``
|
|
83
|
+
bounds how long a caller waits on another caller's in-flight fetch; past
|
|
84
|
+
it, :class:`MemoWaitTimeout` is raised. Every caller gets its own copy.
|
|
85
|
+
"""
|
|
86
|
+
deadline = time.monotonic() + max(0.0, timeout)
|
|
87
|
+
while True:
|
|
88
|
+
with _LOCK:
|
|
89
|
+
hit = key in _CACHE
|
|
90
|
+
if hit:
|
|
91
|
+
cached = _CACHE[key]
|
|
92
|
+
else:
|
|
93
|
+
slot = _INFLIGHT.get(key)
|
|
94
|
+
owner = slot is None
|
|
95
|
+
if owner:
|
|
96
|
+
slot = _Slot()
|
|
97
|
+
_INFLIGHT[key] = slot
|
|
98
|
+
if hit:
|
|
99
|
+
return copy.deepcopy(cached)
|
|
100
|
+
if owner:
|
|
101
|
+
break
|
|
102
|
+
if not slot.event.wait(max(0.0, deadline - time.monotonic())):
|
|
103
|
+
raise MemoWaitTimeout(
|
|
104
|
+
f"waited {timeout:g}s for an identical in-flight fetch"
|
|
105
|
+
)
|
|
106
|
+
if slot.outcome is not None:
|
|
107
|
+
return _deliver_shared(slot.outcome)
|
|
108
|
+
# The owner was interrupted before publishing (e.g. KeyboardInterrupt):
|
|
109
|
+
# loop and elect a new owner.
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
with http.tee_failures() as recorded:
|
|
113
|
+
try:
|
|
114
|
+
value = fetch()
|
|
115
|
+
ok = True
|
|
116
|
+
except Exception as exc:
|
|
117
|
+
value = exc
|
|
118
|
+
ok = False
|
|
119
|
+
clean = ok and not recorded and cacheable(value)
|
|
120
|
+
# Waiters and the cache copy from this snapshot, never from the
|
|
121
|
+
# owner's own value, which the owner's caller may mutate.
|
|
122
|
+
shared = copy.deepcopy(value) if ok else value
|
|
123
|
+
slot.outcome = (ok, shared, list(recorded))
|
|
124
|
+
with _LOCK:
|
|
125
|
+
# Slot identity guards against a reset() during the fetch.
|
|
126
|
+
if clean and _INFLIGHT.get(key) is slot:
|
|
127
|
+
_CACHE[key] = shared
|
|
128
|
+
finally:
|
|
129
|
+
with _LOCK:
|
|
130
|
+
if _INFLIGHT.get(key) is slot:
|
|
131
|
+
_INFLIGHT.pop(key)
|
|
132
|
+
slot.event.set()
|
|
133
|
+
if not ok:
|
|
134
|
+
raise value
|
|
135
|
+
return value
|