hyperagents 1.2.3rc5.post3__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.
- agents/__init__.py +31 -0
- agents/background_monitor.py +453 -0
- agents/learning.py +460 -0
- agents/planner.py +421 -0
- agents/transcriber.py +341 -0
- civilization/__init__.py +51 -0
- civilization/api/__init__.py +7 -0
- civilization/api/civilization_api.py +361 -0
- civilization/graph/__init__.py +19 -0
- civilization/graph/knowledge_linker.py +348 -0
- civilization/graph/org_graph.py +348 -0
- civilization/graph/process_graph.py +386 -0
- civilization/ingestion/__init__.py +20 -0
- civilization/ingestion/chunker.py +273 -0
- civilization/ingestion/document_ingestor.py +358 -0
- civilization/ingestion/embedder.py +262 -0
- civilization/ingestion/extractor.py +224 -0
- civilization/interview/__init__.py +19 -0
- civilization/interview/gap_detector.py +380 -0
- civilization/interview/interviewer.py +323 -0
- civilization/interview/questioner.py +363 -0
- civilization/retrieval/__init__.py +17 -0
- civilization/retrieval/civilization_rag.py +316 -0
- civilization/retrieval/context_injector.py +342 -0
- civilization/retrieval/relevance_ranker.py +287 -0
- civilization/schema.py +299 -0
- civilization/store.py +177 -0
- civilization/sync/__init__.py +16 -0
- civilization/sync/confluence_sync.py +317 -0
- civilization/sync/gdrive_sync.py +262 -0
- civilization/sync/notion_sync.py +237 -0
- civilization/versioning/__init__.py +19 -0
- civilization/versioning/diff_engine.py +351 -0
- civilization/versioning/staleness_detector.py +303 -0
- civilization/versioning/version_manager.py +293 -0
- cli/__init__.py +1 -0
- cli/doctor.py +334 -0
- cli/hyperclaw.py +1357 -0
- cli/onboarding.py +469 -0
- core/__init__.py +1 -0
- core/connectors/__init__.py +119 -0
- core/connectors/infrastructure.py +772 -0
- core/connectors/messaging.py +586 -0
- core/connectors/productivity.py +764 -0
- core/connectors/smarthome.py +528 -0
- core/connectors/social.py +730 -0
- core/hyperrouter/__init__.py +23 -0
- core/hyperrouter/bandit.py +218 -0
- core/hyperrouter/fast_loop.py +68 -0
- core/hyperrouter/slow_loop.py +143 -0
- core/hyperstate/__init__.py +24 -0
- core/hyperstate/certifier.py +154 -0
- core/hyperstate/schema.py +160 -0
- core/hyperstate/state_manager.py +103 -0
- core/hyperstate/store.py +163 -0
- core/providers/__init__.py +121 -0
- core/providers/embeddings.py +389 -0
- core/providers/image.py +534 -0
- core/providers/llm.py +1935 -0
- core/providers/search.py +608 -0
- core/providers/voice.py +474 -0
- hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
- hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
- hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
- hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
- hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
- hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
- hyperclaw/__init__.py +67 -0
- hyperclaw/__main__.py +220 -0
- hyperclaw/agent.py +775 -0
- hyperclaw/agent_coordinator.py +577 -0
- hyperclaw/anthropic_params.py +225 -0
- hyperclaw/api_utils.py +85 -0
- hyperclaw/approval_telegram.py +209 -0
- hyperclaw/approval_terminal.py +80 -0
- hyperclaw/attachments.py +177 -0
- hyperclaw/branding.py +55 -0
- hyperclaw/brief/__init__.py +11 -0
- hyperclaw/brief/cli.py +204 -0
- hyperclaw/brief/collect.py +146 -0
- hyperclaw/brief/compose.py +333 -0
- hyperclaw/brief/diff.py +110 -0
- hyperclaw/brief/errors.py +99 -0
- hyperclaw/brief/google.py +269 -0
- hyperclaw/brief/google_auth.py +294 -0
- hyperclaw/brief/links.py +124 -0
- hyperclaw/brief/service.py +424 -0
- hyperclaw/brief/store.py +205 -0
- hyperclaw/brief/tasks.py +200 -0
- hyperclaw/chat_scope.py +115 -0
- hyperclaw/cli_tui.py +497 -0
- hyperclaw/client_guard.py +717 -0
- hyperclaw/connection_check.py +113 -0
- hyperclaw/cost_guard.py +1728 -0
- hyperclaw/default_config/agents.yaml +534 -0
- hyperclaw/default_config/hyperclaw.yaml +27 -0
- hyperclaw/default_config/models.yaml +161 -0
- hyperclaw/deployment.py +39 -0
- hyperclaw/digest/__init__.py +9 -0
- hyperclaw/digest/cli.py +117 -0
- hyperclaw/digest/errors.py +100 -0
- hyperclaw/digest/gitlab.py +187 -0
- hyperclaw/digest/http.py +113 -0
- hyperclaw/digest/redact.py +32 -0
- hyperclaw/digest/service.py +271 -0
- hyperclaw/digest/slack.py +223 -0
- hyperclaw/digest/state.py +124 -0
- hyperclaw/digest/summarize.py +198 -0
- hyperclaw/digest/tokens.py +143 -0
- hyperclaw/distribution.py +46 -0
- hyperclaw/documents.py +351 -0
- hyperclaw/feeds.py +339 -0
- hyperclaw/google_workspace/__init__.py +5 -0
- hyperclaw/google_workspace/api.py +151 -0
- hyperclaw/google_workspace/auth.py +397 -0
- hyperclaw/google_workspace/calendar.py +157 -0
- hyperclaw/google_workspace/cli.py +72 -0
- hyperclaw/google_workspace/drive.py +236 -0
- hyperclaw/google_workspace/errors.py +58 -0
- hyperclaw/google_workspace/gmail.py +295 -0
- hyperclaw/google_workspace/meet.py +122 -0
- hyperclaw/google_workspace/tools.py +319 -0
- hyperclaw/hyperspeed.py +57 -0
- hyperclaw/integrations_layer.py +1427 -0
- hyperclaw/live_state.py +188 -0
- hyperclaw/managed_config.py +245 -0
- hyperclaw/media_hub.py +179 -0
- hyperclaw/memory_manager.py +584 -0
- hyperclaw/model_catalog.py +551 -0
- hyperclaw/model_policy.py +211 -0
- hyperclaw/model_router.py +595 -0
- hyperclaw/model_selector.py +72 -0
- hyperclaw/naming.py +284 -0
- hyperclaw/notices.py +65 -0
- hyperclaw/oai_stream.py +106 -0
- hyperclaw/orchestrator.py +591 -0
- hyperclaw/outbox.py +82 -0
- hyperclaw/persona.py +367 -0
- hyperclaw/prometheus.py +219 -0
- hyperclaw/prompt_assembly.py +157 -0
- hyperclaw/provider_setup.py +146 -0
- hyperclaw/providers.py +340 -0
- hyperclaw/qualification.py +341 -0
- hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
- hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
- hyperclaw/reasoning_display.py +35 -0
- hyperclaw/scheduler.py +454 -0
- hyperclaw/server.py +847 -0
- hyperclaw/setup.py +627 -0
- hyperclaw/skills.py +717 -0
- hyperclaw/solomon.py +442 -0
- hyperclaw/swarm.py +533 -0
- hyperclaw/taint.py +832 -0
- hyperclaw/team/__init__.py +28 -0
- hyperclaw/team/dispatcher.py +431 -0
- hyperclaw/team/genesis.py +106 -0
- hyperclaw/team/lead.py +110 -0
- hyperclaw/team/model.py +135 -0
- hyperclaw/team/morning.py +238 -0
- hyperclaw/team/repos.py +66 -0
- hyperclaw/team/roster.py +264 -0
- hyperclaw/team/specialist.py +235 -0
- hyperclaw/team/status.py +163 -0
- hyperclaw/team/watchers.py +112 -0
- hyperclaw/teamsetup/__init__.py +17 -0
- hyperclaw/teamsetup/checks.py +208 -0
- hyperclaw/teamsetup/item.py +391 -0
- hyperclaw/teamsetup/oauth_loopback.py +84 -0
- hyperclaw/teamsetup/onboard.py +550 -0
- hyperclaw/teamsetup/profile.py +82 -0
- hyperclaw/teamsetup/secrets.py +313 -0
- hyperclaw/teamsetup/selfcheck.py +295 -0
- hyperclaw/teamsetup/service.py +495 -0
- hyperclaw/teamsetup/slack_oauth.py +442 -0
- hyperclaw/teamsetup/tty.py +62 -0
- hyperclaw/telegram_bot.py +1106 -0
- hyperclaw/telegram_intake.py +126 -0
- hyperclaw/telegram_pairing.py +360 -0
- hyperclaw/terminal_provider.py +323 -0
- hyperclaw/tools/__init__.py +1 -0
- hyperclaw/tools/approval.py +949 -0
- hyperclaw/tools/args.py +212 -0
- hyperclaw/tools/catalog.py +244 -0
- hyperclaw/tools/classify.py +266 -0
- hyperclaw/tools/effects.py +724 -0
- hyperclaw/tools/egress.py +210 -0
- hyperclaw/tools/envelope.py +188 -0
- hyperclaw/tools/guard.py +367 -0
- hyperclaw/tools/ledger.py +339 -0
- hyperclaw/tools/receipts.py +70 -0
- hyperclaw/tools/registry.py +318 -0
- hyperclaw/tools/service_guard.py +151 -0
- hyperclaw/tools/surfaces.py +54 -0
- hyperclaw/tools/workspace.py +87 -0
- hyperclaw/trading/__init__.py +77 -0
- hyperclaw/truth_guard.py +791 -0
- hyperclaw/tts.py +124 -0
- hyperclaw/tui.py +4255 -0
- hyperclaw/tui_bridge.py +938 -0
- hyperclaw/unattended.py +60 -0
- hyperclaw/updates/__init__.py +54 -0
- hyperclaw/updates/check.py +131 -0
- hyperclaw/updates/index.py +178 -0
- hyperclaw/updates/install.py +110 -0
- hyperclaw/updates/managed.py +161 -0
- hyperclaw/updates/manifest.py +138 -0
- hyperclaw/updates/notices.py +86 -0
- hyperclaw/updates/paths.py +53 -0
- hyperclaw/updates/release.py +146 -0
- hyperclaw/updates/upgrade.py +253 -0
- hyperclaw/updates/versioning.py +104 -0
- hyperclaw/userconfig.py +119 -0
- hyperclaw/xai.py +83 -0
- integrations/__init__.py +31 -0
- integrations/automation/__init__.py +1 -0
- integrations/automation/make.py +87 -0
- integrations/automation/zapier.py +87 -0
- integrations/base.py +476 -0
- integrations/communication/__init__.py +1 -0
- integrations/communication/twilio.py +279 -0
- integrations/communication/zoom.py +290 -0
- integrations/data/__init__.py +1 -0
- integrations/data/google_analytics.py +135 -0
- integrations/data/postgres.py +182 -0
- integrations/data/supabase_connector.py +202 -0
- integrations/data/weather_feeds.py +450 -0
- integrations/developer/__init__.py +9 -0
- integrations/developer/github.py +406 -0
- integrations/developer/gitlab.py +298 -0
- integrations/enterprise/__init__.py +13 -0
- integrations/enterprise/confluence.py +266 -0
- integrations/enterprise/hubspot.py +293 -0
- integrations/enterprise/jira.py +314 -0
- integrations/enterprise/salesforce.py +286 -0
- integrations/finance/__init__.py +1 -0
- integrations/finance/polymarket_client.py +247 -0
- integrations/finance/quickbooks.py +281 -0
- integrations/finance/stripe.py +261 -0
- integrations/gateway.py +238 -0
- integrations/messaging/__init__.py +32 -0
- integrations/messaging/discord.py +159 -0
- integrations/messaging/email.py +401 -0
- integrations/messaging/imessage.py +443 -0
- integrations/messaging/signal.py +175 -0
- integrations/messaging/slack.py +217 -0
- integrations/messaging/sms.py +175 -0
- integrations/messaging/teams.py +208 -0
- integrations/messaging/telegram.py +260 -0
- integrations/messaging/whatsapp.py +203 -0
- integrations/productivity/__init__.py +17 -0
- integrations/productivity/airtable.py +217 -0
- integrations/productivity/asana.py +248 -0
- integrations/productivity/google/__init__.py +19 -0
- integrations/productivity/google/base.py +89 -0
- integrations/productivity/google/calendar.py +272 -0
- integrations/productivity/google/docs.py +202 -0
- integrations/productivity/google/drive.py +268 -0
- integrations/productivity/google/gmail.py +279 -0
- integrations/productivity/google/meet.py +218 -0
- integrations/productivity/google/sheets.py +222 -0
- integrations/productivity/google/tasks.py +199 -0
- integrations/productivity/linear.py +279 -0
- integrations/productivity/microsoft/__init__.py +13 -0
- integrations/productivity/microsoft/base.py +93 -0
- integrations/productivity/microsoft/calendar.py +207 -0
- integrations/productivity/microsoft/onedrive.py +222 -0
- integrations/productivity/microsoft/outlook.py +241 -0
- integrations/productivity/microsoft/sharepoint.py +211 -0
- integrations/productivity/notion.py +243 -0
- integrations/productivity/todoist.py +250 -0
- integrations/productivity/trello.py +244 -0
- integrations/storage/__init__.py +1 -0
- integrations/storage/box.py +279 -0
- integrations/storage/dropbox.py +215 -0
- integrations/telegram_inbound.py +179 -0
- integrations/telegram_poller.py +151 -0
- memory/__init__.py +1 -0
- memory/causal_graph.py +125 -0
- memory/impact_tracker.py +88 -0
- memory/vector_memory.py +436 -0
- models/__init__.py +12 -0
- models/chatjimmy_client.py +210 -0
- models/claude_client.py +235 -0
- models/claude_code_subagent.py +192 -0
- models/openai_client.py +120 -0
- models/router.py +162 -0
- recursive/discovery_loop.py +118 -0
- recursive/optimization_loop.py +135 -0
- recursive/validation_loop.py +38 -0
- security/__init__.py +1 -0
- security/audit_logger.py +106 -0
- security/filesystem_guard.py +100 -0
- security/hypershield.py +99 -0
- security/network_guard.py +97 -0
- security/policy_engine.py +198 -0
- swarm/__init__.py +1 -0
- swarm/agents/__init__.py +0 -0
- swarm/agents/base.py +230 -0
- swarm/agents/business/__init__.py +0 -0
- swarm/agents/business/counsel.py +31 -0
- swarm/agents/business/herald.py +32 -0
- swarm/agents/business/ledger.py +26 -0
- swarm/agents/business/nexus.py +30 -0
- swarm/agents/business/ops.py +30 -0
- swarm/agents/business/pipeline.py +28 -0
- swarm/agents/business/revenue.py +30 -0
- swarm/agents/business/sovereign.py +31 -0
- swarm/agents/business/strategos.py +27 -0
- swarm/agents/business/talent.py +27 -0
- swarm/agents/business/venture.py +32 -0
- swarm/agents/comms/__init__.py +1 -0
- swarm/agents/comms/cipher.py +32 -0
- swarm/agents/comms/echo_comms.py +31 -0
- swarm/agents/comms/envoy.py +31 -0
- swarm/agents/comms/herald_scheduler.py +33 -0
- swarm/agents/comms/pulse.py +29 -0
- swarm/agents/creative/__init__.py +0 -0
- swarm/agents/creative/author.py +27 -0
- swarm/agents/creative/lens.py +25 -0
- swarm/agents/executive/__init__.py +1 -0
- swarm/agents/intelligence/__init__.py +1 -0
- swarm/agents/intelligence/arbiter.py +30 -0
- swarm/agents/intelligence/sentinel.py +30 -0
- swarm/agents/media/__init__.py +0 -0
- swarm/agents/personal/__init__.py +0 -0
- swarm/agents/personal/atlas.py +27 -0
- swarm/agents/personal/hearth.py +26 -0
- swarm/agents/personal/midas.py +27 -0
- swarm/agents/personal/navigator.py +27 -0
- swarm/agents/personal/nourish.py +31 -0
- swarm/agents/personal/vitals.py +33 -0
- swarm/agents/recursive/__init__.py +0 -0
- swarm/agents/recursive/alchemist.py +73 -0
- swarm/agents/recursive/calibrator.py +45 -0
- swarm/agents/recursive/scout.py +97 -0
- swarm/agents/scientific/__init__.py +0 -0
- swarm/agents/scientific/cosmos.py +36 -0
- swarm/agents/scientific/gaia.py +26 -0
- swarm/agents/scientific/medicus.py +33 -0
- swarm/agents/scientific/oracle_agent.py +39 -0
- swarm/agents/scientific/scribe.py +39 -0
- swarm/agents/talent/__init__.py +0 -0
- swarm/agents/talent/deal.py +28 -0
- swarm/agents/talent/roster.py +29 -0
- swarm/agents/talent/scout.py +29 -0
- swarm/agents/talent/stage.py +27 -0
- swarm/agents/tech/__init__.py +0 -0
- swarm/agents/tech/aegis.py +28 -0
- swarm/agents/tech/bridge.py +28 -0
- swarm/agents/tech/forge.py +28 -0
- swarm/agents/trading/__init__.py +1 -0
- swarm/agents/trading/global_prediction_engine.py +611 -0
- swarm/agents/trading/polymarket_trader.py +165 -0
- swarm/agents/trading/prediction_strategist.py +218 -0
- swarm/autogen_bridge.py +68 -0
- swarm/bid_protocol.py +196 -0
- swarm/dispatcher.py +527 -0
- swarm/nexus.py +181 -0
- swarm/registry.py +112 -0
- telegram_listener.py +403 -0
- ui/tui/app.py +214 -0
hyperclaw/unattended.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Unattended mode: one switch for everything HyperClaw does on its own.
|
|
2
|
+
|
|
3
|
+
``HYPERCLAW_UNATTENDED=1`` turns on the scheduled jobs (heartbeat, morning
|
|
4
|
+
brief, memory consolidation, ...) and Telegram polling. Anything else,
|
|
5
|
+
including ``true``, ``yes``, ``0`` or an empty/unset value, leaves them OFF.
|
|
6
|
+
Off is the default (Vaughn's rc3 ruling, 2026-10-09: internal team only,
|
|
7
|
+
overnight and unattended tasks switched off).
|
|
8
|
+
|
|
9
|
+
The interactive terminal (``hyperclaw``, ``python -m hyperclaw tui``) and
|
|
10
|
+
request-driven API endpoints do not depend on this flag.
|
|
11
|
+
|
|
12
|
+
When a background entry point starts with the flag off it logs ONE line
|
|
13
|
+
(``OFF_LINE``) and does nothing: the server keeps serving, a stand-alone
|
|
14
|
+
poller exits with status 0 so launchd (``KeepAlive.SuccessfulExit=false``)
|
|
15
|
+
does not restart it.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
ENV = "HYPERCLAW_UNATTENDED"
|
|
24
|
+
|
|
25
|
+
OFF_LINE = ("Unattended mode is off: scheduled jobs and Telegram polling are not "
|
|
26
|
+
"started. To turn them on, set HYPERCLAW_UNATTENDED=1 (only the exact "
|
|
27
|
+
"value 1 enables it).")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def enabled() -> bool:
|
|
31
|
+
"""True only when HYPERCLAW_UNATTENDED is exactly ``1``."""
|
|
32
|
+
return os.environ.get(ENV, "") == "1"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_said = False # one line per process, however many entry points ask
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def log_off(logger: logging.Logger | None = None, what: str = "") -> bool:
|
|
39
|
+
"""Log the 'unattended mode is off' line, once per process. Returns
|
|
40
|
+
whether it logged now."""
|
|
41
|
+
global _said
|
|
42
|
+
if _said:
|
|
43
|
+
return False
|
|
44
|
+
_said = True
|
|
45
|
+
line = f"{OFF_LINE} ({what})" if what else OFF_LINE
|
|
46
|
+
(logger or logging.getLogger("hyperclaw.unattended")).warning(line)
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def exit_if_off(logger: logging.Logger | None = None, what: str = "") -> None:
|
|
51
|
+
"""For stand-alone pollers/daemons: when the flag is off, log the line and
|
|
52
|
+
exit 0 (a clean exit, so a KeepAlive/on-failure supervisor does not
|
|
53
|
+
restart it)."""
|
|
54
|
+
if enabled():
|
|
55
|
+
return
|
|
56
|
+
if logging.getLogger().handlers or (logger is not None and logger.handlers):
|
|
57
|
+
log_off(logger, what)
|
|
58
|
+
else: # logging not configured yet: still say it, once, on stderr
|
|
59
|
+
print(OFF_LINE + (f" ({what})" if what else ""), file=sys.stderr)
|
|
60
|
+
raise SystemExit(0)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""HyperClaw terminal updates (docs/UPDATES.md).
|
|
2
|
+
|
|
3
|
+
Entry points call :func:`on_startup` once. It never raises, never blocks on
|
|
4
|
+
the network, and does nothing when ``HYPERCLAW_NO_UPDATE_CHECK=1``.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("hyperclaw.updates")
|
|
12
|
+
|
|
13
|
+
INTERACTIVE = ("cli", "tui")
|
|
14
|
+
_done = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def startup_lines(surface: str = "cli") -> list:
|
|
18
|
+
"""What the startup notice would show (cache + unread owner notices)."""
|
|
19
|
+
from hyperclaw.updates import check, notices
|
|
20
|
+
from hyperclaw.updates.paths import opted_out
|
|
21
|
+
if opted_out():
|
|
22
|
+
return []
|
|
23
|
+
lines = []
|
|
24
|
+
if surface in INTERACTIVE:
|
|
25
|
+
lines += [f"[update] {t}" for t in notices.unread(mark_read=True)]
|
|
26
|
+
n = check.notice_text()
|
|
27
|
+
if n:
|
|
28
|
+
lines.append(n)
|
|
29
|
+
return lines
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def on_startup(surface: str = "cli", *, out=None) -> None:
|
|
33
|
+
"""Startup hook: show the cached notice, refresh a stale cache in the
|
|
34
|
+
background and, on long-running surfaces, start the managed scheduler
|
|
35
|
+
(only when its gate passes). Once per process."""
|
|
36
|
+
global _done
|
|
37
|
+
if _done:
|
|
38
|
+
return
|
|
39
|
+
_done = True
|
|
40
|
+
try:
|
|
41
|
+
from hyperclaw.updates import check, managed
|
|
42
|
+
from hyperclaw.updates.paths import opted_out
|
|
43
|
+
if opted_out():
|
|
44
|
+
return
|
|
45
|
+
for line in startup_lines(surface):
|
|
46
|
+
if surface in INTERACTIVE:
|
|
47
|
+
print(line, file=out or sys.stderr)
|
|
48
|
+
else:
|
|
49
|
+
log.warning(line)
|
|
50
|
+
check.maybe_refresh_in_background()
|
|
51
|
+
if surface not in INTERACTIVE:
|
|
52
|
+
managed.start_scheduler()
|
|
53
|
+
except Exception as e: # never let the updater break startup
|
|
54
|
+
log.debug("update startup hook: %s", e)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Cached, rate-limited, offline-safe version check + the startup notice.
|
|
2
|
+
|
|
3
|
+
Startup only ever READS the cache. A stale cache is refreshed in a daemon
|
|
4
|
+
thread (short timeouts), so the notice for a new release appears on the next
|
|
5
|
+
start. A version is only advertised once its signed manifest verifies.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as _dt
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from hyperclaw import distribution as _dist
|
|
15
|
+
from hyperclaw.updates import index, versioning
|
|
16
|
+
from hyperclaw.updates.manifest import ManifestError
|
|
17
|
+
from hyperclaw.updates.paths import (current_version, opted_out, read_json, updates_dir,
|
|
18
|
+
write_json)
|
|
19
|
+
|
|
20
|
+
log = logging.getLogger("hyperclaw.updates")
|
|
21
|
+
|
|
22
|
+
CHECK_INTERVAL = _dt.timedelta(hours=24)
|
|
23
|
+
FAIL_BACKOFF = _dt.timedelta(hours=6)
|
|
24
|
+
TIMEOUT = 4.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _now() -> _dt.datetime:
|
|
28
|
+
return _dt.datetime.now(_dt.timezone.utc)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cache_path():
|
|
32
|
+
return updates_dir() / "check.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_cache() -> dict:
|
|
36
|
+
c = read_json(cache_path(), {})
|
|
37
|
+
return c if isinstance(c, dict) else {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ts(s) -> Optional[_dt.datetime]:
|
|
41
|
+
try:
|
|
42
|
+
t = _dt.datetime.fromisoformat(str(s))
|
|
43
|
+
return t if t.tzinfo else t.replace(tzinfo=_dt.timezone.utc)
|
|
44
|
+
except Exception:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_due(cache: dict, now: Optional[_dt.datetime] = None) -> bool:
|
|
49
|
+
now = now or _now()
|
|
50
|
+
last = _ts(cache.get("checked_at"))
|
|
51
|
+
if last is None:
|
|
52
|
+
return True
|
|
53
|
+
wait = FAIL_BACKOFF if cache.get("error") else CHECK_INTERVAL
|
|
54
|
+
return now - last >= wait or last > now + _dt.timedelta(minutes=5)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def refresh(now: Optional[_dt.datetime] = None, *, timeout: float = TIMEOUT,
|
|
58
|
+
current: Optional[str] = None) -> dict:
|
|
59
|
+
"""Ask the index for the newest release and verify its signed manifest.
|
|
60
|
+
Never raises; failures are recorded in the cache (and back off)."""
|
|
61
|
+
now = now or _now()
|
|
62
|
+
current = current or current_version()
|
|
63
|
+
cache = load_cache()
|
|
64
|
+
cache.update({"checked_at": now.isoformat(), "current": current})
|
|
65
|
+
try:
|
|
66
|
+
info = index.project_info(timeout=timeout)
|
|
67
|
+
latest = index.latest_version(info, include_pre=index.include_prereleases(current))
|
|
68
|
+
cache["latest"] = latest
|
|
69
|
+
cache.pop("error", None)
|
|
70
|
+
if latest and versioning.is_newer(latest, current):
|
|
71
|
+
try:
|
|
72
|
+
rel = index.release_info(latest, timeout=timeout)
|
|
73
|
+
m = index.fetch_manifest(latest, rel, timeout=timeout)
|
|
74
|
+
cache["latest_signed"] = m["version"]
|
|
75
|
+
cache["min_upgrade_from"] = m["min_upgrade_from"]
|
|
76
|
+
cache.pop("unsigned", None)
|
|
77
|
+
except (ManifestError, index.IndexError_) as e:
|
|
78
|
+
cache["unsigned"] = latest
|
|
79
|
+
cache.pop("latest_signed", None)
|
|
80
|
+
log.info("update check: %s is not installable: %s", latest, e)
|
|
81
|
+
else:
|
|
82
|
+
cache.pop("latest_signed", None)
|
|
83
|
+
cache.pop("unsigned", None)
|
|
84
|
+
except Exception as e: # offline, DNS, bad JSON, anything: never raise
|
|
85
|
+
cache["error"] = f"{type(e).__name__}"
|
|
86
|
+
log.debug("update check failed: %s", e)
|
|
87
|
+
try:
|
|
88
|
+
write_json(cache_path(), cache)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
return cache
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def notice_text(cache: Optional[dict] = None, current: Optional[str] = None,
|
|
95
|
+
legacy: Optional[bool] = None) -> Optional[str]:
|
|
96
|
+
"""The one-line notice, or None. Reads the cache only (no network)."""
|
|
97
|
+
if opted_out():
|
|
98
|
+
return None
|
|
99
|
+
cache = load_cache() if cache is None else cache
|
|
100
|
+
current = current or current_version()
|
|
101
|
+
if _dist.is_legacy_install() if legacy is None else legacy:
|
|
102
|
+
# Old ``hyperclaw`` distribution: the only notice is how to move over.
|
|
103
|
+
latest = cache.get("latest")
|
|
104
|
+
if not latest or not versioning.is_newer(latest, current):
|
|
105
|
+
latest = current
|
|
106
|
+
return _dist.migration_line(latest)
|
|
107
|
+
latest = cache.get("latest_signed")
|
|
108
|
+
if not latest or not versioning.is_newer(latest, current):
|
|
109
|
+
return None
|
|
110
|
+
mn = versioning.parse(cache.get("min_upgrade_from") or "0")
|
|
111
|
+
cur = versioning.parse(current)
|
|
112
|
+
if mn and cur and cur < mn:
|
|
113
|
+
return (f"HyperClaw {latest} is available (you have {current}); upgrade to "
|
|
114
|
+
f"{cache.get('min_upgrade_from')} first. Run 'hyperclaw upgrade'.")
|
|
115
|
+
return (f"HyperClaw {latest} is available (you have {current}). "
|
|
116
|
+
f"Run 'hyperclaw upgrade' to see what's new.")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_thread: Optional[threading.Thread] = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def maybe_refresh_in_background(now: Optional[_dt.datetime] = None) -> Optional[threading.Thread]:
|
|
123
|
+
"""Start one daemon refresh if the cache is stale. Returns the thread."""
|
|
124
|
+
global _thread
|
|
125
|
+
if opted_out() or not is_due(load_cache(), now):
|
|
126
|
+
return None
|
|
127
|
+
if _thread is not None and _thread.is_alive():
|
|
128
|
+
return None
|
|
129
|
+
_thread = threading.Thread(target=refresh, name="hyperclaw-update-check", daemon=True)
|
|
130
|
+
_thread.start()
|
|
131
|
+
return _thread
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Talking to the package index (PyPI JSON API) and fetching signed manifests.
|
|
2
|
+
|
|
3
|
+
Only ``https`` URLs are fetched, plus plain ``http`` to a loopback host (a
|
|
4
|
+
local mirror or the unit tests' fake index). Every call has a timeout and a
|
|
5
|
+
size cap. Nothing here installs anything.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from hyperclaw.updates import manifest as _m
|
|
18
|
+
from hyperclaw.updates import versioning
|
|
19
|
+
|
|
20
|
+
DEFAULT_INDEX = "https://pypi.org/pypi"
|
|
21
|
+
INDEX_ENV = "HYPERCLAW_UPDATE_INDEX_URL"
|
|
22
|
+
MANIFEST_URL_ENV = "HYPERCLAW_UPDATE_MANIFEST_URL"
|
|
23
|
+
PRERELEASE_ENV = "HYPERCLAW_UPDATE_PRERELEASE"
|
|
24
|
+
MANIFEST_URL_KEYS = ("release-manifest", "release manifest", "release_manifest")
|
|
25
|
+
|
|
26
|
+
JSON_CAP = 8 << 20
|
|
27
|
+
WHEEL_CAP = 200 << 20
|
|
28
|
+
_LOOPBACK = {"127.0.0.1", "localhost", "::1"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IndexError_(Exception):
|
|
32
|
+
"""Index unreachable or answered something unusable."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def index_url() -> str:
|
|
36
|
+
return (os.environ.get(INDEX_ENV, "").strip() or DEFAULT_INDEX).rstrip("/")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _check_url(url: str) -> str:
|
|
40
|
+
u = urllib.parse.urlparse(url)
|
|
41
|
+
if u.scheme == "https" and u.hostname:
|
|
42
|
+
return url
|
|
43
|
+
if u.scheme == "http" and (u.hostname or "") in _LOOPBACK:
|
|
44
|
+
return url
|
|
45
|
+
raise IndexError_(f"refusing non-https URL ({u.scheme}://{u.hostname})")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _open(url: str, timeout: float):
|
|
49
|
+
req = urllib.request.Request(_check_url(url), headers={
|
|
50
|
+
"User-Agent": "hyperclaw-updater", "Accept": "application/json, */*"})
|
|
51
|
+
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310 (scheme checked)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fetch_bytes(url: str, *, timeout: float = 10, cap: int = JSON_CAP) -> bytes:
|
|
55
|
+
try:
|
|
56
|
+
with _open(url, timeout) as r:
|
|
57
|
+
data = r.read(cap + 1)
|
|
58
|
+
except IndexError_:
|
|
59
|
+
raise
|
|
60
|
+
except Exception as e:
|
|
61
|
+
raise IndexError_(f"fetch failed: {type(e).__name__}")
|
|
62
|
+
if len(data) > cap:
|
|
63
|
+
raise IndexError_("response too large")
|
|
64
|
+
return data
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def fetch_json(url: str, *, timeout: float = 10):
|
|
68
|
+
try:
|
|
69
|
+
return json.loads(fetch_bytes(url, timeout=timeout).decode("utf-8"))
|
|
70
|
+
except IndexError_:
|
|
71
|
+
raise
|
|
72
|
+
except Exception:
|
|
73
|
+
raise IndexError_("index returned invalid JSON")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def project_info(*, timeout: float = 10) -> dict:
|
|
77
|
+
return fetch_json(f"{index_url()}/{_m.PACKAGE}/json", timeout=timeout)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def release_info(version: str, *, timeout: float = 10) -> dict:
|
|
81
|
+
return fetch_json(f"{index_url()}/{_m.PACKAGE}/{versioning.normalize(version)}/json",
|
|
82
|
+
timeout=timeout)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def include_prereleases(current: str) -> bool:
|
|
86
|
+
if os.environ.get(PRERELEASE_ENV, "").strip() == "1":
|
|
87
|
+
return True
|
|
88
|
+
v = versioning.parse(current)
|
|
89
|
+
return bool(v and v.is_prerelease)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def latest_version(info: dict, *, include_pre: bool) -> Optional[str]:
|
|
93
|
+
"""Highest non-yanked release on the index (prereleases only if asked)."""
|
|
94
|
+
best = None
|
|
95
|
+
for ver, files in (info.get("releases") or {}).items():
|
|
96
|
+
v = versioning.parse(ver)
|
|
97
|
+
if v is None or v.local or (v.is_prerelease and not include_pre):
|
|
98
|
+
continue
|
|
99
|
+
if isinstance(files, list) and files and all(f.get("yanked") for f in files):
|
|
100
|
+
continue
|
|
101
|
+
if best is None or v > best:
|
|
102
|
+
best = v
|
|
103
|
+
return str(best) if best else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def manifest_url_for(version: str, release_json: Optional[dict]) -> Optional[str]:
|
|
107
|
+
"""The release's manifest URL: env template, else the release's own
|
|
108
|
+
``Release-Manifest`` project URL. ``{version}`` is substituted."""
|
|
109
|
+
tpl = os.environ.get(MANIFEST_URL_ENV, "").strip()
|
|
110
|
+
if not tpl and isinstance(release_json, dict):
|
|
111
|
+
urls = (release_json.get("info") or {}).get("project_urls") or {}
|
|
112
|
+
for k, val in urls.items():
|
|
113
|
+
if str(k).strip().lower() in MANIFEST_URL_KEYS and val:
|
|
114
|
+
tpl = str(val)
|
|
115
|
+
break
|
|
116
|
+
if not tpl:
|
|
117
|
+
return None
|
|
118
|
+
return tpl.replace("{version}", versioning.normalize(version))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def fetch_manifest(version: str, release_json: Optional[dict] = None, *,
|
|
122
|
+
timeout: float = 10) -> dict:
|
|
123
|
+
"""Fetch and VERIFY the signed manifest for ``version``.
|
|
124
|
+
Raises ManifestError when there is none or it does not verify."""
|
|
125
|
+
return fetch_signed(version, release_json, timeout=timeout)[0]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fetch_signed(version: str, release_json: Optional[dict] = None, *,
|
|
129
|
+
timeout: float = 10):
|
|
130
|
+
"""(verified manifest, signed envelope) for ``version``; ManifestError otherwise."""
|
|
131
|
+
url = manifest_url_for(version, release_json)
|
|
132
|
+
if not url:
|
|
133
|
+
raise _m.ManifestError(f"release {version} has no signed manifest (unsigned release)")
|
|
134
|
+
try:
|
|
135
|
+
env = fetch_json(url, timeout=timeout)
|
|
136
|
+
except IndexError_ as e:
|
|
137
|
+
raise _m.ManifestError(f"signed manifest for {version} unavailable ({e})")
|
|
138
|
+
return _m.verify_manifest(env, expect_version=version), env
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def wheel_url(release_json: dict, filename: str) -> str:
|
|
142
|
+
for f in release_json.get("urls") or []:
|
|
143
|
+
if f.get("filename") == filename:
|
|
144
|
+
if f.get("yanked"):
|
|
145
|
+
raise IndexError_(f"{filename} is yanked")
|
|
146
|
+
return f["url"]
|
|
147
|
+
raise IndexError_(f"{filename} is not on the index")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def download_wheel(url: str, dest_dir: Path, manifest: dict, *, timeout: float = 60) -> Path:
|
|
151
|
+
"""Download into ``dest_dir`` and check size + sha256 against the signed
|
|
152
|
+
manifest. A mismatching file is deleted and ManifestError raised."""
|
|
153
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
dest = dest_dir / manifest["wheel"]
|
|
155
|
+
part = dest.with_suffix(".part")
|
|
156
|
+
h, n = hashlib.sha256(), 0
|
|
157
|
+
try:
|
|
158
|
+
with _open(url, timeout) as r, open(part, "wb") as out:
|
|
159
|
+
while True:
|
|
160
|
+
chunk = r.read(1 << 16)
|
|
161
|
+
if not chunk:
|
|
162
|
+
break
|
|
163
|
+
n += len(chunk)
|
|
164
|
+
if n > min(WHEEL_CAP, manifest["size"]):
|
|
165
|
+
raise _m.ManifestError("wheel is larger than the signed manifest says")
|
|
166
|
+
h.update(chunk)
|
|
167
|
+
out.write(chunk)
|
|
168
|
+
except _m.ManifestError:
|
|
169
|
+
part.unlink(missing_ok=True)
|
|
170
|
+
raise
|
|
171
|
+
except Exception as e:
|
|
172
|
+
part.unlink(missing_ok=True)
|
|
173
|
+
raise IndexError_(f"wheel download failed: {type(e).__name__}")
|
|
174
|
+
if n != manifest["size"] or h.hexdigest() != manifest["sha256"]:
|
|
175
|
+
part.unlink(missing_ok=True)
|
|
176
|
+
raise _m.ManifestError("wheel sha256 does not match the signed manifest")
|
|
177
|
+
os.replace(part, dest)
|
|
178
|
+
return dest
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Install-method detection, the pip/pipx call, and the post-install check.
|
|
2
|
+
|
|
3
|
+
Supported: a pipx install (``.../pipx/venvs/<name>``) and a plain venv. Any
|
|
4
|
+
other interpreter (system Python, container image) is refused with
|
|
5
|
+
instructions; the updater never pip-installs into a system Python.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from hyperclaw.updates import versioning
|
|
19
|
+
|
|
20
|
+
UNSUPPORTED = (
|
|
21
|
+
"This HyperClaw is not installed with pipx or in a virtualenv, so "
|
|
22
|
+
"'hyperclaw upgrade' cannot update it safely.\n"
|
|
23
|
+
"Reinstall with pipx (recommended): pipx install hyperagents\n"
|
|
24
|
+
"or, in a container, rebuild the image with the new version.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class UnsupportedInstall(Exception):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class InstallMethod:
|
|
33
|
+
kind: str # "pipx" | "venv"
|
|
34
|
+
prefix: Path # the environment HyperClaw runs from
|
|
35
|
+
pip: List[str] # command prefix that runs pip in that env
|
|
36
|
+
name: str = "" # pipx venv name
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def bin_dir(self) -> Path:
|
|
40
|
+
return self.prefix / ("Scripts" if os.name == "nt" else "bin")
|
|
41
|
+
|
|
42
|
+
def hyperclaw_exe(self) -> Path:
|
|
43
|
+
return self.bin_dir / ("hyperclaw.exe" if os.name == "nt" else "hyperclaw")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _pipx_venvs_roots(env) -> List[Path]:
|
|
47
|
+
roots = []
|
|
48
|
+
if env.get("PIPX_HOME"):
|
|
49
|
+
roots.append(Path(env["PIPX_HOME"]) / "venvs")
|
|
50
|
+
home = Path(env.get("HOME") or Path.home())
|
|
51
|
+
roots += [home / ".local" / "pipx" / "venvs", home / ".local" / "share" / "pipx" / "venvs"]
|
|
52
|
+
return roots
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def detect(prefix: Optional[str] = None, base_prefix: Optional[str] = None,
|
|
56
|
+
env=None) -> InstallMethod:
|
|
57
|
+
env = os.environ if env is None else env
|
|
58
|
+
pfx = Path(prefix or sys.prefix)
|
|
59
|
+
base = Path(base_prefix or sys.base_prefix)
|
|
60
|
+
parent = pfx.parent
|
|
61
|
+
is_pipx = parent.name == "venvs" and (
|
|
62
|
+
parent.parent.name == "pipx" or any(parent == r for r in _pipx_venvs_roots(env)))
|
|
63
|
+
if is_pipx:
|
|
64
|
+
pipx = shutil.which("pipx", path=env.get("PATH"))
|
|
65
|
+
if not pipx:
|
|
66
|
+
raise UnsupportedInstall(
|
|
67
|
+
"HyperClaw is installed with pipx, but the 'pipx' command is not on PATH. "
|
|
68
|
+
"Add it to PATH and run 'hyperclaw upgrade' again.")
|
|
69
|
+
return InstallMethod("pipx", pfx, [pipx, "runpip", pfx.name], name=pfx.name)
|
|
70
|
+
if pfx.resolve() != base.resolve():
|
|
71
|
+
py = pfx / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
72
|
+
return InstallMethod("venv", pfx, [str(py), "-m", "pip"])
|
|
73
|
+
raise UnsupportedInstall(UNSUPPORTED)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
Runner = Callable[..., "subprocess.CompletedProcess"]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def install(method: InstallMethod, wheel: Path, *, force: bool = False,
|
|
80
|
+
runner: Runner = subprocess.run) -> Tuple[bool, str]:
|
|
81
|
+
cmd = list(method.pip) + ["install", "--disable-pip-version-check", "-q"]
|
|
82
|
+
if force:
|
|
83
|
+
cmd += ["--force-reinstall", "--no-deps"]
|
|
84
|
+
cmd.append(str(wheel))
|
|
85
|
+
try:
|
|
86
|
+
r = runner(cmd, capture_output=True, text=True, timeout=900, check=False)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
return False, f"{type(e).__name__}: {e}"
|
|
89
|
+
return r.returncode == 0, (r.stdout or "") + (r.stderr or "")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_VER = re.compile(r"\bv?(\d+(?:\.\d+)+\S*)")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def installed_version(method: InstallMethod, *, runner: Runner = subprocess.run) -> Optional[str]:
|
|
96
|
+
"""Run ``hyperclaw version`` in the target env and parse the version."""
|
|
97
|
+
try:
|
|
98
|
+
r = runner([str(method.hyperclaw_exe()), "version"], capture_output=True,
|
|
99
|
+
text=True, timeout=120, check=False)
|
|
100
|
+
except Exception:
|
|
101
|
+
return None
|
|
102
|
+
if r.returncode != 0:
|
|
103
|
+
return None
|
|
104
|
+
m = _VER.search(re.sub(r"\x1b\[[0-9;]*m", "", r.stdout or ""))
|
|
105
|
+
return m.group(1) if m else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def verify(method: InstallMethod, expect: str, *, runner: Runner = subprocess.run) -> bool:
|
|
109
|
+
got = installed_version(method, runner=runner)
|
|
110
|
+
return bool(got) and versioning.parse(got) == versioning.parse(expect)
|