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.
- clockify_unofficial_cli/__init__.py +3 -0
- clockify_unofficial_cli/__main__.py +3 -0
- clockify_unofficial_cli/_version.py +3 -0
- clockify_unofficial_cli/auth/__init__.py +0 -0
- clockify_unofficial_cli/auth/credentials.py +70 -0
- clockify_unofficial_cli/auth/env_store.py +26 -0
- clockify_unofficial_cli/auth/file_store.py +49 -0
- clockify_unofficial_cli/auth/keyring_store.py +62 -0
- clockify_unofficial_cli/auth/resolver.py +34 -0
- clockify_unofficial_cli/commands/__init__.py +0 -0
- clockify_unofficial_cli/commands/auth.py +153 -0
- clockify_unofficial_cli/commands/client.py +116 -0
- clockify_unofficial_cli/commands/config.py +50 -0
- clockify_unofficial_cli/commands/custom_field.py +130 -0
- clockify_unofficial_cli/commands/entry.py +275 -0
- clockify_unofficial_cli/commands/group.py +89 -0
- clockify_unofficial_cli/commands/project.py +174 -0
- clockify_unofficial_cli/commands/tag.py +107 -0
- clockify_unofficial_cli/commands/task.py +194 -0
- clockify_unofficial_cli/commands/timer_shortcuts.py +130 -0
- clockify_unofficial_cli/commands/user.py +45 -0
- clockify_unofficial_cli/commands/workspace.py +68 -0
- clockify_unofficial_cli/config/__init__.py +0 -0
- clockify_unofficial_cli/config/paths.py +25 -0
- clockify_unofficial_cli/config/settings.py +75 -0
- clockify_unofficial_cli/config/store.py +56 -0
- clockify_unofficial_cli/main.py +131 -0
- clockify_unofficial_cli/output/__init__.py +0 -0
- clockify_unofficial_cli/output/columns.py +88 -0
- clockify_unofficial_cli/output/formats.py +82 -0
- clockify_unofficial_cli/output/registry.py +29 -0
- clockify_unofficial_cli/output/renderer.py +68 -0
- clockify_unofficial_cli/runtime/__init__.py +0 -0
- clockify_unofficial_cli/runtime/client_factory.py +42 -0
- clockify_unofficial_cli/runtime/context.py +99 -0
- clockify_unofficial_cli/runtime/errors.py +89 -0
- clockify_unofficial_cli/runtime/exit_codes.py +15 -0
- clockify_unofficial_cli/runtime/params.py +39 -0
- clockify_unofficial_cli/runtime/prompts.py +24 -0
- clockify_unofficial_cli/services/__init__.py +0 -0
- clockify_unofficial_cli/services/listing.py +58 -0
- clockify_unofficial_cli/services/parsing.py +118 -0
- clockify_unofficial_cli/services/resolve.py +128 -0
- clockify_unofficial_cli/services/timer.py +126 -0
- clockify_unofficial_cli-0.2.0.dist-info/METADATA +278 -0
- clockify_unofficial_cli-0.2.0.dist-info/RECORD +49 -0
- clockify_unofficial_cli-0.2.0.dist-info/WHEEL +4 -0
- clockify_unofficial_cli-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|