crowdtime-cli 0.1.0__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.
crowdtime_cli/main.py ADDED
@@ -0,0 +1,334 @@
1
+ """CrowdTime CLI - Main entry point.
2
+
3
+ Registers all command groups, handles magic default routing,
4
+ and provides short aliases for common commands.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from datetime import date, timedelta
11
+ from decimal import Decimal
12
+ from typing import Optional
13
+
14
+ import typer
15
+ from rich.console import Console
16
+
17
+ from . import __version__
18
+ from .client import APIError, CrowdTimeClient
19
+ from .formatters import format_error, format_status, format_timer, print_json
20
+ from .models import TimeEntry, User
21
+
22
+ from .commands import (
23
+ ai_cmd,
24
+ auth_cmd,
25
+ clients_cmd,
26
+ config_cmd,
27
+ favorites_cmd,
28
+ log_cmd,
29
+ org_cmd,
30
+ projects_cmd,
31
+ report_cmd,
32
+ skill_cmd,
33
+ tasks_cmd,
34
+ timer_cmd,
35
+ )
36
+
37
+ console = Console()
38
+
39
+ app = typer.Typer(
40
+ name="crowdtime",
41
+ help="CrowdTime - AI-powered time tracking from the command line.",
42
+ rich_markup_mode="rich",
43
+ no_args_is_help=False,
44
+ invoke_without_command=True,
45
+ )
46
+
47
+ # Register command groups
48
+ app.add_typer(auth_cmd.app, name="auth")
49
+ app.add_typer(timer_cmd.app, name="timer")
50
+ app.add_typer(log_cmd.app, name="log")
51
+ app.add_typer(projects_cmd.app, name="projects")
52
+ app.add_typer(clients_cmd.app, name="clients")
53
+ app.add_typer(tasks_cmd.app, name="tasks")
54
+ app.add_typer(report_cmd.app, name="report")
55
+ app.add_typer(ai_cmd.app, name="ai")
56
+ app.add_typer(org_cmd.app, name="org")
57
+ app.add_typer(config_cmd.app, name="config")
58
+ app.add_typer(favorites_cmd.app, name="favorites")
59
+ app.add_typer(skill_cmd.app, name="skill")
60
+
61
+
62
+ # ─── Status command (default when no subcommand given) ──────────────────────
63
+
64
+
65
+ def _show_status(output_json: bool = False) -> None:
66
+ """Show the full status dashboard."""
67
+ try:
68
+ client = CrowdTimeClient(require_auth=True, require_org=True)
69
+ except SystemExit:
70
+ return
71
+
72
+ # Fetch user info
73
+ try:
74
+ user_data = client.get("/api/v1/auth/me/", org_scoped=False)
75
+ user = User(**user_data)
76
+ user_name = user.display_name
77
+ except (APIError, SystemExit):
78
+ user_name = "Unknown"
79
+
80
+ org_name = client.org_slug or "No org"
81
+
82
+ # Fetch running timer
83
+ running_entry: TimeEntry | None = None
84
+ try:
85
+ running_data = client.get("/time/running/")
86
+ running_entry = TimeEntry(**running_data)
87
+ except APIError:
88
+ pass
89
+
90
+ # Fetch today's entries
91
+ today = date.today()
92
+ today_entries: list[TimeEntry] = []
93
+ try:
94
+ today_data = client.get("/time/daily/{}/".format(today.isoformat()))
95
+ if isinstance(today_data, list):
96
+ today_list = today_data
97
+ elif isinstance(today_data, dict):
98
+ today_list = today_data.get("entries", today_data.get("results", []))
99
+ else:
100
+ today_list = []
101
+ today_entries = [TimeEntry(**item) for item in today_list]
102
+ except APIError:
103
+ pass
104
+
105
+ # Fetch this week's entries
106
+ week_entries: list[TimeEntry] | None = None
107
+ try:
108
+ monday = today - timedelta(days=today.weekday())
109
+ week_data = client.get("/time/weekly/{}/".format(monday.isoformat()))
110
+ if isinstance(week_data, list):
111
+ week_list = week_data
112
+ elif isinstance(week_data, dict):
113
+ week_list = week_data.get("entries", week_data.get("results", []))
114
+ else:
115
+ week_list = []
116
+ week_entries = [TimeEntry(**item) for item in week_list]
117
+ except APIError:
118
+ pass
119
+
120
+ if output_json:
121
+ print_json({
122
+ "user": user_name,
123
+ "organization": org_name,
124
+ "running_timer": running_entry.model_dump(mode="json") if running_entry else None,
125
+ "today_entries": [e.model_dump(mode="json") for e in today_entries],
126
+ "today_total": str(sum((e.duration or Decimal("0")) for e in today_entries if not e.is_running)),
127
+ })
128
+ return
129
+
130
+ format_status(
131
+ user_name=user_name,
132
+ org_name=org_name,
133
+ running_entry=running_entry,
134
+ today_entries=today_entries,
135
+ daily_target=client.config.daily_target,
136
+ week_entries=week_entries,
137
+ )
138
+
139
+
140
+ @app.callback(invoke_without_command=True)
141
+ def main(
142
+ ctx: typer.Context,
143
+ version: bool = typer.Option(False, "--version", "-v", help="Show version."),
144
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
145
+ ) -> None:
146
+ """CrowdTime - AI-powered time tracking from the command line."""
147
+ if version:
148
+ console.print(f"crowdtime-cli {__version__}")
149
+ raise typer.Exit()
150
+
151
+ if ctx.invoked_subcommand is None:
152
+ _show_status(output_json=output_json)
153
+
154
+
155
+ # ─── Short alias commands ───────────────────────────────────────────────────
156
+
157
+
158
+ @app.command("status", hidden=False)
159
+ def status_cmd(
160
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
161
+ ) -> None:
162
+ """Show current status dashboard."""
163
+ _show_status(output_json=output_json)
164
+
165
+
166
+ @app.command("s", hidden=True)
167
+ def status_alias(
168
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
169
+ ) -> None:
170
+ """Alias for 'status'."""
171
+ _show_status(output_json=output_json)
172
+
173
+
174
+ @app.command("t", hidden=True)
175
+ def timer_status_alias(
176
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
177
+ ) -> None:
178
+ """Alias for 'timer status'."""
179
+ timer_cmd.status(output_json=output_json)
180
+
181
+
182
+ @app.command("ts", hidden=True)
183
+ def timer_start_alias(
184
+ description: str = typer.Argument("", help="What are you working on?"),
185
+ project: Optional[str] = typer.Option(None, "--project", "-p"),
186
+ task: Optional[str] = typer.Option(None, "--task", "-t"),
187
+ billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B"),
188
+ output_json: bool = typer.Option(False, "--json"),
189
+ ) -> None:
190
+ """Alias for 'timer start'."""
191
+ timer_cmd.start(
192
+ description=description, project=project, task=task,
193
+ billable=billable, output_json=output_json,
194
+ )
195
+
196
+
197
+ @app.command("tx", hidden=True)
198
+ def timer_stop_alias(
199
+ note: Optional[str] = typer.Option(None, "--note", "-n"),
200
+ output_json: bool = typer.Option(False, "--json"),
201
+ ) -> None:
202
+ """Alias for 'timer stop'."""
203
+ timer_cmd.stop(note=note, output_json=output_json)
204
+
205
+
206
+ @app.command("l", hidden=True)
207
+ def log_alias(
208
+ duration: str = typer.Argument(..., help="Duration (e.g. 2h, 2h30m, 2:30)."),
209
+ description: Optional[str] = typer.Argument(None),
210
+ project: Optional[str] = typer.Option(None, "--project", "-p"),
211
+ task: Optional[str] = typer.Option(None, "--task", "-t"),
212
+ date: Optional[str] = typer.Option(None, "--date", "-d"),
213
+ billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B"),
214
+ output_json: bool = typer.Option(False, "--json"),
215
+ ) -> None:
216
+ """Alias for 'log create'."""
217
+ log_cmd.create(
218
+ duration=duration, description=description, project=project,
219
+ task=task, date=date, billable=billable, output_json=output_json,
220
+ )
221
+
222
+
223
+ @app.command("ll", hidden=True)
224
+ def log_list_alias(
225
+ date: Optional[str] = typer.Option(None, "--date", "-d"),
226
+ week: bool = typer.Option(False, "--week", "-w"),
227
+ month: bool = typer.Option(False, "--month", "-m"),
228
+ from_date: Optional[str] = typer.Option(None, "--from"),
229
+ to_date: Optional[str] = typer.Option(None, "--to"),
230
+ project: Optional[str] = typer.Option(None, "--project", "-p"),
231
+ format_output: str = typer.Option("table", "--format"),
232
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
233
+ ) -> None:
234
+ """Alias for 'log list'."""
235
+ log_cmd.list_entries(
236
+ date=date, week=week, month=month,
237
+ from_date=from_date, to_date=to_date, project=project,
238
+ format_output=format_output, output_json=output_json,
239
+ )
240
+
241
+
242
+ @app.command("r", hidden=True)
243
+ def report_alias(
244
+ ctx: typer.Context,
245
+ today_flag: bool = typer.Option(False, "--today"),
246
+ yesterday_flag: bool = typer.Option(False, "--yesterday"),
247
+ week: bool = typer.Option(False, "--week", "-w"),
248
+ last_week: bool = typer.Option(False, "--last-week"),
249
+ month: bool = typer.Option(False, "--month", "-m"),
250
+ from_date: Optional[str] = typer.Option(None, "--from"),
251
+ to_date: Optional[str] = typer.Option(None, "--to"),
252
+ project: Optional[str] = typer.Option(None, "--project", "-p"),
253
+ group_by: str = typer.Option("project", "--group-by", "-g"),
254
+ format_output: str = typer.Option("table", "--format", "-f"),
255
+ ) -> None:
256
+ """Alias for 'report'."""
257
+ report_cmd.report(
258
+ ctx=ctx,
259
+ today_flag=today_flag, yesterday_flag=yesterday_flag, week=week,
260
+ last_week=last_week, month=month, from_date=from_date, to_date=to_date,
261
+ project=project, group_by=group_by, format_output=format_output,
262
+ )
263
+
264
+
265
+ @app.command("p", hidden=True)
266
+ def projects_list_alias(
267
+ archived: bool = typer.Option(False, "--archived", "-a"),
268
+ output_json: bool = typer.Option(False, "--json"),
269
+ ) -> None:
270
+ """Alias for 'projects list'."""
271
+ projects_cmd.list_projects(archived=archived, output_json=output_json)
272
+
273
+
274
+ # ─── Magic routing: unrecognized text -> ai parse ────────────────────────────
275
+
276
+
277
+ def _is_known_command(arg: str) -> bool:
278
+ """Check if an argument is a known command or subcommand."""
279
+ known = {
280
+ "auth", "timer", "log", "projects", "clients", "tasks", "report", "ai",
281
+ "org", "config", "favorites", "skill", "status", "s", "t", "ts", "tx",
282
+ "l", "ll", "r", "p", "--help", "-h", "--version", "-v", "--json",
283
+ }
284
+ return arg in known
285
+
286
+
287
+ _LOG_SUBCOMMANDS = {"list", "edit", "delete", "create", "--help", "-h", "--json"}
288
+
289
+
290
+ def _fix_log_routing() -> None:
291
+ """Auto-insert 'create' for `ct log -p ... 2h "work"` style invocations.
292
+
293
+ When `ct log` is followed by args that aren't a known log subcommand,
294
+ the user intends `ct log create ...`. Insert 'create' so Typer routes
295
+ correctly without the callback needing positional args (which would
296
+ break subcommand routing).
297
+ """
298
+ if len(sys.argv) > 2 and sys.argv[1] == "log":
299
+ second = sys.argv[2]
300
+ if second not in _LOG_SUBCOMMANDS:
301
+ sys.argv = [sys.argv[0], "log", "create"] + sys.argv[2:]
302
+
303
+
304
+ def _original_main() -> None:
305
+ """CLI entry point with magic routing and error handling.
306
+
307
+ If the first argument is not a known command, treat the entire
308
+ argument string as natural language and route to 'ai parse'.
309
+ """
310
+ if len(sys.argv) > 1:
311
+ first_arg = sys.argv[1]
312
+ # If it's not a known command and doesn't start with --, route to ai parse
313
+ if not first_arg.startswith("-") and not _is_known_command(first_arg):
314
+ text = " ".join(sys.argv[1:])
315
+ sys.argv = [sys.argv[0], "ai", "parse", text]
316
+
317
+ _fix_log_routing()
318
+
319
+ try:
320
+ app()
321
+ except APIError as e:
322
+ format_error(e.message)
323
+ raise SystemExit(1)
324
+ except SystemExit:
325
+ raise
326
+ except KeyboardInterrupt:
327
+ raise SystemExit(0)
328
+ except Exception as e:
329
+ format_error(f"Unexpected error: {e}")
330
+ raise SystemExit(1)
331
+
332
+
333
+ if __name__ == "__main__":
334
+ _original_main()
@@ -0,0 +1,146 @@
1
+ """Pydantic models for CrowdTime API responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ from decimal import Decimal
7
+ from typing import Any, Optional
8
+ from pydantic import BaseModel, Field, model_validator
9
+
10
+
11
+ class User(BaseModel):
12
+ id: str
13
+ email: str
14
+ first_name: str = ""
15
+ last_name: str = ""
16
+ timezone: str = "UTC"
17
+
18
+ @property
19
+ def display_name(self) -> str:
20
+ if self.first_name or self.last_name:
21
+ return f"{self.first_name} {self.last_name}".strip()
22
+ return self.email
23
+
24
+
25
+ class Organization(BaseModel):
26
+ id: str
27
+ name: str
28
+ slug: str
29
+ role: str | None = None
30
+ member_count: int | None = None
31
+
32
+
33
+ class Project(BaseModel):
34
+ id: str
35
+ name: str
36
+ code: str = ""
37
+ client: str | None = None
38
+ color: str = "#3B82F6"
39
+ status: str = "active"
40
+ is_billable: bool = True
41
+ budget_hours: Decimal | None = None
42
+ description: str = ""
43
+
44
+
45
+ class Task(BaseModel):
46
+ id: str
47
+ name: str
48
+ project: str | None = None
49
+ project_name: str | None = None
50
+ is_billable: bool = True
51
+
52
+
53
+ class TimeEntry(BaseModel):
54
+ model_config = {"extra": "ignore"}
55
+
56
+ id: str | None = None
57
+ description: str = ""
58
+ notes: str = ""
59
+ project: str | None = None
60
+ project_name: str | None = None
61
+ project_code: str | None = None
62
+ project_color: str | None = None
63
+ task: str | None = None
64
+ task_name: str | None = None
65
+ date: Optional[dt.date] = None
66
+ start_time: Optional[dt.datetime] = None
67
+ end_time: Optional[dt.datetime] = None
68
+ duration: Decimal | None = None
69
+ hours: Decimal | None = None
70
+ is_running: bool = False
71
+ is_billable: bool = True
72
+ user: str | None = None
73
+ user_name: str | None = None
74
+ source: str = ""
75
+ created_at: Optional[dt.datetime] = None
76
+ updated_at: Optional[dt.datetime] = None
77
+
78
+ @model_validator(mode="before")
79
+ @classmethod
80
+ def _normalize_fields(cls, data: Any) -> Any:
81
+ if isinstance(data, dict):
82
+ # Map API field names to CLI field names
83
+ if "notes" in data and not data.get("description"):
84
+ data["description"] = data["notes"]
85
+ if "hours" in data and not data.get("duration"):
86
+ data["duration"] = data["hours"]
87
+ return data
88
+
89
+
90
+ class FavoriteEntry(BaseModel):
91
+ id: str
92
+ project: str | None = None
93
+ project_name: str | None = None
94
+ task: str | None = None
95
+ task_name: str | None = None
96
+ description: str = ""
97
+ is_billable: bool = True
98
+ use_count: int = 0
99
+
100
+
101
+ class ParseResult(BaseModel):
102
+ parsed_result: dict[str, Any] = Field(default_factory=dict)
103
+ confidence: float = 0.0
104
+ ambiguities: list[str] = Field(default_factory=list)
105
+ parse_log_id: str | None = None
106
+
107
+
108
+ class Suggestion(BaseModel):
109
+ model_config = {"extra": "ignore"}
110
+
111
+ project_name: str = ""
112
+ task_name: str | None = ""
113
+ description: str = ""
114
+ estimated_hours: Decimal | None = None
115
+ reason: str = ""
116
+
117
+
118
+ class WeeklySummary(BaseModel):
119
+ headline: str = ""
120
+ total_hours: Decimal = Decimal("0")
121
+ project_breakdown: list[dict[str, Any]] = Field(default_factory=list)
122
+ insights: list[str] = Field(default_factory=list)
123
+
124
+
125
+ class ReportRow(BaseModel):
126
+ project: str = ""
127
+ task: str = ""
128
+ hours: Decimal = Decimal("0")
129
+ billable_hours: Decimal = Decimal("0")
130
+ entries_count: int = 0
131
+
132
+
133
+ class Member(BaseModel):
134
+ id: str
135
+ user_email: str = ""
136
+ user_name: str = ""
137
+ role: str = "member"
138
+ is_active: bool = True
139
+
140
+
141
+ class TokenResponse(BaseModel):
142
+ id: str | None = None
143
+ name: str = ""
144
+ key: str = ""
145
+ key_prefix: str = ""
146
+ scopes: list[str] = Field(default_factory=list)
crowdtime_cli/oauth.py ADDED
@@ -0,0 +1,107 @@
1
+ """Browser-based OAuth login flow for CrowdTime CLI.
2
+
3
+ Opens the user's browser for Google OAuth authentication. The CLI polls
4
+ the server for the auth code, then exchanges it for an API token (PKCE-protected).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import secrets
11
+ import time
12
+ import webbrowser
13
+
14
+ import httpx
15
+
16
+
17
+ def run_oauth_flow(server_url: str, timeout: int = 120) -> tuple[str, str] | None:
18
+ """Run the browser-based OAuth login flow with PKCE protection.
19
+
20
+ 1. Opens browser for Google OAuth on the server
21
+ 2. Polls the server until the auth code is available
22
+ 3. Exchanges the code + PKCE verifier for an API token
23
+
24
+ Args:
25
+ server_url: The CrowdTime server URL (e.g. https://api.crowdtime.lat).
26
+ timeout: Max seconds to wait for the user to complete OAuth.
27
+
28
+ Returns:
29
+ Tuple of (api_token, org_slug) on success, or None on failure/timeout.
30
+ """
31
+ # Generate PKCE pair
32
+ code_verifier = secrets.token_hex(32)
33
+ code_challenge = hashlib.sha256(code_verifier.encode()).hexdigest()
34
+
35
+ # Generate state for CSRF protection
36
+ state = secrets.token_hex(32)
37
+
38
+ login_url = (
39
+ f'{server_url}/api/v1/auth/cli-login/'
40
+ f'?state={state}&code_challenge={code_challenge}'
41
+ )
42
+
43
+ if not webbrowser.open(login_url):
44
+ return None
45
+
46
+ # Poll the server for the auth code
47
+ poll_url = f'{server_url}/api/v1/auth/cli-poll/?state={state}'
48
+ deadline = time.monotonic() + timeout
49
+ poll_interval = 2 # seconds
50
+
51
+ while time.monotonic() < deadline:
52
+ time.sleep(poll_interval)
53
+ try:
54
+ response = httpx.get(poll_url, timeout=10.0)
55
+ if response.status_code == 200:
56
+ data = response.json()
57
+ if data.get('status') == 'complete' and data.get('code'):
58
+ return _exchange_code(
59
+ server_url=server_url,
60
+ code=data['code'],
61
+ code_verifier=code_verifier,
62
+ )
63
+ except (httpx.HTTPError, ValueError, KeyError):
64
+ pass # Retry on transient errors
65
+
66
+ return None
67
+
68
+
69
+ def _exchange_code(
70
+ server_url: str,
71
+ code: str,
72
+ code_verifier: str,
73
+ ) -> tuple[str, str] | None:
74
+ """Exchange a one-time auth code for an API token via server-to-server POST.
75
+
76
+ Args:
77
+ server_url: The CrowdTime server URL.
78
+ code: The one-time auth code from polling.
79
+ code_verifier: The PKCE verifier to prove we initiated the flow.
80
+
81
+ Returns:
82
+ Tuple of (api_token, org_slug) on success, or None on failure.
83
+ """
84
+ try:
85
+ response = httpx.post(
86
+ f'{server_url}/api/v1/auth/cli-exchange/',
87
+ json={
88
+ 'code': code,
89
+ 'code_verifier': code_verifier,
90
+ },
91
+ timeout=10.0,
92
+ )
93
+
94
+ if response.status_code != 200:
95
+ return None
96
+
97
+ data = response.json()
98
+ token = data.get('token')
99
+ org_slug = data.get('org_slug', '')
100
+
101
+ if not token:
102
+ return None
103
+
104
+ return token, org_slug
105
+
106
+ except (httpx.HTTPError, ValueError, KeyError):
107
+ return None
@@ -0,0 +1,80 @@
1
+ """Shared name-to-UUID resolution helpers for CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from rich.console import Console
8
+
9
+ from .client import APIError, CrowdTimeClient
10
+
11
+ console = Console(stderr=True)
12
+
13
+
14
+ def resolve_task(client: CrowdTimeClient, task_ref: str, project_id: str | None = None) -> str:
15
+ """Resolve a task name or UUID to a UUID. Auto-assigns to project if needed.
16
+
17
+ Args:
18
+ client: Authenticated API client.
19
+ task_ref: Task name or UUID.
20
+ project_id: If provided, ensures the task is assigned to this project.
21
+
22
+ Returns:
23
+ The task UUID string.
24
+ """
25
+ # If it looks like a UUID already, use it directly
26
+ if _looks_like_uuid(task_ref):
27
+ task_id = task_ref
28
+ else:
29
+ # Search org tasks by name
30
+ data = client.get("/tasks/", params={"search": task_ref})
31
+ tasks_list = data if isinstance(data, list) else data.get("results", [])
32
+
33
+ # Exact match (case-insensitive)
34
+ task_id = None
35
+ for t in tasks_list:
36
+ if t.get("name", "").lower() == task_ref.lower():
37
+ task_id = t["id"]
38
+ break
39
+
40
+ if task_id is None:
41
+ # No match — create the task
42
+ console.print(f"[dim]Task '{task_ref}' not found — creating it...[/dim]")
43
+ new_task = client.post("/tasks/", data={"name": task_ref})
44
+ task_id = new_task["id"]
45
+
46
+ # If a project is specified, ensure the task is assigned to it
47
+ if project_id:
48
+ _ensure_task_assigned_to_project(client, task_id, project_id)
49
+
50
+ return task_id
51
+
52
+
53
+ def _ensure_task_assigned_to_project(
54
+ client: CrowdTimeClient, task_id: str, project_id: str
55
+ ) -> None:
56
+ """Check if a task is assigned to a project; assign it if not."""
57
+ # List tasks currently assigned to the project
58
+ try:
59
+ data = client.get(f"/projects/{project_id}/tasks/")
60
+ assigned = data if isinstance(data, list) else data.get("results", [])
61
+
62
+ for pt in assigned:
63
+ # The response has 'task' (UUID) field
64
+ if pt.get("task") == task_id:
65
+ return # Already assigned
66
+
67
+ # Not assigned — assign it
68
+ console.print(f"[dim]Assigning task to project...[/dim]")
69
+ client.post(f"/projects/{project_id}/tasks/", data={"task": task_id})
70
+ except APIError:
71
+ # If we can't check/assign, let the API validate later
72
+ pass
73
+
74
+
75
+ def _looks_like_uuid(value: str) -> bool:
76
+ """Quick check if a string looks like a UUID."""
77
+ return bool(re.match(
78
+ r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
79
+ value.lower(),
80
+ ))