usage-cli 0.29.32__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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- wintray_menu.py +136 -0
statusline_settings.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from setup_hook import _atomic_write_text
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _claude_settings_path() -> Path:
|
|
21
|
+
return Path(os.path.expanduser("~/.claude/settings.json"))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_claude_settings() -> dict[str, Any]:
|
|
25
|
+
settings_path = _claude_settings_path()
|
|
26
|
+
if not settings_path.exists():
|
|
27
|
+
return {}
|
|
28
|
+
with settings_path.open(encoding="utf-8") as file:
|
|
29
|
+
settings = json.load(file)
|
|
30
|
+
if not isinstance(settings, dict):
|
|
31
|
+
raise ValueError(f"{settings_path} must be a JSON object")
|
|
32
|
+
return settings
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _save_claude_settings(settings: dict[str, Any]) -> None:
|
|
36
|
+
settings_path = _claude_settings_path()
|
|
37
|
+
trailing_newline = True
|
|
38
|
+
with contextlib.suppress(OSError):
|
|
39
|
+
trailing_newline = settings_path.read_bytes().endswith(b"\n")
|
|
40
|
+
content = json.dumps(settings, indent=2, ensure_ascii=False)
|
|
41
|
+
if trailing_newline:
|
|
42
|
+
content += "\n"
|
|
43
|
+
_atomic_write_text(settings_path, content)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _statusline_command_target_exists(statusline: object) -> bool:
|
|
47
|
+
if not isinstance(statusline, dict):
|
|
48
|
+
return True
|
|
49
|
+
command = statusline.get("command")
|
|
50
|
+
if not isinstance(command, str):
|
|
51
|
+
return True
|
|
52
|
+
try:
|
|
53
|
+
parts = shlex.split(command)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return True
|
|
56
|
+
for part in parts:
|
|
57
|
+
if "statusline" not in part or not part.endswith(".py"):
|
|
58
|
+
continue
|
|
59
|
+
return Path(os.path.expanduser(part)).exists()
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _set_forwarder_mode_prompt_dismissed() -> None:
|
|
64
|
+
import setup_hook
|
|
65
|
+
|
|
66
|
+
settings = setup_hook._load_settings()
|
|
67
|
+
usage_settings = settings.get(setup_hook.BACKUP_KEY)
|
|
68
|
+
if not isinstance(usage_settings, dict):
|
|
69
|
+
usage_settings = {}
|
|
70
|
+
settings[setup_hook.BACKUP_KEY] = usage_settings
|
|
71
|
+
usage_settings["forwarderModePromptDismissed"] = True
|
|
72
|
+
setup_hook._save_settings(settings)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _sync_agy_statusline(enable: bool) -> None:
|
|
76
|
+
"""Mirror the switch onto Antigravity's status line when its CLI is installed.
|
|
77
|
+
|
|
78
|
+
Antigravity is a side effect of the one switch the panel shows, so a missing
|
|
79
|
+
or unwritable ~/.gemini config must never stop Claude's own status line from
|
|
80
|
+
being toggled.
|
|
81
|
+
"""
|
|
82
|
+
if sys.platform not in {"darwin", "win32"}:
|
|
83
|
+
return
|
|
84
|
+
import setup_hook
|
|
85
|
+
|
|
86
|
+
with contextlib.suppress(Exception):
|
|
87
|
+
if enable:
|
|
88
|
+
setup_hook._setup_agy()
|
|
89
|
+
else:
|
|
90
|
+
setup_hook._unsetup_agy()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _disable_statusline_settings() -> int:
|
|
94
|
+
_sync_agy_statusline(False)
|
|
95
|
+
settings = _load_claude_settings()
|
|
96
|
+
if "statusLine" not in settings:
|
|
97
|
+
return 0
|
|
98
|
+
usage_settings = settings.setdefault("usage", {})
|
|
99
|
+
if not isinstance(usage_settings, dict):
|
|
100
|
+
usage_settings = {}
|
|
101
|
+
settings["usage"] = usage_settings
|
|
102
|
+
usage_settings["previousStatusLine"] = settings["statusLine"]
|
|
103
|
+
del settings["statusLine"]
|
|
104
|
+
_save_claude_settings(settings)
|
|
105
|
+
return 0
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _enable_statusline_settings() -> int:
|
|
109
|
+
_sync_agy_statusline(True)
|
|
110
|
+
settings = _load_claude_settings()
|
|
111
|
+
if "statusLine" in settings:
|
|
112
|
+
return 0
|
|
113
|
+
raw_usage_settings = settings.get("usage")
|
|
114
|
+
usage_settings = raw_usage_settings if isinstance(raw_usage_settings, dict) else None
|
|
115
|
+
previous = usage_settings.get("previousStatusLine") if usage_settings is not None else None
|
|
116
|
+
if previous:
|
|
117
|
+
assert usage_settings is not None
|
|
118
|
+
if not _statusline_command_target_exists(previous):
|
|
119
|
+
del usage_settings["previousStatusLine"]
|
|
120
|
+
if not usage_settings:
|
|
121
|
+
del settings["usage"]
|
|
122
|
+
_save_claude_settings(settings)
|
|
123
|
+
import setup_hook
|
|
124
|
+
|
|
125
|
+
return setup_hook.setup()
|
|
126
|
+
settings["statusLine"] = previous
|
|
127
|
+
del usage_settings["previousStatusLine"]
|
|
128
|
+
if not usage_settings:
|
|
129
|
+
del settings["usage"]
|
|
130
|
+
_save_claude_settings(settings)
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
import setup_hook
|
|
134
|
+
|
|
135
|
+
exit_code = setup_hook.setup()
|
|
136
|
+
if exit_code != 0 and setup_hook.is_agy_setup():
|
|
137
|
+
return 0
|
|
138
|
+
return exit_code
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _toggle_statusline_settings() -> tuple[str, int]:
|
|
142
|
+
if _statusline_enabled():
|
|
143
|
+
return "uninstall", _disable_statusline_settings()
|
|
144
|
+
return "install", _enable_statusline_settings()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _statusline_enabled() -> bool:
|
|
148
|
+
try:
|
|
149
|
+
settings = _load_claude_settings()
|
|
150
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
151
|
+
return False
|
|
152
|
+
if "statusLine" in settings:
|
|
153
|
+
return True
|
|
154
|
+
if sys.platform not in {"darwin", "win32"}:
|
|
155
|
+
return False
|
|
156
|
+
with contextlib.suppress(Exception):
|
|
157
|
+
import setup_hook
|
|
158
|
+
|
|
159
|
+
return setup_hook.is_agy_setup()
|
|
160
|
+
return False
|
talent_market_bridge.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
# Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
|
|
3
|
+
#
|
|
4
|
+
# Part of "usage". Free software licensed under the GNU Affero General Public
|
|
5
|
+
# License v3.0 only; see the LICENSE file for full terms and the warranty
|
|
6
|
+
# disclaimer.
|
|
7
|
+
|
|
8
|
+
# mypy: disable-error-code="import-untyped,import-not-found,misc"
|
|
9
|
+
"""Bridge to the external ``instate-cli`` executable that powers the
|
|
10
|
+
"AI 人才市場" panel.
|
|
11
|
+
|
|
12
|
+
The CLI is built in a separate project (``~/Developer/instate``) and bundled
|
|
13
|
+
into ``vendor/instate-cli`` at .app build time. It prints one JSON object per
|
|
14
|
+
invocation on stdout. Every wrapper here returns a dict and never raises, so a
|
|
15
|
+
missing/broken CLI degrades to an empty-state panel instead of crashing the
|
|
16
|
+
popover. Folder selection uses a native ``NSOpenPanel`` (no CLI round-trip).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import subprocess
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
TIMEOUT_SECONDS = 10.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _repo_root() -> Path:
|
|
33
|
+
return Path(__file__).resolve().parent
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _bundled_cli_path() -> Path | None:
|
|
37
|
+
try:
|
|
38
|
+
from Foundation import NSBundle
|
|
39
|
+
except ImportError:
|
|
40
|
+
return None
|
|
41
|
+
bundle = NSBundle.mainBundle()
|
|
42
|
+
if bundle is None:
|
|
43
|
+
return None
|
|
44
|
+
# py2app flattens a single-file resource to Resources/<basename>, so the
|
|
45
|
+
# compiled CLI lives at Resources/instate-cli. In dev (python3 main.py) the
|
|
46
|
+
# main bundle is the Python framework and this returns None → fall back to
|
|
47
|
+
# repo vendor/instate-cli.
|
|
48
|
+
path = bundle.pathForResource_ofType_("instate-cli", "")
|
|
49
|
+
if path:
|
|
50
|
+
return Path(str(path))
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _cli_path() -> Path:
|
|
55
|
+
bundled = _bundled_cli_path()
|
|
56
|
+
if bundled is not None:
|
|
57
|
+
return bundled
|
|
58
|
+
vendor = "vendor"
|
|
59
|
+
name = "instate-cli"
|
|
60
|
+
return _repo_root() / vendor / name
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run(args: list[str]) -> dict[str, Any]:
|
|
64
|
+
cli = _cli_path()
|
|
65
|
+
if not cli.exists():
|
|
66
|
+
return {"ok": False, "status": "missing", "error": "instate-cli not installed"}
|
|
67
|
+
try:
|
|
68
|
+
proc = subprocess.run( # noqa: S603 — fixed local binary, args are constants/IDs
|
|
69
|
+
[str(cli), *args],
|
|
70
|
+
capture_output=True,
|
|
71
|
+
text=True,
|
|
72
|
+
timeout=TIMEOUT_SECONDS,
|
|
73
|
+
encoding="utf-8",
|
|
74
|
+
errors="replace",
|
|
75
|
+
)
|
|
76
|
+
except subprocess.TimeoutExpired:
|
|
77
|
+
return {"ok": False, "status": "timeout", "error": "instate-cli timed out"}
|
|
78
|
+
except Exception as exc:
|
|
79
|
+
# Any failure to spawn/read (OSError, decode hiccups, ...) degrades to
|
|
80
|
+
# an error dict — the panel shows its empty state instead of crashing.
|
|
81
|
+
return {"ok": False, "status": "error", "error": str(exc)}
|
|
82
|
+
if proc.returncode != 0:
|
|
83
|
+
return {
|
|
84
|
+
"ok": False,
|
|
85
|
+
"status": "error",
|
|
86
|
+
"error": (proc.stderr or "").strip() or f"instate-cli exited {proc.returncode}",
|
|
87
|
+
}
|
|
88
|
+
try:
|
|
89
|
+
parsed = json.loads(proc.stdout)
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
return {
|
|
92
|
+
"ok": False,
|
|
93
|
+
"status": "error",
|
|
94
|
+
"error": "instate-cli returned non-JSON",
|
|
95
|
+
"stdout": proc.stdout,
|
|
96
|
+
}
|
|
97
|
+
if not isinstance(parsed, dict):
|
|
98
|
+
return {"ok": False, "status": "error", "error": "instate-cli returned non-object"}
|
|
99
|
+
return parsed
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def list_state(lang: str | None = None) -> dict[str, Any]:
|
|
103
|
+
args = ["list-state"]
|
|
104
|
+
if lang:
|
|
105
|
+
args.append(lang)
|
|
106
|
+
return _run(args)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def list_personas(lang: str | None = None) -> list[dict[str, str]]:
|
|
110
|
+
"""Return every available role as a flat, discussion-friendly record."""
|
|
111
|
+
try:
|
|
112
|
+
state = list_state(lang)
|
|
113
|
+
except Exception:
|
|
114
|
+
return []
|
|
115
|
+
packs = state.get("packs")
|
|
116
|
+
if not isinstance(packs, list):
|
|
117
|
+
return []
|
|
118
|
+
personas: list[dict[str, str]] = []
|
|
119
|
+
for pack in packs:
|
|
120
|
+
if not isinstance(pack, dict):
|
|
121
|
+
continue
|
|
122
|
+
pack_id = pack.get("id")
|
|
123
|
+
pack_name = pack.get("name")
|
|
124
|
+
if not isinstance(pack_id, str) or not isinstance(pack_name, str):
|
|
125
|
+
pack_id = ""
|
|
126
|
+
pack_name = ""
|
|
127
|
+
roles = pack.get("roles")
|
|
128
|
+
if not isinstance(roles, list):
|
|
129
|
+
continue
|
|
130
|
+
for role in roles:
|
|
131
|
+
if not isinstance(role, dict):
|
|
132
|
+
continue
|
|
133
|
+
role_id = role.get("id")
|
|
134
|
+
name = role.get("name")
|
|
135
|
+
persona_name = role.get("personaName")
|
|
136
|
+
description = role.get("description")
|
|
137
|
+
system_prompt = role.get("systemPrompt")
|
|
138
|
+
if (
|
|
139
|
+
not isinstance(role_id, str)
|
|
140
|
+
or not isinstance(name, str)
|
|
141
|
+
or not isinstance(persona_name, str)
|
|
142
|
+
or not isinstance(description, str)
|
|
143
|
+
or not isinstance(system_prompt, str)
|
|
144
|
+
):
|
|
145
|
+
continue
|
|
146
|
+
personas.append(
|
|
147
|
+
{
|
|
148
|
+
"id": role_id,
|
|
149
|
+
"name": name,
|
|
150
|
+
"persona_name": persona_name,
|
|
151
|
+
"description": description,
|
|
152
|
+
"system_prompt": system_prompt,
|
|
153
|
+
"pack_id": pack_id,
|
|
154
|
+
"pack_name": pack_name,
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
return personas
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def install_role(role_id: str) -> dict[str, Any]:
|
|
161
|
+
return _run(["install", role_id])
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def launch_role(role_id: str, task_prompt: str | None = None) -> dict[str, Any]:
|
|
165
|
+
args = ["launch", role_id]
|
|
166
|
+
if task_prompt:
|
|
167
|
+
args.append(task_prompt)
|
|
168
|
+
return _run(args)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def restore_role(role_id: str) -> dict[str, Any]:
|
|
172
|
+
return _run(["restore", role_id])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def ignore_drift(role_id: str) -> dict[str, Any]:
|
|
176
|
+
return _run(["ignore-drift", role_id])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def set_folder(role_id: str, path: str) -> dict[str, Any]:
|
|
180
|
+
return _run(["set-folder", role_id, path])
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def pick_folder() -> str | None:
|
|
184
|
+
"""Open a native folder picker; return the chosen path or ``None``.
|
|
185
|
+
|
|
186
|
+
The popover's content window sits at ``NSNormalWindowLevel`` and usage runs
|
|
187
|
+
as an accessory app (no Dock icon). A bare ``runModal`` opens the panel at
|
|
188
|
+
the same level, so it lands behind the still-open popover and never takes
|
|
189
|
+
focus — the user clicks "選擇資料夾" and nothing visible happens. Two nudges
|
|
190
|
+
fix it: activate the app so the panel can become key, and raise the panel
|
|
191
|
+
one notch above ``NSPopUpMenuWindowLevel`` so it clears the popover.
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
from AppKit import NSApp, NSOpenPanel, NSPopUpMenuWindowLevel
|
|
195
|
+
except ImportError:
|
|
196
|
+
return None
|
|
197
|
+
NSApp.activateIgnoringOtherApps_(True)
|
|
198
|
+
panel = NSOpenPanel.openPanel()
|
|
199
|
+
panel.setCanChooseFiles_(False)
|
|
200
|
+
panel.setCanChooseDirectories_(True)
|
|
201
|
+
panel.setAllowsMultipleSelection_(False)
|
|
202
|
+
panel.setResolvesAliases_(True)
|
|
203
|
+
panel.setLevel_(NSPopUpMenuWindowLevel + 1)
|
|
204
|
+
if panel.runModal() != 1: # NSModalResponseOK
|
|
205
|
+
return None
|
|
206
|
+
urls = panel.URLs()
|
|
207
|
+
if urls is None or len(urls) == 0:
|
|
208
|
+
return None
|
|
209
|
+
url = urls[0]
|
|
210
|
+
if url is None:
|
|
211
|
+
return None
|
|
212
|
+
path = url.path()
|
|
213
|
+
return str(path) if path else None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def pick_image_file() -> str | None:
|
|
217
|
+
"""Open a native image picker; return the chosen path or ``None``.
|
|
218
|
+
|
|
219
|
+
Shares the activation/level fixups of :func:`pick_folder` so the panel lands
|
|
220
|
+
in front of the discussion window instead of behind it.
|
|
221
|
+
"""
|
|
222
|
+
try:
|
|
223
|
+
from AppKit import NSApp, NSOpenPanel, NSPopUpMenuWindowLevel
|
|
224
|
+
except ImportError:
|
|
225
|
+
return None
|
|
226
|
+
NSApp.activateIgnoringOtherApps_(True)
|
|
227
|
+
panel = NSOpenPanel.openPanel()
|
|
228
|
+
panel.setCanChooseFiles_(True)
|
|
229
|
+
panel.setCanChooseDirectories_(False)
|
|
230
|
+
panel.setAllowsMultipleSelection_(False)
|
|
231
|
+
panel.setResolvesAliases_(True)
|
|
232
|
+
panel.setAllowedFileTypes_(["png", "jpg", "jpeg", "gif", "webp"])
|
|
233
|
+
panel.setLevel_(NSPopUpMenuWindowLevel + 1)
|
|
234
|
+
if panel.runModal() != 1: # NSModalResponseOK
|
|
235
|
+
return None
|
|
236
|
+
urls = panel.URLs()
|
|
237
|
+
if urls is None or len(urls) == 0:
|
|
238
|
+
return None
|
|
239
|
+
url = urls[0]
|
|
240
|
+
if url is None:
|
|
241
|
+
return None
|
|
242
|
+
path = url.path()
|
|
243
|
+
return str(path) if path else None
|
time_utils.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def parse_optional_iso8601_utc(value: object) -> datetime | None:
|
|
7
|
+
if not isinstance(value, str) or not value:
|
|
8
|
+
return None
|
|
9
|
+
try:
|
|
10
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
11
|
+
except ValueError:
|
|
12
|
+
return None
|
|
13
|
+
return _coerce_utc(parsed)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_iso8601_utc_or_raise(value: str) -> datetime:
|
|
17
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
18
|
+
return _coerce_utc(parsed)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _coerce_utc(value: datetime) -> datetime:
|
|
22
|
+
if value.tzinfo is None:
|
|
23
|
+
return value.replace(tzinfo=UTC)
|
|
24
|
+
return value.astimezone(UTC)
|