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.
Files changed (274) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. 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