clinear 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.
VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.2.0
clinear/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ """clinear — Type-safe Linear CLI built on Pydantic v2."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def _read_version() -> str:
7
+ """Read version from VERSION file at the repo root.
8
+
9
+ Falls back to a hardcoded string if the file is missing (e.g. when
10
+ installed from a wheel where VERSION isn't packaged).
11
+ """
12
+ try:
13
+ version_file = Path(__file__).resolve().parent.parent / "VERSION"
14
+ if version_file.is_file():
15
+ return version_file.read_text(encoding="utf-8").strip()
16
+ except OSError:
17
+ pass
18
+ return "0.2.0"
19
+
20
+
21
+ __version__ = _read_version()
22
+ __all__ = ["__version__"]
clinear/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m clinear`."""
2
+
3
+ from clinear.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
clinear/auth.py ADDED
@@ -0,0 +1,43 @@
1
+ """Authentication helpers.
2
+
3
+ Token resolution lives in `config.py`. This module wraps viewer caching
4
+ so we can resolve "me" without an extra API call when the user passes
5
+ `--assignee me`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+
12
+ from clinear.client import LinearClient
13
+ from clinear.graphql import queries
14
+ from clinear.models.user import User
15
+
16
+
17
+ _viewer_cache: User | None = None
18
+ _viewer_lock = asyncio.Lock()
19
+
20
+
21
+ async def get_viewer(client: LinearClient, *, refresh: bool = False) -> User:
22
+ """Return the currently-authenticated user.
23
+
24
+ Cached per-process so repeated --assignee me resolutions don't
25
+ cost an API call each.
26
+ """
27
+ global _viewer_cache
28
+ if _viewer_cache is not None and not refresh:
29
+ return _viewer_cache
30
+
31
+ async with _viewer_lock:
32
+ if _viewer_cache is not None and not refresh:
33
+ return _viewer_cache
34
+ _viewer_cache = await client.execute_as(
35
+ User, queries.VIEWER, path=["viewer"], operation="Viewer"
36
+ )
37
+ return _viewer_cache
38
+
39
+
40
+ def reset_viewer_cache() -> None:
41
+ """Reset cached viewer (used after logout/token change)."""
42
+ global _viewer_cache
43
+ _viewer_cache = None
clinear/cli.py ADDED
@@ -0,0 +1,122 @@
1
+ """Root CLI entrypoint.
2
+
3
+ Defines the top-level Typer app, global flags, error handling, and
4
+ registers all subcommand modules.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import sys
10
+ from typing import Optional
11
+
12
+ import typer
13
+
14
+ from clinear import __version__
15
+ from clinear.cli_state import CLIState, set_state
16
+ from clinear.commands.auth_cmd import auth_app, me_app
17
+ from clinear.commands.comment import comment_app
18
+ from clinear.commands.cycle import cycle_app
19
+ from clinear.commands.init import init_app
20
+ from clinear.commands.issue import issue_app
21
+ from clinear.commands.label import label_app
22
+ from clinear.commands.project import project_app
23
+ from clinear.commands.raw import raw_app
24
+ from clinear.commands.team import team_app
25
+ from clinear.config import load_config, resolve_token
26
+ from clinear.errors import ClinearError
27
+ from clinear.models.enums import OutputFormat
28
+ from clinear.output import emit_error
29
+
30
+
31
+ app = typer.Typer(
32
+ name="clinear",
33
+ help="Type-safe Linear CLI built on Pydantic v2 + httpx + Typer",
34
+ no_args_is_help=True,
35
+ pretty_exceptions_enable=False,
36
+ )
37
+
38
+
39
+ def _version_cb(value: bool) -> None:
40
+ if value:
41
+ typer.echo(f"clinear {__version__}")
42
+ raise typer.Exit()
43
+
44
+
45
+ @app.callback()
46
+ def main(
47
+ ctx: typer.Context,
48
+ token: Optional[str] = typer.Option(None, "--token", envvar=None, help="Linear API token (overrides $LINEAR_TOKEN)"),
49
+ output: OutputFormat = typer.Option(OutputFormat.HUMAN, "--output", "-o", help="Output format"),
50
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Print GraphQL operations to stderr"),
51
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-essential output"),
52
+ no_color: bool = typer.Option(False, "--no-color", help="Disable ANSI colors"),
53
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print mutations without executing"),
54
+ timeout: float = typer.Option(30.0, "--timeout", help="HTTP timeout in seconds"),
55
+ version: Optional[bool] = typer.Option(
56
+ None, "--version", callback=_version_cb, is_eager=True, help="Show version and exit"
57
+ ),
58
+ ) -> None:
59
+ """Global flags applied before any subcommand."""
60
+ # Resolve config + token
61
+ config = load_config()
62
+
63
+ # Token resolution: only required for commands that hit the API.
64
+ # We defer the actual AuthError to first API call to allow `--help` etc.
65
+ try:
66
+ resolved_token = resolve_token(token, config)
67
+ except ClinearError:
68
+ resolved_token = ""
69
+
70
+ state = CLIState(
71
+ token=resolved_token,
72
+ config=config,
73
+ output=output,
74
+ verbose=verbose,
75
+ quiet=quiet,
76
+ no_color=no_color,
77
+ dry_run=dry_run,
78
+ timeout=timeout,
79
+ )
80
+ set_state(state)
81
+
82
+
83
+ # Register subcommands
84
+ app.add_typer(me_app, name="me")
85
+ app.add_typer(auth_app, name="auth")
86
+ app.add_typer(init_app, name="init")
87
+ app.add_typer(team_app, name="team")
88
+ app.add_typer(issue_app, name="issue")
89
+ app.add_typer(project_app, name="project")
90
+ app.add_typer(cycle_app, name="cycle")
91
+ app.add_typer(comment_app, name="comment")
92
+ app.add_typer(label_app, name="label")
93
+ app.add_typer(raw_app, name="raw")
94
+
95
+
96
+ def _entry() -> None:
97
+ """Wrap typer_app() with consistent error handling and exit codes."""
98
+ try:
99
+ typer_app()
100
+ except ClinearError as e:
101
+ from clinear.cli_state import get_state
102
+ state = get_state()
103
+ if state.output == OutputFormat.JSON:
104
+ import json as _json
105
+ print(_json.dumps(e.to_dict(), indent=2), file=sys.stderr)
106
+ else:
107
+ emit_error(e.message, hint=e.hint)
108
+ sys.exit(int(e.exit_code))
109
+ except KeyboardInterrupt:
110
+ emit_error("Interrupted")
111
+ sys.exit(130)
112
+
113
+
114
+ # Public callable for `clinear` script entry point in pyproject.toml.
115
+ # Renamed to `typer_app` internally so the wrapper is what pip-installed
116
+ # `clinear` actually invokes.
117
+ typer_app = app
118
+ app = _entry # type: ignore[assignment]
119
+
120
+
121
+ if __name__ == "__main__":
122
+ _entry()
clinear/cli_state.py ADDED
@@ -0,0 +1,50 @@
1
+ """Shared CLI state — populated by the root callback, read by subcommands.
2
+
3
+ Typer doesn't have a great way to share state across commands without going
4
+ through Context. We use a small module-level container to keep subcommands
5
+ clean.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from clinear.client import LinearClient
13
+ from clinear.config import Config
14
+ from clinear.models.enums import OutputFormat
15
+
16
+
17
+ @dataclass
18
+ class CLIState:
19
+ """Mutable per-invocation state."""
20
+
21
+ token: str = ""
22
+ config: Config = field(default_factory=Config)
23
+ output: OutputFormat = OutputFormat.HUMAN
24
+ verbose: bool = False
25
+ quiet: bool = False
26
+ no_color: bool = False
27
+ dry_run: bool = False
28
+ timeout: float = 30.0
29
+
30
+
31
+ _state = CLIState()
32
+
33
+
34
+ def get_state() -> CLIState:
35
+ return _state
36
+
37
+
38
+ def set_state(state: CLIState) -> None:
39
+ global _state
40
+ _state = state
41
+
42
+
43
+ def build_client(state: CLIState | None = None) -> LinearClient:
44
+ """Construct a LinearClient configured from the current CLI state."""
45
+ s = state or _state
46
+ return LinearClient(
47
+ token=s.token,
48
+ timeout=s.timeout,
49
+ verbose=s.verbose,
50
+ )
clinear/client.py ADDED
@@ -0,0 +1,265 @@
1
+ """Async GraphQL client for the Linear API.
2
+
3
+ Design goals:
4
+ - Single httpx.AsyncClient with sensible defaults (HTTP/2, timeout, retries).
5
+ - Token never logged in plaintext (redacted in verbose mode).
6
+ - Linear errors lifted into typed exceptions.
7
+ - Rate limit awareness (X-RateLimit-* headers honored).
8
+ - Cursor-based pagination helper.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import logging
16
+ from typing import Any, AsyncIterator, TypeVar
17
+
18
+ import httpx
19
+ from pydantic import BaseModel
20
+
21
+ from clinear import __version__
22
+ from clinear.config import redact_token
23
+ from clinear.errors import (
24
+ APIError,
25
+ AuthError,
26
+ NetworkError,
27
+ RateLimitError,
28
+ ValidationError,
29
+ )
30
+
31
+ logger = logging.getLogger("clinear.client")
32
+
33
+ LINEAR_API_URL = "https://api.linear.app/graphql"
34
+
35
+ T = TypeVar("T", bound=BaseModel)
36
+
37
+
38
+ class LinearClient:
39
+ """Async GraphQL client for the Linear API."""
40
+
41
+ def __init__(
42
+ self,
43
+ token: str,
44
+ *,
45
+ base_url: str = LINEAR_API_URL,
46
+ timeout: float = 30.0,
47
+ verbose: bool = False,
48
+ ) -> None:
49
+ self._token = token
50
+ self._base_url = base_url
51
+ self._verbose = verbose
52
+ self._http = httpx.AsyncClient(
53
+ http2=False, # httpx[http2] is a separate dep — keep deps minimal
54
+ timeout=timeout,
55
+ headers={
56
+ "Authorization": token,
57
+ "Content-Type": "application/json",
58
+ "User-Agent": f"clinear/{__version__} (+https://github.com/Clover/clinear)",
59
+ },
60
+ )
61
+
62
+ async def __aenter__(self) -> "LinearClient":
63
+ return self
64
+
65
+ async def __aexit__(self, *_: Any) -> None:
66
+ await self.close()
67
+
68
+ async def close(self) -> None:
69
+ await self._http.aclose()
70
+
71
+ # ------------------------------------------------------------------
72
+ # Core request method
73
+ # ------------------------------------------------------------------
74
+
75
+ async def execute(
76
+ self,
77
+ query: str,
78
+ variables: dict[str, Any] | None = None,
79
+ *,
80
+ operation: str | None = None,
81
+ ) -> dict[str, Any]:
82
+ """Execute a GraphQL query/mutation. Returns the `data` portion.
83
+
84
+ Raises:
85
+ AuthError: 401
86
+ RateLimitError: 429
87
+ APIError: GraphQL errors in response
88
+ NetworkError: transport-level failures
89
+ """
90
+ payload: dict[str, Any] = {"query": query}
91
+ if variables:
92
+ # Drop None values — Linear's API rejects explicit nulls in many inputs
93
+ payload["variables"] = _strip_none(variables)
94
+ if operation:
95
+ payload["operationName"] = operation
96
+
97
+ if self._verbose:
98
+ logger.info(
99
+ "POST %s [token=%s] op=%s vars=%s",
100
+ self._base_url,
101
+ redact_token(self._token),
102
+ operation or "<anon>",
103
+ json.dumps(payload.get("variables", {}), default=str)[:200],
104
+ )
105
+
106
+ try:
107
+ resp = await self._http.post(self._base_url, json=payload)
108
+ except httpx.TimeoutException as e:
109
+ raise NetworkError(f"Request timed out: {e}") from e
110
+ except httpx.RequestError as e:
111
+ raise NetworkError(f"Network error: {e}") from e
112
+
113
+ # Handle HTTP-level errors
114
+ if resp.status_code == 401:
115
+ raise AuthError(
116
+ "Linear rejected the token (HTTP 401)",
117
+ hint="Generate a new token at https://linear.app/settings/api",
118
+ )
119
+ if resp.status_code == 429:
120
+ retry = resp.headers.get("Retry-After")
121
+ raise RateLimitError(
122
+ "Linear API rate limit exceeded",
123
+ retry_after=int(retry) if retry and retry.isdigit() else None,
124
+ )
125
+ if resp.status_code >= 500:
126
+ raise APIError(
127
+ f"Linear API returned HTTP {resp.status_code}",
128
+ hint="This is usually transient — retry in a few seconds",
129
+ )
130
+ if resp.status_code >= 400:
131
+ raise APIError(
132
+ f"HTTP {resp.status_code}: {resp.text[:500]}",
133
+ )
134
+
135
+ try:
136
+ body = resp.json()
137
+ except json.JSONDecodeError as e:
138
+ raise APIError(f"Non-JSON response from Linear: {e}") from e
139
+
140
+ # GraphQL-level errors
141
+ if errors := body.get("errors"):
142
+ messages = "; ".join(e.get("message", "<unknown>") for e in errors)
143
+ raise APIError(messages, errors=errors)
144
+
145
+ data = body.get("data")
146
+ if data is None:
147
+ raise APIError("Linear returned empty data")
148
+ return data
149
+
150
+ # ------------------------------------------------------------------
151
+ # Typed helpers
152
+ # ------------------------------------------------------------------
153
+
154
+ async def execute_as(
155
+ self,
156
+ model: type[T],
157
+ query: str,
158
+ variables: dict[str, Any] | None = None,
159
+ *,
160
+ path: list[str] | None = None,
161
+ operation: str | None = None,
162
+ ) -> T:
163
+ """Execute and validate the response against a Pydantic model.
164
+
165
+ path: list of dict keys to drill down to before parsing.
166
+ e.g. ["viewer"] turns {"viewer": {...}} into the viewer object.
167
+ """
168
+ data = await self.execute(query, variables, operation=operation)
169
+ node: Any = data
170
+ for key in path or []:
171
+ if not isinstance(node, dict) or key not in node:
172
+ raise ValidationError(
173
+ f"Expected key {key!r} in response path "
174
+ f"{'.'.join(path or [])}",
175
+ )
176
+ node = node[key]
177
+ try:
178
+ return model.model_validate(node)
179
+ except Exception as e:
180
+ raise ValidationError(
181
+ f"Response did not match {model.__name__}: {e}",
182
+ ) from e
183
+
184
+ async def execute_list(
185
+ self,
186
+ model: type[T],
187
+ query: str,
188
+ variables: dict[str, Any] | None = None,
189
+ *,
190
+ path: list[str],
191
+ operation: str | None = None,
192
+ ) -> list[T]:
193
+ """Execute and validate a connection.nodes list response.
194
+
195
+ path should end at the dict containing 'nodes'.
196
+ """
197
+ data = await self.execute(query, variables, operation=operation)
198
+ node: Any = data
199
+ for key in path:
200
+ if not isinstance(node, dict) or key not in node:
201
+ raise ValidationError(
202
+ f"Expected key {key!r} at path {'.'.join(path)}",
203
+ )
204
+ node = node[key]
205
+ if not isinstance(node, dict) or "nodes" not in node:
206
+ raise ValidationError(
207
+ "Expected a connection object with 'nodes' at path "
208
+ f"{'.'.join(path)}"
209
+ )
210
+ try:
211
+ return [model.model_validate(item) for item in node["nodes"]]
212
+ except Exception as e:
213
+ raise ValidationError(
214
+ f"List items did not match {model.__name__}: {e}",
215
+ ) from e
216
+
217
+ # ------------------------------------------------------------------
218
+ # Pagination
219
+ # ------------------------------------------------------------------
220
+
221
+ async def paginate(
222
+ self,
223
+ query: str,
224
+ variables: dict[str, Any],
225
+ *,
226
+ path: list[str],
227
+ page_size: int = 50,
228
+ ) -> AsyncIterator[dict[str, Any]]:
229
+ """Yield every node across all pages of a connection."""
230
+ cursor: str | None = None
231
+ while True:
232
+ vars_with_cursor = {**variables, "first": page_size}
233
+ if cursor:
234
+ vars_with_cursor["after"] = cursor
235
+ data = await self.execute(query, vars_with_cursor)
236
+ node: Any = data
237
+ for key in path:
238
+ node = node[key]
239
+ for item in node.get("nodes", []):
240
+ yield item
241
+ page_info = node.get("pageInfo") or {}
242
+ if not page_info.get("hasNextPage"):
243
+ break
244
+ cursor = page_info.get("endCursor")
245
+ if not cursor:
246
+ break
247
+
248
+
249
+ def _strip_none(d: dict[str, Any]) -> dict[str, Any]:
250
+ """Recursively remove None values from a dict for GraphQL variables."""
251
+ if not isinstance(d, dict):
252
+ return d
253
+ out: dict[str, Any] = {}
254
+ for k, v in d.items():
255
+ if v is None:
256
+ continue
257
+ if isinstance(v, dict):
258
+ stripped = _strip_none(v)
259
+ if stripped:
260
+ out[k] = stripped
261
+ elif isinstance(v, list):
262
+ out[k] = [_strip_none(item) if isinstance(item, dict) else item for item in v]
263
+ else:
264
+ out[k] = v
265
+ return out
@@ -0,0 +1,4 @@
1
+ """Command modules.
2
+
3
+ Each file exports a Typer sub-app registered in clinear.cli.
4
+ """
@@ -0,0 +1,42 @@
1
+ """`clinear me` and `clinear auth ...` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ import typer
8
+
9
+ from clinear.auth import get_viewer
10
+ from clinear.cli_state import build_client, get_state
11
+ from clinear.models.enums import OutputFormat
12
+ from clinear.output import render
13
+
14
+ me_app = typer.Typer(help="Show the currently-authenticated Linear user")
15
+ auth_app = typer.Typer(help="Authentication operations")
16
+
17
+
18
+ @me_app.callback(invoke_without_command=True)
19
+ def me(ctx: typer.Context) -> None:
20
+ """Show the currently-authenticated user (alias for `auth status`)."""
21
+ if ctx.invoked_subcommand is not None:
22
+ return
23
+ asyncio.run(_run_me())
24
+
25
+
26
+ @auth_app.command("status")
27
+ def status() -> None:
28
+ """Show the currently-authenticated user and token source."""
29
+ asyncio.run(_run_me())
30
+
31
+
32
+ @auth_app.command("whoami")
33
+ def whoami() -> None:
34
+ """Alias of `auth status`."""
35
+ asyncio.run(_run_me())
36
+
37
+
38
+ async def _run_me() -> None:
39
+ state = get_state()
40
+ async with build_client(state) as client:
41
+ viewer = await get_viewer(client)
42
+ render(viewer, fmt=state.output, title="Viewer")