clockify-unofficial-cli 0.2.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.
Files changed (49) hide show
  1. clockify_unofficial_cli/__init__.py +3 -0
  2. clockify_unofficial_cli/__main__.py +3 -0
  3. clockify_unofficial_cli/_version.py +3 -0
  4. clockify_unofficial_cli/auth/__init__.py +0 -0
  5. clockify_unofficial_cli/auth/credentials.py +70 -0
  6. clockify_unofficial_cli/auth/env_store.py +26 -0
  7. clockify_unofficial_cli/auth/file_store.py +49 -0
  8. clockify_unofficial_cli/auth/keyring_store.py +62 -0
  9. clockify_unofficial_cli/auth/resolver.py +34 -0
  10. clockify_unofficial_cli/commands/__init__.py +0 -0
  11. clockify_unofficial_cli/commands/auth.py +153 -0
  12. clockify_unofficial_cli/commands/client.py +116 -0
  13. clockify_unofficial_cli/commands/config.py +50 -0
  14. clockify_unofficial_cli/commands/custom_field.py +130 -0
  15. clockify_unofficial_cli/commands/entry.py +275 -0
  16. clockify_unofficial_cli/commands/group.py +89 -0
  17. clockify_unofficial_cli/commands/project.py +174 -0
  18. clockify_unofficial_cli/commands/tag.py +107 -0
  19. clockify_unofficial_cli/commands/task.py +194 -0
  20. clockify_unofficial_cli/commands/timer_shortcuts.py +130 -0
  21. clockify_unofficial_cli/commands/user.py +45 -0
  22. clockify_unofficial_cli/commands/workspace.py +68 -0
  23. clockify_unofficial_cli/config/__init__.py +0 -0
  24. clockify_unofficial_cli/config/paths.py +25 -0
  25. clockify_unofficial_cli/config/settings.py +75 -0
  26. clockify_unofficial_cli/config/store.py +56 -0
  27. clockify_unofficial_cli/main.py +131 -0
  28. clockify_unofficial_cli/output/__init__.py +0 -0
  29. clockify_unofficial_cli/output/columns.py +88 -0
  30. clockify_unofficial_cli/output/formats.py +82 -0
  31. clockify_unofficial_cli/output/registry.py +29 -0
  32. clockify_unofficial_cli/output/renderer.py +68 -0
  33. clockify_unofficial_cli/runtime/__init__.py +0 -0
  34. clockify_unofficial_cli/runtime/client_factory.py +42 -0
  35. clockify_unofficial_cli/runtime/context.py +99 -0
  36. clockify_unofficial_cli/runtime/errors.py +89 -0
  37. clockify_unofficial_cli/runtime/exit_codes.py +15 -0
  38. clockify_unofficial_cli/runtime/params.py +39 -0
  39. clockify_unofficial_cli/runtime/prompts.py +24 -0
  40. clockify_unofficial_cli/services/__init__.py +0 -0
  41. clockify_unofficial_cli/services/listing.py +58 -0
  42. clockify_unofficial_cli/services/parsing.py +118 -0
  43. clockify_unofficial_cli/services/resolve.py +128 -0
  44. clockify_unofficial_cli/services/timer.py +126 -0
  45. clockify_unofficial_cli-0.2.0.dist-info/METADATA +278 -0
  46. clockify_unofficial_cli-0.2.0.dist-info/RECORD +49 -0
  47. clockify_unofficial_cli-0.2.0.dist-info/WHEEL +4 -0
  48. clockify_unofficial_cli-0.2.0.dist-info/entry_points.txt +2 -0
  49. clockify_unofficial_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from tempfile import NamedTemporaryFile
5
+ from typing import TYPE_CHECKING
6
+ from typing import Final
7
+
8
+ import tomli_w
9
+ from pydantic import ValidationError
10
+
11
+ from clockify_unofficial_cli.config.settings import Settings
12
+ from clockify_unofficial_cli.runtime.errors import CliError
13
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+ PRIVATE_FILE_MODE: Final = 0o600
19
+ PRIVATE_DIR_MODE: Final = 0o700
20
+
21
+
22
+ def read_toml(path: Path) -> dict[str, object]:
23
+ try:
24
+ with path.open("rb") as handle:
25
+ return tomllib.load(handle)
26
+ except FileNotFoundError:
27
+ return {}
28
+ except tomllib.TOMLDecodeError as error:
29
+ message = f"Cannot parse {path}: {error}"
30
+ raise CliError(message, exit_code=ExitCode.CONFIGURATION) from error
31
+
32
+
33
+ # Written to a sibling temp file and renamed so a crash never leaves a half-written file,
34
+ # and created 0600 from the start so secrets are never briefly world-readable.
35
+ def write_private_toml(path: Path, data: dict[str, object]) -> None:
36
+ path.parent.mkdir(mode=PRIVATE_DIR_MODE, parents=True, exist_ok=True)
37
+ with NamedTemporaryFile("wb", dir=path.parent, prefix=f".{path.name}.", delete=False) as handle:
38
+ tomli_w.dump(data, handle)
39
+ temp_path = path.parent / handle.name
40
+ temp_path.chmod(PRIVATE_FILE_MODE)
41
+ temp_path.replace(path)
42
+
43
+
44
+ class SettingsStore:
45
+ def __init__(self, path: Path) -> None:
46
+ self.path = path
47
+
48
+ def load(self) -> Settings:
49
+ try:
50
+ return Settings.model_validate(read_toml(self.path))
51
+ except ValidationError as error:
52
+ message = f"Invalid settings in {self.path}: {error.error_count()} problem(s)"
53
+ raise CliError(message, exit_code=ExitCode.CONFIGURATION, hint=str(error)) from error
54
+
55
+ def save(self, settings: Settings) -> None:
56
+ write_private_toml(self.path, settings.model_dump(mode="json", exclude_none=True))
@@ -0,0 +1,131 @@
1
+ import os
2
+ import sys
3
+ from dataclasses import dataclass
4
+ from typing import TYPE_CHECKING
5
+ from typing import Annotated
6
+ from typing import Final
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from clockify_unofficial_cli._version import __version__
12
+ from clockify_unofficial_cli.auth.env_store import EnvCredentialStore
13
+ from clockify_unofficial_cli.auth.file_store import FileCredentialStore
14
+ from clockify_unofficial_cli.auth.keyring_store import KeyringCredentialStore
15
+ from clockify_unofficial_cli.auth.resolver import CredentialStores
16
+ from clockify_unofficial_cli.commands import auth
17
+ from clockify_unofficial_cli.commands import client
18
+ from clockify_unofficial_cli.commands import config
19
+ from clockify_unofficial_cli.commands import custom_field
20
+ from clockify_unofficial_cli.commands import entry
21
+ from clockify_unofficial_cli.commands import group
22
+ from clockify_unofficial_cli.commands import project
23
+ from clockify_unofficial_cli.commands import tag
24
+ from clockify_unofficial_cli.commands import task
25
+ from clockify_unofficial_cli.commands import timer_shortcuts
26
+ from clockify_unofficial_cli.commands import user
27
+ from clockify_unofficial_cli.commands import workspace
28
+ from clockify_unofficial_cli.config.paths import credentials_file
29
+ from clockify_unofficial_cli.config.paths import settings_file
30
+ from clockify_unofficial_cli.config.settings import GlobalOptions
31
+ from clockify_unofficial_cli.config.settings import OutputFormat
32
+ from clockify_unofficial_cli.config.settings import resolve_options
33
+ from clockify_unofficial_cli.config.store import SettingsStore
34
+ from clockify_unofficial_cli.output.registry import create_renderer
35
+ from clockify_unofficial_cli.output.renderer import RenderTarget
36
+ from clockify_unofficial_cli.runtime.client_factory import create_sdk_client
37
+ from clockify_unofficial_cli.runtime.context import AppContext
38
+ from clockify_unofficial_cli.runtime.context import Services
39
+ from clockify_unofficial_cli.runtime.errors import handle_errors
40
+ from clockify_unofficial_cli.runtime.params import options_from
41
+
42
+ if TYPE_CHECKING:
43
+ from collections.abc import Callable
44
+
45
+
46
+ def default_services() -> Services:
47
+ return Services(
48
+ settings=SettingsStore(settings_file()),
49
+ credentials=CredentialStores(
50
+ environment=EnvCredentialStore(os.environ),
51
+ keyring=KeyringCredentialStore(),
52
+ file=FileCredentialStore(credentials_file()),
53
+ ),
54
+ clients=create_sdk_client,
55
+ )
56
+
57
+
58
+ def print_version(
59
+ value: bool, # ruff: ignore[boolean-type-hint-positional-argument] Typer passes the flag value positionally
60
+ ) -> None:
61
+ if value:
62
+ typer.echo(f"clockify {__version__}")
63
+ raise typer.Exit
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class _RootOptions:
68
+ profile: Annotated[
69
+ str | None,
70
+ typer.Option("--profile", "-p", envvar="CLOCKIFY_PROFILE", help="Configuration profile to use."),
71
+ ] = None
72
+ workspace: Annotated[
73
+ str | None,
74
+ typer.Option("--workspace", "-w", envvar="CLOCKIFY_WORKSPACE", help="Workspace ID; overrides the profile."),
75
+ ] = None
76
+ output: Annotated[
77
+ OutputFormat | None,
78
+ typer.Option("--output", "-o", envvar="CLOCKIFY_OUTPUT", case_sensitive=False, help="Output format."),
79
+ ] = None
80
+ verbose: Annotated[
81
+ bool,
82
+ typer.Option("--verbose", "-v", help="Print HTTP request diagnostics to stderr."),
83
+ ] = False
84
+ version: Annotated[
85
+ bool,
86
+ typer.Option("--version", callback=print_version, is_eager=True, help="Show the version and exit."),
87
+ ] = False
88
+
89
+
90
+ # Global options must precede the sub-command (`clockify -o json auth status`). Colors follow
91
+ # Rich's NO_COLOR handling, so there is no --no-color flag to keep the callback small.
92
+ def create_app(services_factory: Callable[[], Services] = default_services) -> typer.Typer:
93
+ cli = typer.Typer(name="clockify", help="Unofficial command-line interface for Clockify.", no_args_is_help=True)
94
+
95
+ @cli.callback()
96
+ @handle_errors
97
+ @options_from(_RootOptions)
98
+ def root(ctx: typer.Context, options: _RootOptions) -> None:
99
+ flags = GlobalOptions(
100
+ profile=options.profile, workspace=options.workspace, output=options.output, verbose=options.verbose
101
+ )
102
+ _bind_context(ctx=ctx, services=services_factory(), flags=flags)
103
+
104
+ def _bind_context(*, ctx: typer.Context, services: Services, flags: GlobalOptions) -> None:
105
+ resolved = resolve_options(flags, services.settings.load(), is_tty=sys.stdout.isatty())
106
+ console = Console(highlight=False)
107
+ renderer = create_renderer(resolved.output, RenderTarget(console=console, stream=sys.stdout))
108
+ app_context = AppContext(options=resolved, services=services, console=console, renderer=renderer)
109
+ ctx.obj = app_context
110
+ ctx.call_on_close(app_context.close)
111
+
112
+ cli.add_typer(auth.APP, name="auth")
113
+ cli.add_typer(config.APP, name="config")
114
+ cli.add_typer(workspace.APP, name="workspace")
115
+ cli.add_typer(user.APP, name="user")
116
+ cli.add_typer(client.APP, name="client")
117
+ cli.add_typer(project.APP, name="project")
118
+ cli.add_typer(tag.APP, name="tag")
119
+ cli.add_typer(task.APP, name="task")
120
+ cli.add_typer(custom_field.APP, name="custom-field")
121
+ cli.add_typer(group.APP, name="group")
122
+ cli.add_typer(entry.APP, name="entry")
123
+ cli.registered_commands.extend(timer_shortcuts.APP.registered_commands)
124
+ return cli
125
+
126
+
127
+ APP: Final = create_app()
128
+
129
+
130
+ def run() -> None:
131
+ APP(prog_name="clockify")
File without changes
@@ -0,0 +1,88 @@
1
+ from typing import Final
2
+
3
+ from clockify_unofficial_cli.output.renderer import Column
4
+
5
+ AUTH_STATUS: Final = (
6
+ Column("profile", "Profile"),
7
+ Column("name", "User"),
8
+ Column("email", "Email"),
9
+ Column("workspaceId", "Workspace"),
10
+ Column("region", "Region"),
11
+ Column("source", "Credential source"),
12
+ Column("apiKey", "API key"),
13
+ )
14
+
15
+ PROFILES: Final = (
16
+ Column("name", "Profile"),
17
+ Column("default", "Default"),
18
+ Column("region", "Region"),
19
+ Column("workspaceId", "Workspace"),
20
+ Column("email", "Email"),
21
+ )
22
+
23
+ WORKSPACES: Final = (
24
+ Column("id", "ID"),
25
+ Column("name", "Name"),
26
+ )
27
+
28
+ USERS: Final = (
29
+ Column("id", "ID"),
30
+ Column("name", "Name"),
31
+ Column("email", "Email"),
32
+ Column("status", "Status"),
33
+ )
34
+
35
+ CLIENTS: Final = (
36
+ Column("id", "ID"),
37
+ Column("name", "Name"),
38
+ Column("email", "Email"),
39
+ Column("archived", "Archived"),
40
+ )
41
+
42
+ PROJECTS_LIST: Final = (
43
+ Column("id", "ID"),
44
+ Column("name", "Name"),
45
+ Column("clientId", "Client"),
46
+ Column("public", "Public"),
47
+ Column("billable", "Billable"),
48
+ Column("archived", "Archived"),
49
+ )
50
+
51
+ TAGS: Final = (
52
+ Column("id", "ID"),
53
+ Column("name", "Name"),
54
+ Column("archived", "Archived"),
55
+ )
56
+
57
+ TASKS: Final = (
58
+ Column("id", "ID"),
59
+ Column("name", "Name"),
60
+ Column("status", "Status"),
61
+ Column("assigneeIds", "Assignees"),
62
+ Column("estimate", "Estimate"),
63
+ )
64
+
65
+ CUSTOM_FIELDS: Final = (
66
+ Column("id", "ID"),
67
+ Column("name", "Name"),
68
+ Column("type", "Type"),
69
+ Column("entityType", "Entity"),
70
+ Column("status", "Status"),
71
+ )
72
+
73
+ GROUPS: Final = (
74
+ Column("id", "ID"),
75
+ Column("name", "Name"),
76
+ Column("userIds", "Members"),
77
+ )
78
+
79
+ TIME_ENTRIES: Final = (
80
+ Column("id", "ID"),
81
+ Column("description", "Description"),
82
+ Column("projectId", "Project"),
83
+ Column("taskId", "Task"),
84
+ Column("timeInterval.start", "Start"),
85
+ Column("timeInterval.end", "End"),
86
+ Column("timeInterval.duration", "Duration"),
87
+ Column("billable", "Billable"),
88
+ )
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from typing import TYPE_CHECKING
6
+
7
+ from rich.table import Table
8
+
9
+ if TYPE_CHECKING:
10
+ from clockify_unofficial_cli.output.renderer import Dataset
11
+ from clockify_unofficial_cli.output.renderer import RenderTarget
12
+
13
+
14
+ def _cell(value: object) -> str:
15
+ if value is None:
16
+ return ""
17
+ if isinstance(value, bool):
18
+ return "yes" if value else "no"
19
+ if isinstance(value, (dict, list)):
20
+ return json.dumps(value)
21
+ return str(value)
22
+
23
+
24
+ class TableRenderer:
25
+ def __init__(self, target: RenderTarget) -> None:
26
+ self._console = target.console
27
+
28
+ def render(self, dataset: Dataset) -> None:
29
+ if dataset.single and dataset.records:
30
+ table = Table(show_header=False, box=None)
31
+ table.add_column(style="bold")
32
+ table.add_column()
33
+ for column in dataset.columns:
34
+ table.add_row(column.header, _cell(column.value(dataset.records[0])))
35
+ else:
36
+ table = Table()
37
+ for column in dataset.columns:
38
+ table.add_column(column.header)
39
+ for record in dataset.records:
40
+ table.add_row(*(_cell(column.value(record)) for column in dataset.columns))
41
+ self._console.print(table)
42
+
43
+
44
+ class JsonRenderer:
45
+ def __init__(self, target: RenderTarget) -> None:
46
+ self._stream = target.stream
47
+
48
+ def render(self, dataset: Dataset) -> None:
49
+ payload = dataset.records[0] if dataset.single and dataset.records else list(dataset.records)
50
+ self._stream.write(json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
51
+
52
+
53
+ class JsonLinesRenderer:
54
+ def __init__(self, target: RenderTarget) -> None:
55
+ self._stream = target.stream
56
+
57
+ def render(self, dataset: Dataset) -> None:
58
+ for record in dataset.records:
59
+ self._stream.write(json.dumps(record, ensure_ascii=False) + "\n")
60
+
61
+
62
+ class CsvRenderer:
63
+ def __init__(self, target: RenderTarget) -> None:
64
+ self._stream = target.stream
65
+
66
+ def render(self, dataset: Dataset) -> None:
67
+ writer = csv.writer(self._stream, lineterminator="\n")
68
+ writer.writerow(column.header for column in dataset.columns)
69
+ for record in dataset.records:
70
+ writer.writerow(_cell(column.value(record)) for column in dataset.columns)
71
+
72
+
73
+ # Bare identifiers, one per line, for `xargs`/`$(...)` pipelines.
74
+ class IdRenderer:
75
+ def __init__(self, target: RenderTarget) -> None:
76
+ self._stream = target.stream
77
+
78
+ def render(self, dataset: Dataset) -> None:
79
+ for record in dataset.records:
80
+ identifier = record.get("id")
81
+ if identifier is not None:
82
+ self._stream.write(f"{identifier}\n")
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+ from typing import Final
5
+
6
+ from clockify_unofficial_cli.config.settings import OutputFormat
7
+ from clockify_unofficial_cli.output.formats import CsvRenderer
8
+ from clockify_unofficial_cli.output.formats import IdRenderer
9
+ from clockify_unofficial_cli.output.formats import JsonLinesRenderer
10
+ from clockify_unofficial_cli.output.formats import JsonRenderer
11
+ from clockify_unofficial_cli.output.formats import TableRenderer
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable
15
+
16
+ from clockify_unofficial_cli.output.renderer import RenderTarget
17
+ from clockify_unofficial_cli.output.renderer import Renderer
18
+
19
+ _RENDERERS: Final[dict[OutputFormat, Callable[[RenderTarget], Renderer]]] = {
20
+ OutputFormat.TABLE: TableRenderer,
21
+ OutputFormat.JSON: JsonRenderer,
22
+ OutputFormat.JSONL: JsonLinesRenderer,
23
+ OutputFormat.CSV: CsvRenderer,
24
+ OutputFormat.ID: IdRenderer,
25
+ }
26
+
27
+
28
+ def create_renderer(output: OutputFormat, target: RenderTarget) -> Renderer:
29
+ return _RENDERERS[output](target)
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from dataclasses import field
6
+ from typing import TYPE_CHECKING
7
+ from typing import Protocol
8
+
9
+ from pydantic import BaseModel
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Iterable
13
+ from collections.abc import Sequence
14
+ from typing import TextIO
15
+
16
+ from rich.console import Console
17
+
18
+ type Record = Mapping[str, object] # pylint: disable=gajaguar-module-const-naming
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Column:
23
+ key: str
24
+ header: str
25
+
26
+ # Keys may be dotted paths into nested API objects, e.g. "timeInterval.duration".
27
+ def value(self, record: Record) -> object:
28
+ current: object = record
29
+ for part in self.key.split("."):
30
+ if not isinstance(current, Mapping):
31
+ return None
32
+ current = current.get(part)
33
+ return current
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class Dataset:
38
+ records: Sequence[Record]
39
+ columns: Sequence[Column]
40
+ single: bool = False
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class RenderTarget:
45
+ console: Console
46
+ stream: TextIO = field(repr=False)
47
+
48
+
49
+ class Renderer(Protocol):
50
+ def render(self, dataset: Dataset) -> None: ...
51
+
52
+
53
+ # JSON output keeps Clockify's camelCase field names so it lines up with the API docs.
54
+ def to_record(item: BaseModel | Record | object) -> Record:
55
+ if isinstance(item, BaseModel):
56
+ return item.model_dump(mode="json", by_alias=True)
57
+ if isinstance(item, Mapping):
58
+ return item
59
+ message = f"Cannot coerce {type(item).__name__} to a renderer record."
60
+ raise TypeError(message)
61
+
62
+
63
+ def many(items: Iterable[BaseModel | Record], columns: Sequence[Column]) -> Dataset:
64
+ return Dataset(records=[to_record(item) for item in items], columns=columns)
65
+
66
+
67
+ def single(item: BaseModel | Record, columns: Sequence[Column]) -> Dataset:
68
+ return Dataset(records=[to_record(item)], columns=columns, single=True)
File without changes
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from clockify import ClientOptions
9
+ from clockify import ClockifyClient
10
+ from clockify import Region
11
+
12
+ if TYPE_CHECKING:
13
+ from clockify_unofficial_cli.auth.credentials import Credential
14
+
15
+
16
+ # Per-invocation bundle so the client factory can build a verbose request hook without
17
+ # growing a long positional argument list every time a new flag appears.
18
+ @dataclass(frozen=True, slots=True)
19
+ class ClientRequest:
20
+ credential: Credential
21
+ region: Region
22
+ verbose: bool = False
23
+
24
+
25
+ type ClientFactory = Callable[[ClientRequest], ClockifyClient] # pylint: disable=gajaguar-module-const-naming
26
+
27
+
28
+ def _log_response(response: object) -> None:
29
+ raw_request = getattr(response, "request", None)
30
+ method = getattr(raw_request, "method", "?")
31
+ url = str(getattr(raw_request, "url", "?"))
32
+ status = getattr(response, "status_code", "?")
33
+ sys.stderr.write(f"-> {method} {url} {status}\n")
34
+ sys.stderr.flush()
35
+
36
+
37
+ def create_sdk_client(request: ClientRequest) -> ClockifyClient:
38
+ event_hooks: dict[str, list[Callable[..., object]]] | None = (
39
+ {"response": [_log_response]} if request.verbose else None
40
+ )
41
+ options = ClientOptions(region=request.region, event_hooks=event_hooks)
42
+ return ClockifyClient(api_key=request.credential.api_key, options=options)
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from dataclasses import field
5
+ from typing import TYPE_CHECKING
6
+
7
+ from clockify import UserId
8
+ from rich.console import Console
9
+
10
+ from clockify_unofficial_cli.runtime.client_factory import ClientRequest
11
+ from clockify_unofficial_cli.runtime.errors import CliError
12
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
13
+
14
+ if TYPE_CHECKING:
15
+ import typer
16
+ from clockify import ClockifyClient
17
+ from clockify import WorkspaceClient
18
+
19
+ from clockify_unofficial_cli.auth.credentials import ResolvedCredential
20
+ from clockify_unofficial_cli.auth.resolver import CredentialStores
21
+ from clockify_unofficial_cli.config.settings import ResolvedOptions
22
+ from clockify_unofficial_cli.config.store import SettingsStore
23
+ from clockify_unofficial_cli.output.renderer import Dataset
24
+ from clockify_unofficial_cli.output.renderer import Renderer
25
+ from clockify_unofficial_cli.runtime.client_factory import ClientFactory
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class Services:
30
+ settings: SettingsStore
31
+ credentials: CredentialStores
32
+ clients: ClientFactory
33
+
34
+
35
+ # Built once per invocation by the root callback and shared with every command through
36
+ # typer.Context.obj; the SDK client is created lazily so offline commands never need a key.
37
+ @dataclass(slots=True)
38
+ class AppContext:
39
+ options: ResolvedOptions
40
+ services: Services
41
+ console: Console
42
+ renderer: Renderer
43
+ _client: ClockifyClient | None = field(default=None, init=False, repr=False)
44
+ _user_id: UserId | None = field(default=None, init=False, repr=False)
45
+
46
+ def credential(self) -> ResolvedCredential:
47
+ resolved = self.services.credentials.resolve(self.options.profile_name)
48
+ if resolved is None:
49
+ message = f"Not logged in (profile '{self.options.profile_name}')."
50
+ hint = "Run `clockify auth login` or set CLOCKIFY_API_KEY."
51
+ raise CliError(message, exit_code=ExitCode.CONFIGURATION, hint=hint)
52
+ return resolved
53
+
54
+ def client(self) -> ClockifyClient:
55
+ if self._client is None:
56
+ credential = self.credential().credential
57
+ request = ClientRequest(
58
+ credential=credential,
59
+ region=self.options.profile.region,
60
+ verbose=self.options.verbose,
61
+ )
62
+ self._client = self.services.clients(request)
63
+ return self._client
64
+
65
+ def user_id(self) -> UserId:
66
+ if self._user_id is None:
67
+ cached = self.options.profile.user_id
68
+ if cached is not None:
69
+ self._user_id = UserId(cached)
70
+ else:
71
+ self._user_id = self.client().user.me().id
72
+ return self._user_id
73
+
74
+ def workspace(self) -> WorkspaceClient:
75
+ if self.options.workspace_id:
76
+ return self.client().workspace(self.options.workspace_id)
77
+ return self.client().default_workspace()
78
+
79
+ # Status messages go to stderr so stdout stays clean for piping rendered data.
80
+ @staticmethod
81
+ def notify(message: str) -> None:
82
+ Console(stderr=True, highlight=False).print(message, markup=False)
83
+
84
+ def render(self, dataset: Dataset) -> None:
85
+ self.renderer.render(dataset)
86
+
87
+ def close(self) -> None:
88
+ if self._client is not None:
89
+ self._client.close()
90
+ self._client = None
91
+ self._user_id = None
92
+
93
+
94
+ def get_app_context(ctx: typer.Context) -> AppContext:
95
+ obj: object = ctx.find_root().obj
96
+ if not isinstance(obj, AppContext):
97
+ message = "CLI context is not initialized."
98
+ raise CliError(message)
99
+ return obj
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import wraps
4
+ from typing import TYPE_CHECKING
5
+ from typing import Final
6
+
7
+ import typer
8
+ from clockify.errors import AuthenticationError
9
+ from clockify.errors import ClockifyAPIError
10
+ from clockify.errors import ClockifyError
11
+ from clockify.errors import ConfigurationError
12
+ from clockify.errors import ConflictError
13
+ from clockify.errors import ForbiddenError
14
+ from clockify.errors import NotFoundError
15
+ from clockify.errors import RateLimitError
16
+ from clockify.errors import ServerError
17
+ from clockify.errors import TransportError
18
+ from clockify.errors import ValidationError
19
+ from rich.console import Console
20
+ from rich.markup import escape
21
+
22
+ from clockify_unofficial_cli.runtime.exit_codes import ExitCode
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Callable
26
+
27
+
28
+ class CliError(Exception):
29
+ def __init__(self, message: str, *, exit_code: ExitCode = ExitCode.FAILURE, hint: str | None = None) -> None:
30
+ super().__init__(message)
31
+ self.message = message
32
+ self.exit_code = exit_code
33
+ self.hint = hint
34
+
35
+
36
+ # Ordered most-specific first: the first isinstance match wins.
37
+ _SDK_EXIT_CODES: Final[tuple[tuple[type[ClockifyError], ExitCode], ...]] = (
38
+ (ConfigurationError, ExitCode.CONFIGURATION),
39
+ (AuthenticationError, ExitCode.AUTHENTICATION),
40
+ (ForbiddenError, ExitCode.FORBIDDEN),
41
+ (NotFoundError, ExitCode.NOT_FOUND),
42
+ (ValidationError, ExitCode.VALIDATION),
43
+ (ConflictError, ExitCode.VALIDATION),
44
+ (RateLimitError, ExitCode.RATE_LIMITED),
45
+ (ServerError, ExitCode.UNAVAILABLE),
46
+ (TransportError, ExitCode.UNAVAILABLE),
47
+ )
48
+
49
+ _SDK_HINTS: Final[dict[ExitCode, str]] = {
50
+ ExitCode.AUTHENTICATION: "Run `clockify auth login` to store a valid API key.",
51
+ ExitCode.RATE_LIMITED: "Clockify rate limit reached after retries; try again shortly.",
52
+ }
53
+
54
+
55
+ def exit_code_for(error: ClockifyError) -> ExitCode:
56
+ for error_type, exit_code in _SDK_EXIT_CODES:
57
+ if isinstance(error, error_type):
58
+ return exit_code
59
+ return ExitCode.FAILURE
60
+
61
+
62
+ def describe(error: ClockifyError) -> str:
63
+ detail = error.message or type(error).__name__
64
+ if isinstance(error, ClockifyAPIError) and error.status_code:
65
+ return f"{detail} (HTTP {error.status_code})"
66
+ return detail
67
+
68
+
69
+ def report(message: str, hint: str | None = None) -> None:
70
+ console = Console(stderr=True, highlight=False)
71
+ console.print(f"[bold red]error:[/] {escape(message)}")
72
+ if hint:
73
+ console.print(f"[dim]hint:[/] {escape(hint)}")
74
+
75
+
76
+ def handle_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
77
+ @wraps(func)
78
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
79
+ try:
80
+ return func(*args, **kwargs)
81
+ except CliError as error:
82
+ report(error.message, error.hint)
83
+ raise typer.Exit(error.exit_code) from error
84
+ except ClockifyError as error:
85
+ exit_code = exit_code_for(error)
86
+ report(describe(error), _SDK_HINTS.get(exit_code))
87
+ raise typer.Exit(exit_code) from error
88
+
89
+ return wrapper